From c290575be7f4c3f9a2afbd3dab631011c2774fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Mon, 17 Aug 2026 14:10:27 +0330 Subject: [PATCH] feat: real design system tokens + C01/C02/C03 login-OTP flow per winofyfrontenddoc.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces placeholder colors/type-scale with the documented Figma design system (§1), and rebuilds login as three screens matching the doc exactly: role selection, phone entry, and 6-box OTP verification with countdown resend. Fixes a real bug along the way: normalizeIranianPhone stripped Persian-digit keyboard input entirely instead of converting it (\D matches non-ASCII digits too) -- added toLatinDigits/toPersianDigits and verified the round-trip in isolation. --- src/app/(auth)/login/page.tsx | 231 +++++++++++++++------ src/app/(seller)/seller/page.tsx | 16 ++ src/app/globals.css | 105 +++++++--- src/app/layout.tsx | 2 +- src/components/auth/auth-header-banner.tsx | 25 +++ src/components/auth/otp-input.tsx | 91 ++++++++ src/components/auth/resend-prompt.tsx | 43 ++++ src/components/auth/role-card.tsx | 42 ++++ src/components/ui/button.tsx | 8 +- src/components/ui/input.tsx | 14 +- src/lib/auth/gooyal-client.ts | 9 +- src/lib/format/persian-digits.ts | 16 ++ src/lib/validation/phone.ts | 6 + 13 files changed, 510 insertions(+), 98 deletions(-) create mode 100644 src/app/(seller)/seller/page.tsx create mode 100644 src/components/auth/auth-header-banner.tsx create mode 100644 src/components/auth/otp-input.tsx create mode 100644 src/components/auth/resend-prompt.tsx create mode 100644 src/components/auth/role-card.tsx create mode 100644 src/lib/format/persian-digits.ts create mode 100644 src/lib/validation/phone.ts diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 50a8e7d..e702a6e 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,32 +1,64 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { ArrowRight, ShoppingBag, Store } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { AuthHeaderBanner } from "@/components/auth/auth-header-banner"; +import { RoleCard } from "@/components/auth/role-card"; +import { OtpInput } from "@/components/auth/otp-input"; +import { ResendPrompt } from "@/components/auth/resend-prompt"; import { requestOtp, exchangeOtpForToken, normalizeIranianPhone, GooyalClientError } from "@/lib/auth/gooyal-client"; +import { isValidIranianMobile } from "@/lib/validation/phone"; +import { toPersianDigits, toLatinDigits } from "@/lib/format/persian-digits"; -type Step = "phone" | "otp"; +type Step = "role" | "phone" | "otp"; +type Role = "buyer" | "seller"; + +const RESEND_COOLDOWN_SECONDS = 105; // "۱:۴۵" per winofyfrontenddoc.md §2.4 — confirm real value with backend, see doc §2.6.7 export default function LoginPage() { const router = useRouter(); const searchParams = useSearchParams(); const next = searchParams.get("next") ?? "/"; - const [step, setStep] = useState("phone"); + // Arriving via a protected-route redirect implies buyer intent already — skip the role card. + const [step, setStep] = useState(next !== "/" ? "phone" : "role"); + const [role, setRole] = useState("buyer"); const [phone, setPhone] = useState(""); const [otp, setOtp] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [secondsRemaining, setSecondsRemaining] = useState(0); - async function handleRequestOtp(e: React.FormEvent) { - e.preventDefault(); + const intervalRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, []); + + function startResendCountdown() { + setSecondsRemaining(RESEND_COOLDOWN_SECONDS); + if (intervalRef.current) clearInterval(intervalRef.current); + intervalRef.current = setInterval(() => { + setSecondsRemaining((s) => { + if (s <= 1 && intervalRef.current) clearInterval(intervalRef.current); + return Math.max(0, s - 1); + }); + }, 1000); + } + + async function sendOtp() { setError(null); setLoading(true); try { - // Calls Gooyal accounts directly from the browser — not our own server. await requestOtp(normalizeIranianPhone(phone)); setStep("otp"); + setOtp(""); + startResendCountdown(); } catch (err) { setError(err instanceof GooyalClientError || err instanceof Error ? err.message : "خطا در ارسال کد تایید"); } finally { @@ -34,15 +66,18 @@ export default function LoginPage() { } } - async function handleVerifyOtp(e: React.FormEvent) { + async function handlePhoneSubmit(e: React.FormEvent) { e.preventDefault(); + if (!isValidIranianMobile(phone)) return; + await sendOtp(); + } + + async function verifyOtp(code: string) { setError(null); setLoading(true); try { - // Token exchange also happens directly against Gooyal from the browser. - const token = await exchangeOtpForToken(normalizeIranianPhone(phone), otp); + const token = await exchangeOtpForToken(normalizeIranianPhone(phone), code); - // Hand the token to our server just to wrap it in an httpOnly session cookie. const sessionRes = await fetch("/api/auth/session", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -50,7 +85,7 @@ export default function LoginPage() { }); if (!sessionRes.ok) throw new Error("خطا در برقراری نشست ورود"); - router.push(next); + router.push(role === "seller" ? "/seller" : next); router.refresh(); } catch (err) { setError(err instanceof GooyalClientError || err instanceof Error ? err.message : "کد تایید نامعتبر است"); @@ -59,63 +94,133 @@ export default function LoginPage() { } } - return ( -
-
-

