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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user