Add OTP login flow and multi-cafe role switching

Introduce an OTP input box on login/register, surface user roles and a
cafe chooser, add a dashboard switch button in the POS screen, and
register OTP validators explicitly to survive Docker layer caching.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-29 17:14:46 +03:30
parent 923a00b113
commit c68cca4f17
15 changed files with 364 additions and 44 deletions
@@ -7,6 +7,7 @@ import { useTranslations, useLocale } from "next-intl";
import {
ChevronLeft,
ChevronRight,
LayoutDashboard,
Minus,
Package,
Plus,
@@ -899,6 +900,19 @@ export function PosScreen() {
>
{t("modePay")}
</Button>
<div className="flex-1" />
{/* Dashboard shortcut — only visible to Owner / Manager */}
{isManager && (
<a
href="/"
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<LayoutDashboard className="size-4" />
<span className="hidden sm:inline">{cafeName}</span>
</a>
)}
</div>
{/* ── Pay mode ──────────────────────────────────────────────────────── */}
@@ -0,0 +1,96 @@
"use client";
import { useRef, KeyboardEvent, ClipboardEvent } from "react";
import { cn } from "@/lib/utils";
interface OtpInputProps {
value: string;
onChange: (value: string) => void;
length?: number;
disabled?: boolean;
autoFocus?: boolean;
}
export function OtpInput({
value,
onChange,
length = 6,
disabled = false,
autoFocus = false,
}: OtpInputProps) {
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
const digits = Array.from({ length }, (_, i) => value[i] ?? "");
const focus = (index: number) => {
inputsRef.current[index]?.focus();
};
const handleChange = (index: number, char: string) => {
// Accept only digits
const digit = char.replace(/\D/g, "").slice(-1);
const next = digits.map((d, i) => (i === index ? digit : d)).join("");
onChange(next);
if (digit && index < length - 1) focus(index + 1);
};
const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Backspace") {
if (digits[index]) {
const next = digits.map((d, i) => (i === index ? "" : d)).join("");
onChange(next);
} else if (index > 0) {
focus(index - 1);
}
} else if (e.key === "ArrowLeft") {
focus(Math.max(0, index - 1));
} else if (e.key === "ArrowRight") {
focus(Math.min(length - 1, index + 1));
}
};
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
if (!pasted) return;
onChange(pasted.padEnd(length, "").slice(0, length).replace(/ /g, ""));
// Actually just set what was pasted
const filled = pasted.slice(0, length);
onChange(filled);
focus(Math.min(filled.length, length - 1));
};
return (
<div
className="flex items-center justify-center gap-2"
dir="ltr"
>
{digits.map((digit, i) => (
<input
key={i}
ref={(el) => { inputsRef.current[i] = el; }}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={1}
value={digit}
disabled={disabled}
autoFocus={autoFocus && i === 0}
autoComplete={i === 0 ? "one-time-code" : "off"}
onChange={(e) => handleChange(i, e.target.value)}
onKeyDown={(e) => handleKeyDown(i, e)}
onPaste={handlePaste}
onFocus={(e) => e.target.select()}
className={cn(
"h-12 w-10 rounded-lg border-2 bg-background text-center text-lg font-semibold",
"transition-all duration-150 outline-none",
"border-border",
"focus:border-primary focus:ring-2 focus:ring-primary/20",
digit && "border-primary/60 bg-primary/5",
disabled && "cursor-not-allowed opacity-50",
)}
/>
))}
</div>
);
}