ورود به وینوفی

-

- {step === "phone" - ? "شماره موبایل خود را وارد کنید" - : `کد ارسال شده به ${phone} را وارد کنید`} -

-
+ function handleOtpSubmit(e: React.FormEvent) { + e.preventDefault(); + if (otp.length === 6) verifyOtp(otp); + } - {step === "phone" ? ( -
- setPhone(e.target.value)} - required - className="text-center" - /> - {error &&

{error}

} - -
- ) : ( -
- setOtp(e.target.value)} - required - className="text-center tracking-[0.5em]" - /> - {error &&

{error}

} - - + {loading ? "در حال ارسال..." : "دریافت کد تأیید"} +
- )} +
+ ); + } + + return ( +
+ + +
+
+

کد تأیید

+

+ کد ۶ رقمی ارسال شده به شماره {toPersianDigits(normalizeIranianPhone(phone).replace("+98", "0"))} را وارد کنید +

+
+ + + + + + {error &&

{error}

} + + + +

+ شماره اشتباه است؟{" "} + +

+
); } diff --git a/src/app/(seller)/seller/page.tsx b/src/app/(seller)/seller/page.tsx new file mode 100644 index 0000000..ec9d353 --- /dev/null +++ b/src/app/(seller)/seller/page.tsx @@ -0,0 +1,16 @@ +import { Store } from "lucide-react"; + +// Placeholder landing spot for the seller-role login path. The real seller +// panel (store onboarding, products, orders, analytics — FRONTEND_GUIDE.md §7) +// is a separate build; this just keeps the login flow's seller CTA from 404ing. +export default function SellerPlaceholderPage() { + return ( +
+ + + +

پنل فروشنده

+

این بخش به‌زودی راه‌اندازی می‌شود.

