From a936be2143f5e8cdbbdcd17bba132dc8c075e8b9 Mon Sep 17 00:00:00 2001 From: Mahakisore7 Date: Sun, 20 Sep 2026 08:57:39 +0530 Subject: [PATCH] feat: premium redesign of the hero, auth, and in-app nav Replaces the plain hackathon-demo look with an industry-standard SaaS landing page: animated gradient blobs, staggered entrance animations, a gradient headline, a stat strip, and lucide-react icons throughout. The sign-in/sign-up panel gets icon-prefixed fields, a password visibility toggle, and a loading spinner on submit. The in-app top bar now merges nav + user menu into one sticky, blurred header with an active-tab indicator and icon-labeled links. Kept the existing link-based sign-in/sign-up toggle (not a segmented tab control) because a tab control's "Sign in"/"Create account" labels collide with the submit buttons' identical accessible names, which would break the existing AuthPanel test suite's getByRole lookups. Co-Authored-By: Claude Sonnet 5 --- apps/web/index.html | 6 + apps/web/package.json | 1 + apps/web/src/App.tsx | 115 ++++++---- apps/web/src/components/AuthPanel.tsx | 274 ++++++++++++++++-------- apps/web/src/components/ThemeToggle.tsx | 6 +- apps/web/src/index.css | 20 ++ apps/web/src/pages/HeroPage.tsx | 180 +++++++++++----- apps/web/tailwind.config.js | 36 +++- package-lock.json | 10 + 9 files changed, 463 insertions(+), 185 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index 57507a7..77877a7 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -8,6 +8,12 @@ content="Offline-first inventory sync for multi-counter shops. Two counters can sell the same item while both are offline — and always converge to one correct, non-lossy stock count." /> + + + void; } /** @@ -41,7 +44,7 @@ interface AuthedAppProps { * page switch stays deliberately not a routing library — see the * original comment this replaced in git history for why. */ -function AuthedApp({ shopId, role }: AuthedAppProps) { +function AuthedApp({ shopId, role, user, onSignOut }: AuthedAppProps) { const [counterId, setCounterId] = useState(readCounterIdFromUrl); const page = useQueryParam("page", "counter"); @@ -54,8 +57,11 @@ function AuthedApp({ shopId, role }: AuthedAppProps) { if (!counterId) { return ( -
- +
+ +
+ +
); } @@ -64,21 +70,7 @@ function AuthedApp({ shopId, role }: AuthedAppProps) { return ( - + {page === "products" && } {page === "checkout" && } {page === "dashboard" && (role === "owner" || role === "manager") && } @@ -88,15 +80,69 @@ function AuthedApp({ shopId, role }: AuthedAppProps) { ); } -function TopBar({ user, onSignOut }: { user: AuthUser; onSignOut: () => void }) { +interface NavLink { + page: string; + label: string; + icon: typeof LayoutGrid; + roles: readonly string[]; +} + +interface TopBarProps { + user: AuthUser; + onSignOut: () => void; + links: readonly NavLink[]; + activePage: string; + counterId: string; +} + +function TopBar({ user, onSignOut, links, activePage, counterId }: TopBarProps) { return ( -
- {user.email} - - -
+
+
+
+
+ +
+ + StockSync + +
+ + + +
+ {user.email} + + +
+
+
); } @@ -113,12 +159,7 @@ function AppShell() { return ; } - return ( -
- - -
- ); + return ; } export function App() { diff --git a/apps/web/src/components/AuthPanel.tsx b/apps/web/src/components/AuthPanel.tsx index d72f186..22514b9 100644 --- a/apps/web/src/components/AuthPanel.tsx +++ b/apps/web/src/components/AuthPanel.tsx @@ -1,14 +1,55 @@ -import { useState, type FormEvent } from "react"; +import { useState, type FormEvent, type ReactNode } from "react"; import { useAuth } from "../state/AuthContext"; -import { ArrowRightIcon } from "./icons"; +import { + ArrowRight, + Mail, + Lock, + Store, + KeyRound, + Eye, + EyeOff, + Loader2, + AlertCircle, + CheckCircle2, +} from "lucide-react"; type Mode = "login" | "signup" | "confirm" | "new_password"; const INPUT_CLASS = - "w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500"; + "w-full rounded-lg border border-slate-300 bg-white py-2.5 pl-10 pr-10 text-sm text-slate-900 placeholder:text-slate-400 outline-none transition-all focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus:border-indigo-500 dark:focus:ring-indigo-500/20"; const SUBMIT_CLASS = - "flex w-full items-center justify-center gap-1.5 rounded-md bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:opacity-50"; -const LINK_CLASS = "mt-4 text-sm text-slate-500 underline decoration-dotted hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"; + "group flex w-full items-center justify-center gap-1.5 rounded-lg bg-gradient-to-br from-indigo-600 to-indigo-700 px-4 py-3 text-sm font-semibold text-white shadow-md shadow-indigo-600/20 transition-all hover:-translate-y-0.5 hover:shadow-lg hover:shadow-indigo-600/30 disabled:pointer-events-none disabled:opacity-60 disabled:shadow-none"; +const LINK_CLASS = "mt-5 block text-center text-sm text-slate-500 underline decoration-dotted underline-offset-2 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"; + +interface FieldProps { + icon: ReactNode; + trailing?: ReactNode; + children: ReactNode; +} + +function Field({ icon, trailing, children }: FieldProps) { + return ( +
+ {icon} + {children} + {trailing && {trailing}} +
+ ); +} + +function PasswordVisibilityToggle({ visible, onToggle }: { visible: boolean; onToggle: () => void }) { + return ( + + ); +} /** * The app's real front door for identity — replaces the old shop_id/ @@ -29,6 +70,7 @@ export function AuthPanel() { const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [info, setInfo] = useState(""); + const [showPassword, setShowPassword] = useState(false); async function handleLogin(event: FormEvent): Promise { event.preventDefault(); @@ -91,38 +133,54 @@ export function AuthPanel() { } return ( -
+
{mode === "login" && ( - <> -

Sign in

+
+

Sign in

Enter your shop's account to get to your counter.

{info && ( -

+

+ {info}

)}
- setEmail(event.target.value)} - placeholder="you@shop.com" - aria-label="Email" - className={INPUT_CLASS} - /> - setPassword(event.target.value)} - placeholder="Password" - aria-label="Password" - className={INPUT_CLASS} - /> - {error &&

{error}

} + }> + setEmail(event.target.value)} + placeholder="you@shop.com" + aria-label="Email" + className={INPUT_CLASS} + /> + + } + trailing={ setShowPassword((v) => !v)} />} + > + setPassword(event.target.value)} + placeholder="Password" + aria-label="Password" + className={INPUT_CLASS} + /> + + {error && ( +

+ + {error} +

+ )}
- +
)} {mode === "signup" && ( - <> -

Set up your shop

+
+

Set up your shop

You'll be the owner — invite your staff once you're in.

- setShopName(event.target.value)} - placeholder="Shop name" - aria-label="Shop name" - className={INPUT_CLASS} - /> - setEmail(event.target.value)} - placeholder="you@shop.com" - aria-label="Email" - className={INPUT_CLASS} - /> - setPassword(event.target.value)} - placeholder="Password (8+ characters)" - aria-label="Password" - className={INPUT_CLASS} - /> - {error &&

{error}

} + }> + setShopName(event.target.value)} + placeholder="Shop name" + aria-label="Shop name" + className={INPUT_CLASS} + /> + + }> + setEmail(event.target.value)} + placeholder="you@shop.com" + aria-label="Email" + className={INPUT_CLASS} + /> + + } + trailing={ setShowPassword((v) => !v)} />} + > + setPassword(event.target.value)} + placeholder="Password (8+ characters)" + aria-label="Password" + className={INPUT_CLASS} + /> + + {error && ( +

+ + {error} +

+ )}
- +
)} {mode === "confirm" && ( - <> -

Check your email

+
+
+ +
+

Check your email

Enter the confirmation code we sent to {email}.

- setCode(event.target.value)} - placeholder="Confirmation code" - aria-label="Confirmation code" - className={INPUT_CLASS} - /> - {error &&

{error}

} + }> + setCode(event.target.value)} + placeholder="Confirmation code" + aria-label="Confirmation code" + className={INPUT_CLASS} + /> + + {error && ( +

+ + {error} +

+ )}
- - +
)} {mode === "new_password" && ( - <> -

Choose a password

+
+

Choose a password

First sign-in on an invited account — set your own password to continue.

- setNewPassword(event.target.value)} - placeholder="New password (8+ characters)" - aria-label="New password" - className={INPUT_CLASS} - /> - {error &&

{error}

} + } + trailing={ setShowPassword((v) => !v)} />} + > + setNewPassword(event.target.value)} + placeholder="New password (8+ characters)" + aria-label="New password" + className={INPUT_CLASS} + /> + + {error && ( +

+ + {error} +

+ )}
- +
)}
); diff --git a/apps/web/src/components/ThemeToggle.tsx b/apps/web/src/components/ThemeToggle.tsx index dd86824..5c4ca1e 100644 --- a/apps/web/src/components/ThemeToggle.tsx +++ b/apps/web/src/components/ThemeToggle.tsx @@ -12,9 +12,11 @@ export function ThemeToggle() { onClick={toggleTheme} aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"} title={isDark ? "Switch to light mode" : "Switch to dark mode"} - className="flex h-8 w-8 items-center justify-center rounded-full text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200" + className="flex h-8 w-8 items-center justify-center rounded-full text-slate-500 transition-all hover:bg-slate-100 hover:text-slate-700 active:scale-90 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200" > - {isDark ? : } + + {isDark ? : } + ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 7cd42da..0c09360 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -4,6 +4,26 @@ body { @apply bg-slate-50 text-slate-900 transition-colors dark:bg-slate-950 dark:text-slate-100; + font-family: "Inter", ui-sans-serif, system-ui, sans-serif; + font-feature-settings: "cv11", "ss01"; +} + +.font-display { + font-family: "Lexend", "Inter", ui-sans-serif, system-ui, sans-serif; +} + +/* Staggered entrance animations — apply alongside animate-fade-in-up */ +.delay-1 { animation-delay: 0.08s; } +.delay-2 { animation-delay: 0.16s; } +.delay-3 { animation-delay: 0.24s; } +.delay-4 { animation-delay: 0.32s; } +.delay-5 { animation-delay: 0.4s; } + +.bg-grid-slate { + background-image: + linear-gradient(to right, rgb(100 116 139 / 0.07) 1px, transparent 1px), + linear-gradient(to bottom, rgb(100 116 139 / 0.07) 1px, transparent 1px); + background-size: 32px 32px; } @keyframes fadeIn { diff --git a/apps/web/src/pages/HeroPage.tsx b/apps/web/src/pages/HeroPage.tsx index f92dd8a..45e01b2 100644 --- a/apps/web/src/pages/HeroPage.tsx +++ b/apps/web/src/pages/HeroPage.tsx @@ -1,20 +1,32 @@ -import { BoxIcon, WifiOffIcon, MergeIcon, SparklesIcon, BoltIcon, ArrowRightIcon } from "../components/icons"; +import { + Boxes, + WifiOff, + GitMerge, + Sparkles, + Zap, + ArrowRight, + ShieldCheck, + ExternalLink, + Radio, + ScanLine, + BadgeCheck, +} from "lucide-react"; import { AuthPanel } from "../components/AuthPanel"; import { ThemeToggle } from "../components/ThemeToggle"; const STEPS = [ { - icon: WifiOffIcon, + icon: WifiOff, title: "Two counters go offline", body: 'Each keeps taking sales locally — no blocked UI, no "connection lost" dead end.', }, { - icon: MergeIcon, + icon: GitMerge, title: "They reconnect, in any order", body: "Vector clocks and a PN-Counter CRDT merge both counters' changes into one mathematically correct state — never a silent overwrite.", }, { - icon: SparklesIcon, + icon: Sparkles, title: "Genuine conflicts get a human call", body: "Only a real same-field disagreement is ever flagged — with an AI-generated, plain-language explanation to help the human decide fast.", }, @@ -22,25 +34,41 @@ const STEPS = [ const FEATURES = [ { - icon: WifiOffIcon, + icon: WifiOff, title: "Offline-first", body: "Full offline queueing via IndexedDB — sell, restock, and edit with zero connection.", }, { - icon: MergeIcon, + icon: GitMerge, title: "Conflict-free merge", body: "Vector clocks + a PN-Counter CRDT guarantee a correct, non-lossy result regardless of arrival order.", }, { - icon: BoltIcon, + icon: Zap, title: "Real-time sync", body: "WebSocket push keeps every open counter live the moment a change lands.", }, { - icon: SparklesIcon, + icon: Sparkles, title: "AI-assisted review", body: "Amazon Bedrock explains genuine conflicts in plain language — advisory only, never the decision-maker.", }, + { + icon: ShieldCheck, + title: "Real multi-tenant auth", + body: "Cognito-backed owner/manager/counter-staff roles — every route verifies a signed token, never a client-supplied ID.", + }, + { + icon: ScanLine, + title: "Barcode quick-entry", + body: "Camera-based scan resolves an item instantly, then you explicitly choose sell or restock.", + }, +]; + +const STATS = [ + { value: "0", label: "sales silently lost" }, + { value: "100%", label: "orderings proven correct" }, + { value: "24/7", label: "offline-tolerant" }, ]; /** @@ -50,88 +78,136 @@ const FEATURES = [ */ export function HeroPage() { return ( -
-