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
+14 -1
View File
@@ -41,7 +41,20 @@
"rateLimited": "طلبات الرمز كثيرة جداً. انتظر ساعة كحد أقصى أو تواصل مع الدعم.",
"notFound": "لا يوجد حساب بهذا الرقم.",
"smsFailed": "فشل إرسال الرسالة. حاول مرة أخرى.",
"invalidOtp": "رمز التحقق غير صحيح أو منتهٍ."
"invalidOtp": "رمز التحقق غير صحيح أو منتهٍ.",
"chooseCafe": "اختر المقهى",
"chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.",
"createNewCafe": "إنشاء مقهى جديد",
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟"
},
"roles": {
"owner": "المالك",
"manager": "المدير",
"cashier": "أمين الصندوق",
"waiter": "النادل",
"chef": "الطاهي",
"delivery": "عامل التوصيل",
"unknown": "مستخدم"
},
"nav": {
"aria": "القائمة الرئيسية",
+21 -2
View File
@@ -52,7 +52,20 @@
"noAccount": "Don't have an account?",
"registerLink": "Register",
"alreadyRegistered": "This phone is already registered. Please sign in.",
"registrationExpired": "Registration session expired. Please try again."
"registrationExpired": "Registration session expired. Please try again.",
"chooseCafe": "Choose a café",
"chooseCafeSubtitle": "This number has access to several cafés. Pick one to continue.",
"createNewCafe": "Create a new café",
"createNewCafeHint": "Want to start your own café with this number?"
},
"roles": {
"owner": "Owner",
"manager": "Manager",
"cashier": "Cashier",
"waiter": "Waiter",
"chef": "Chef",
"delivery": "Delivery",
"unknown": "User"
},
"nav": {
"aria": "Main navigation",
@@ -93,7 +106,13 @@
"offline": "Offline",
"activePlan": "Active plan",
"editCafeSettings": "Café settings",
"viewSubscription": "Plan & billing"
"viewSubscription": "Plan & billing",
"switchCafe": "Switch café",
"currentCafe": "Current café",
"otherCafes": "Other cafés",
"createNewCafe": "Create a new café",
"openMenu": "Menu",
"switchCafeError": "Could not switch café. Please try again."
},
"overview": {
"title": "Home",
+21 -2
View File
@@ -52,7 +52,20 @@
"noAccount": "حساب ندارید؟",
"registerLink": "ثبت‌نام",
"alreadyRegistered": "این شماره قبلاً ثبت‌نام کرده است. لطفاً وارد شوید.",
"registrationExpired": "زمان ثبت‌نام منقضی شد. دوباره تلاش کنید."
"registrationExpired": "زمان ثبت‌نام منقضی شد. دوباره تلاش کنید.",
"chooseCafe": "انتخاب کافه",
"chooseCafeSubtitle": "این شماره به چند کافه دسترسی دارد. یکی را انتخاب کنید.",
"createNewCafe": "ایجاد کافه جدید",
"createNewCafeHint": "می‌خواهید کافه خودتان را با همین شماره راه‌اندازی کنید؟"
},
"roles": {
"owner": "مالک",
"manager": "مدیر",
"cashier": "صندوق‌دار",
"waiter": "گارسون",
"chef": "آشپز",
"delivery": "پیک",
"unknown": "کاربر"
},
"nav": {
"aria": "منوی اصلی",
@@ -93,7 +106,13 @@
"offline": "آفلاین",
"activePlan": "پلن فعال",
"editCafeSettings": "تنظیمات کافه",
"viewSubscription": "اشتراک و پلن"
"viewSubscription": "اشتراک و پلن",
"switchCafe": "تغییر کافه",
"currentCafe": "کافه فعلی",
"otherCafes": "کافه‌های دیگر",
"createNewCafe": "ایجاد کافه جدید",
"openMenu": "منو",
"switchCafeError": "تغییر کافه ناموفق بود. دوباره تلاش کنید."
},
"overview": {
"title": "خانه",
@@ -9,6 +9,7 @@ import { useAuthStore } from "@/lib/stores/auth.store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { OtpInput } from "@/components/ui/otp-input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function LoginPage() {
@@ -113,18 +114,14 @@ export default function LoginPage() {
}}
>
<LabeledField label={t("otp")} htmlFor="login-otp">
<Input
id="login-otp"
<OtpInput
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("otpPlaceholder")}
maxLength={6}
dir="ltr"
className="text-center tracking-widest"
autoComplete="one-time-code"
onChange={setCode}
autoFocus
disabled={loading}
/>
</LabeledField>
<Button type="submit" className="w-full" disabled={loading}>
<Button type="submit" className="w-full" disabled={loading || code.length < 6}>
{loading ? "..." : t("verify")}
</Button>
<Button
@@ -11,6 +11,7 @@ import { useAuthStore } from "@/lib/stores/auth.store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { OtpInput } from "@/components/ui/otp-input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
function RegisterForm() {
@@ -118,18 +119,14 @@ function RegisterForm() {
}}
>
<LabeledField label={t("otp")} htmlFor="reg-otp">
<Input
id="reg-otp"
<OtpInput
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("otpPlaceholder")}
maxLength={6}
dir="ltr"
className="text-center tracking-widest"
autoComplete="one-time-code"
onChange={setCode}
autoFocus
disabled={loading}
/>
</LabeledField>
<Button type="submit" className="w-full" disabled={loading}>
<Button type="submit" className="w-full" disabled={loading || code.length < 6}>
{loading ? "..." : t("createAccount")}
</Button>
<Button
@@ -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>
);
}
+4 -2
View File
@@ -76,7 +76,9 @@ export async function apiGetPaged<T>(url: string): Promise<{ items: T[]; meta: P
export class ApiClientError extends Error {
constructor(
public readonly code: string,
message: string
message: string,
/** Payload returned alongside a non-success response (e.g. CHOOSE_CAFE choices). */
public readonly payload?: unknown
) {
super(message);
this.name = "ApiClientError";
@@ -87,7 +89,7 @@ export async function apiPost<T, B = unknown>(url: string, body?: B): Promise<T>
const { data } = await api.post<ApiResponse<T>>(url, body);
if (!data.success || data.data === undefined) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
throw new ApiClientError(code, data.error?.message ?? "Request failed", data.data);
}
return data.data;
}
+13
View File
@@ -4,6 +4,13 @@ export interface ApiResponse<T> {
error?: { code: string; message: string; field?: string };
}
export interface CafeMembership {
cafeId: string;
cafeName: string;
role: string;
planTier: string;
}
export interface AuthTokenResponse {
accessToken: string;
refreshToken: string;
@@ -15,6 +22,12 @@ export interface AuthTokenResponse {
language: string;
actor?: string;
branchId?: string | null;
memberships?: CafeMembership[] | null;
}
/** Returned (in the data field) when a phone belongs to multiple cafés. */
export interface CafeChoicesResponse {
cafes: CafeMembership[];
}
export interface MenuCategory {
+45
View File
@@ -0,0 +1,45 @@
/**
* Maps backend EmployeeRole names to i18n keys under the "roles" namespace.
* Backend enum: Owner, Manager, Cashier, Waiter, Chef, Delivery.
*/
export type EmployeeRoleName =
| "Owner"
| "Manager"
| "Cashier"
| "Waiter"
| "Chef"
| "Delivery";
export const ROLE_KEYS: Record<string, string> = {
Owner: "owner",
Manager: "manager",
Cashier: "cashier",
Waiter: "waiter",
Chef: "chef",
Delivery: "delivery",
};
export function roleKey(role: string | undefined | null): string {
if (!role) return "unknown";
return ROLE_KEYS[role] ?? "unknown";
}
/** Tailwind classes for a colored role badge. */
export function roleBadgeClass(role: string | undefined | null): string {
switch (role) {
case "Owner":
return "bg-primary/10 text-primary border-primary/30";
case "Manager":
return "bg-violet-50 text-violet-700 border-violet-200";
case "Cashier":
return "bg-blue-50 text-blue-700 border-blue-200";
case "Chef":
return "bg-amber-50 text-amber-700 border-amber-200";
case "Waiter":
return "bg-emerald-50 text-emerald-700 border-emerald-200";
case "Delivery":
return "bg-orange-50 text-orange-700 border-orange-200";
default:
return "bg-muted text-muted-foreground border-border";
}
}