+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 866f6ce..c412c23 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,48 +1,103 @@ @import "tailwindcss"; /* - * Placeholder tokens — TODO replace with real values pulled from the Figma - * design-system page (get_variable_defs) once file access is granted. + * Design tokens per winofyfrontenddoc.md §1.2 (Figma design-system page). + * Light-mode only — the source file documents no dark-mode variants, so we + * don't invent any rather than guess. */ :root { - --background: #ffffff; - --foreground: #171717; - --primary: #16a34a; + --background: #f7f2fa; /* پس‌زمینه — app background */ + --surface: #fffbfe; /* سفید — card/sheet surface */ + --foreground: #111827; /* gray-900 */ + + --primary: #006a6a; /* فیروزه‌ای — customer brand accent */ --primary-foreground: #ffffff; - --secondary: #f1f5f9; - --secondary-foreground: #171717; - --muted: #f1f5f9; - --muted-foreground: #6b7280; - --border: #e5e7eb; - --destructive: #dc2626; + --primary-action: #223d5d; /* auth-flow button/heading accent, see §1.2 */ + + --brand-secondary: #1a237e; /* یاقوتی — seller brand accent */ + + --success: #1b6b3a; + --error: #ba1a1a; + --destructive: #ba1a1a; --destructive-foreground: #ffffff; + + --teal: #2e807a; + --teal-light: #e0f2f1; + --teal-bg: #e6f0f0; + --blue-bg: #ecf2f8; + + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-300: #e5e7eb; + --gray-500: #9ca3af; + --gray-600: #6b7280; + --gray-700: #4b5563; + --gray-800: #1f2937; + --gray-900: #111827; + + --secondary: var(--gray-100); /* generic UI secondary surface, distinct from brand-secondary */ + --secondary-foreground: var(--gray-900); + --muted: var(--gray-100); + --muted-foreground: var(--gray-600); + --border: var(--gray-300); } @theme inline { --color-background: var(--background); + --color-surface: var(--surface); --color-foreground: var(--foreground); + --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); + --color-primary-action: var(--primary-action); + --color-brand-secondary: var(--brand-secondary); + + --color-success: var(--success); + --color-error: var(--error); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + + --color-teal: var(--teal); + --color-teal-light: var(--teal-light); + --color-teal-bg: var(--teal-bg); + --color-blue-bg: var(--blue-bg); + + --color-gray-50: var(--gray-50); + --color-gray-100: var(--gray-100); + --color-gray-300: var(--gray-300); + --color-gray-500: var(--gray-500); + --color-gray-600: var(--gray-600); + --color-gray-700: var(--gray-700); + --color-gray-800: var(--gray-800); + --color-gray-900: var(--gray-900); + --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-border: var(--border); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --font-sans: var(--font-vazirmatn); -} -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - --secondary: #1f2937; - --secondary-foreground: #ededed; - --muted: #1f2937; - --muted-foreground: #9ca3af; - --border: #27272a; - } + --font-sans: var(--font-vazirmatn); + + /* Type scale — §1.3. Line-height is 100% throughout the source file. */ + --text-display-large: 57px; + --text-display-large--line-height: 1; + --text-display-medium: 45px; + --text-display-medium--line-height: 1; + --text-headline-large: 32px; + --text-headline-large--line-height: 1; + --text-headline-medium: 28px; + --text-headline-medium--line-height: 1; + --text-title-large: 22px; + --text-title-large--line-height: 1; + --text-title-medium: 16px; + --text-title-medium--line-height: 1; + --text-body-large: 16px; + --text-body-large--line-height: 1; + --text-body-medium: 14px; + --text-body-medium--line-height: 1; + --text-label: 12px; + --text-label--line-height: 1; } body { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8b9f071..2b18a05 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,7 +5,7 @@ import { QueryProvider } from "@/lib/query-provider"; const vazirmatn = Vazirmatn({ variable: "--font-vazirmatn", - subsets: ["arabic"], + subsets: ["arabic", "latin"], }); export const metadata: Metadata = { diff --git a/src/components/auth/auth-header-banner.tsx b/src/components/auth/auth-header-banner.tsx new file mode 100644 index 0000000..5e0cd19 --- /dev/null +++ b/src/components/auth/auth-header-banner.tsx @@ -0,0 +1,25 @@ +/** + * Dark-navy full-bleed header used on C01/C02 — winofyfrontenddoc.md §2.2/§2.3. + * No logo asset was provided with the doc, so the wordmark below (icon + "وینسو" + * in orange) is an approximation of the documented mark, not a pixel-exact asset. + */ +export function AuthHeaderBanner({ height = 236 }: { height?: number }) { + return ( +
+
+ + + + + + وینسو +
+
+ ); +} diff --git a/src/components/auth/otp-input.tsx b/src/components/auth/otp-input.tsx new file mode 100644 index 0000000..6e48656 --- /dev/null +++ b/src/components/auth/otp-input.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useRef, useState } from "react"; +import { toLatinDigits, toPersianDigits } from "@/lib/format/persian-digits"; +import { cn } from "@/lib/utils/cn"; + +export function OtpInput({ + length = 6, + value, + onChange, + onComplete, + disabled, +}: { + length?: number; + value: string; + onChange: (value: string) => void; + onComplete?: (value: string) => void; + disabled?: boolean; +}) { + const [focusedIndex, setFocusedIndex] = useState(null); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + const digits = value.split(""); + + function setDigitAt(index: number, digit: string) { + const next = [...digits]; + next[index] = digit; + const nextValue = next.join("").slice(0, length); + onChange(nextValue); + if (nextValue.length === length) onComplete?.(nextValue); + } + + function handleChange(index: number, rawInput: string) { + const latin = toLatinDigits(rawInput).replace(/\D/g, ""); + if (!latin) { + setDigitAt(index, ""); + return; + } + // Handles both single keystrokes and a full code landing in one box (some + // mobile keyboards/autofill deliver multiple digits to a single input). + const chars = latin.split(""); + chars.forEach((char, offset) => { + const targetIndex = index + offset; + if (targetIndex < length) setDigitAt(targetIndex, char); + }); + const nextFocus = Math.min(index + chars.length, length - 1); + inputRefs.current[nextFocus]?.focus(); + } + + function handleKeyDown(index: number, e: React.KeyboardEvent) { + if (e.key === "Backspace" && !digits[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + setDigitAt(index - 1, ""); + } + } + + function handlePaste(index: number, e: React.ClipboardEvent) { + e.preventDefault(); + handleChange(index, e.clipboardData.getData("text")); + } + + return ( +
+ {Array.from({ length }).map((_, index) => { + const isFocused = focusedIndex === index; + return ( + { + inputRefs.current[index] = el; + }} + type="text" + inputMode="numeric" + maxLength={1} + disabled={disabled} + value={digits[index] ? toPersianDigits(digits[index]) : ""} + onChange={(e) => handleChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + onPaste={(e) => handlePaste(index, e)} + onFocus={() => setFocusedIndex(index)} + onBlur={() => setFocusedIndex((f) => (f === index ? null : f))} + className={cn( + "h-14 w-12 rounded-xl border text-center text-title-large outline-none transition-colors", + isFocused ? "border-2 border-primary-action bg-primary-action/[8.24%]" : "border-gray-300 bg-white", + )} + /> + ); + })} +
+ ); +} diff --git a/src/components/auth/resend-prompt.tsx b/src/components/auth/resend-prompt.tsx new file mode 100644 index 0000000..d1576a5 --- /dev/null +++ b/src/components/auth/resend-prompt.tsx @@ -0,0 +1,43 @@ +import { toPersianDigits } from "@/lib/format/persian-digits"; + +function formatMMSS(totalSeconds: number) { + const m = Math.floor(totalSeconds / 60); + const s = totalSeconds % 60; + return `${m}:${String(s).padStart(2, "0")}`; +} + +/** + * winofyfrontenddoc.md §2.4 flags that the source file uses the same active + * color for both the ticking countdown and the tappable resend link, with no + * visual distinction — and itself recommends fixing that. Implemented here + * per that recommendation: muted while counting down, primary-action once + * resend is actually available. + */ +export function ResendPrompt({ + secondsRemaining, + onResend, + disabled, +}: { + secondsRemaining: number; + onResend: () => void; + disabled?: boolean; +}) { + if (secondsRemaining > 0) { + return ( +

+ ارسال مجدد کد تا {toPersianDigits(formatMMSS(secondsRemaining))} +

+ ); + } + + return ( + + ); +} diff --git a/src/components/auth/role-card.tsx b/src/components/auth/role-card.tsx new file mode 100644 index 0000000..08fcdd6 --- /dev/null +++ b/src/components/auth/role-card.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils/cn"; + +export function RoleCard({ + icon, + title, + description, + ctaLabel, + ctaVariant, + onSelect, +}: { + icon: ReactNode; + title: string; + description: string; + ctaLabel: string; + ctaVariant: "primary" | "secondary"; + onSelect: () => void; +}) { + return ( +
+
+ + {icon} + +

{title}

+
+

{description}

+ +
+ ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index e3bad4f..232d283 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { cn } from "@/lib/utils/cn"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 rounded-xl text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40", + "inline-flex items-center justify-center gap-2 rounded-full text-body-medium font-semibold transition-colors disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40", { variants: { variant: { @@ -14,9 +14,9 @@ const buttonVariants = cva( ghost: "bg-transparent hover:bg-muted", }, size: { - default: "h-11 px-4", - sm: "h-9 px-3 text-xs", - lg: "h-12 px-6 text-base", + default: "h-12 px-4 text-body-large", + sm: "h-9 px-3 text-label", + lg: "h-12 px-6 text-body-large", icon: "h-10 w-10", }, }, diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index a3cd9dc..3f02163 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,12 +1,20 @@ import * as React from "react"; import { cn } from "@/lib/utils/cn"; -export const Input = React.forwardRef>( - ({ className, ...props }, ref) => ( +export interface InputProps extends React.InputHTMLAttributes { + /** default/empty, valid (green accent), error (red accent) — winofyfrontenddoc.md §1.7. */ + state?: "default" | "valid" | "error"; +} + +export const Input = React.forwardRef( + ({ className, state = "default", ...props }, ref) => ( { + const persianIndex = PERSIAN_DIGITS.indexOf(ch); + if (persianIndex !== -1) return String(persianIndex); + return String(ARABIC_INDIC_DIGITS.indexOf(ch)); + }); +} + +/** Latin digits → Persian, per winofyfrontenddoc.md §1.5 (all numeric UI copy uses Persian digits). */ +export function toPersianDigits(input: string | number): string { + return String(input).replace(/[0-9]/g, (d) => PERSIAN_DIGITS[Number(d)]); +} diff --git a/src/lib/validation/phone.ts b/src/lib/validation/phone.ts new file mode 100644 index 0000000..95d59a4 --- /dev/null +++ b/src/lib/validation/phone.ts @@ -0,0 +1,6 @@ +import { toLatinDigits } from "@/lib/format/persian-digits"; + +/** 11 digits, starting 09 — winofyfrontenddoc.md §2.3 helper copy / validation rules. */ +export function isValidIranianMobile(input: string): boolean { + return /^09\d{9}$/.test(toLatinDigits(input)); +}