From 26bb370ef08c3631783f693626ced6d90caeb07e Mon Sep 17 00:00:00 2001 From: Noam Ben Shimon Date: Tue, 19 May 2026 18:00:13 +0300 Subject: [PATCH 1/6] Refactor Footer and Header components for improved layout and accessibility; enhance LoginForm with error handling and password visibility toggle; update SaveToCartButton and SearchableSelect for better UX; improve Toast component styling and functionality. Signed-off-by: Noam Ben Shimon --- src/app/about/page.tsx | 167 +++++---- src/app/cart/page.tsx | 333 +++++++++--------- src/app/checkout/page.tsx | 269 ++++++++------- src/app/contact/page.tsx | 216 ++++++------ src/app/globals.css | 509 +++++++++++++++++++++------ src/app/layout.tsx | 83 +++-- src/app/login/page.tsx | 25 +- src/app/page.tsx | 516 ++++++++++++++++------------ src/app/payment/cancel/page.tsx | 74 ++-- src/app/payment/success/page.tsx | 83 ++--- src/components/ConfirmDialog.tsx | 75 ++-- src/components/EquipmentList.tsx | 300 +++++++++------- src/components/Footer.tsx | 115 +++++-- src/components/Header.tsx | 310 ++++++++++++----- src/components/LoginForm.tsx | 152 +++++--- src/components/SaveToCartButton.tsx | 53 +-- src/components/SearchableSelect.tsx | 257 +++++++++----- src/components/Toast.tsx | 42 +-- 18 files changed, 2232 insertions(+), 1347 deletions(-) diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 8ed140d..0cda89b 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -3,88 +3,123 @@ import Layout from '@/components/Layout'; interface TeamMember { - login: string; - name: string | null; - role: 'Mentor' | 'Team Member'; + login: string; + name: string | null; + role: 'Mentor' | 'Team Member'; } -// Team members sorted alphabetically by login, with roles assigned const teamMembers: TeamMember[] = [ - { login: 'Avnermond12344', name: 'Avner Mondshine', role: 'Team Member' }, - { login: 'danielyehoshua123', name: null, role: 'Team Member' }, - { login: 'HarelZeevi', name: 'Harel Zeevi', role: 'Team Member' }, - { login: 'idanC1111', name: null, role: 'Team Member' }, - { login: 'NoamBenShimon', name: 'Noam Ben Shimon', role: 'Team Member' }, - { login: 'roishm', name: 'Roi Shmerling', role: 'Team Member' }, - { login: 'Tomer-David', name: null, role: 'Team Member' }, - { login: 'vMaroon', name: 'Maroon Ayoub', role: 'Mentor' }, + { login: 'Avnermond12344', name: 'Avner Mondshine', role: 'Team Member' }, + { login: 'danielyehoshua123', name: null, role: 'Team Member' }, + { login: 'HarelZeevi', name: 'Harel Zeevi', role: 'Team Member' }, + { login: 'idanC1111', name: null, role: 'Team Member' }, + { login: 'NoamBenShimon', name: 'Noam Ben Shimon', role: 'Team Member' }, + { login: 'roishm', name: 'Roi Shmerling', role: 'Team Member' }, + { login: 'Tomer-David', name: null, role: 'Team Member' }, + { login: 'vMaroon', name: 'Maroon Ayoub', role: 'Mentor' }, ]; export default function AboutPage() { + const mentors = teamMembers.filter(m => m.role === 'Mentor'); + const members = teamMembers.filter(m => m.role === 'Team Member'); + return ( -
- {/* Hero Section */} -
-

- About Us +
+ {/* Hero */} +
+

About

+

+ About Motzkin Store

-

- Motzkin Store is a school equipment management system built by a dedicated team - of developers. Our goal is to simplify the process of managing and distributing - educational equipment to schools. +

+ Motzkin Store is a school-supply ordering site for families in + Kiryat Motzkin. Pick the school, pick the grade, and order the + equipment list in one go.

-
- - {/* Team Section */} - ); } +function MemberCard({ + member, + delay, + mentor = false, +}: { + member: TeamMember; + delay: number; + mentor?: boolean; +}) { + return ( + +
+ +
+

+ {member.name || member.login} +

+

@{member.login}

+ {mentor && ( +

+ Mentor +

+ )} +
+
+
+ ); +} diff --git a/src/app/cart/page.tsx b/src/app/cart/page.tsx index ed7173c..b9edae7 100644 --- a/src/app/cart/page.tsx +++ b/src/app/cart/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState } from 'react'; -import Image from 'next/image'; import Link from 'next/link'; import Layout from '@/components/Layout'; import ConfirmDialog from '@/components/ConfirmDialog'; @@ -10,51 +9,39 @@ import { useCart, CartEntry } from '@/contexts/CartContext'; export default function CartPage() { const { cartEntries, removeFromCart, clearCart } = useCart(); - // Dialog state const [dialogState, setDialogState] = useState<{ isOpen: boolean; type: 'remove' | 'clear'; entryId?: string; entryName?: string; - }>({ - isOpen: false, - type: 'remove', - }); + }>({ isOpen: false, type: 'remove' }); const handleRemoveClick = (entry: CartEntry) => { setDialogState({ isOpen: true, type: 'remove', entryId: entry.id, - entryName: `${entry.school.name} - ${entry.grade.name}`, + entryName: `${entry.school.name} · ${entry.grade.name}`, }); }; - const handleClearClick = () => { - setDialogState({ - isOpen: true, - type: 'clear', - }); - }; + const handleClearClick = () => setDialogState({ isOpen: true, type: 'clear' }); const handleConfirm = () => { - if (dialogState.type === 'remove' && dialogState.entryId) { - removeFromCart(dialogState.entryId); - } else if (dialogState.type === 'clear') { - clearCart(); - } + if (dialogState.type === 'remove' && dialogState.entryId) removeFromCart(dialogState.entryId); + else if (dialogState.type === 'clear') clearCart(); setDialogState({ isOpen: false, type: 'remove' }); }; - const handleCancel = () => { - setDialogState({ isOpen: false, type: 'remove' }); - }; + const handleCancel = () => setDialogState({ isOpen: false, type: 'remove' }); const formatDate = (timestamp: number) => { - return new Date(timestamp).toLocaleDateString('en-US', { - month: 'short', + if (!timestamp || isNaN(timestamp)) return 'just now'; + const date = new Date(timestamp); + if (isNaN(date.getTime())) return 'just now'; + return date.toLocaleDateString(undefined, { day: 'numeric', - year: 'numeric', + month: 'short', hour: '2-digit', minute: '2-digit', }); @@ -62,185 +49,209 @@ export default function CartPage() { const formatCurrency = (amount: number) => `${amount.toFixed(2)} ILS`; - // Calculate total items across all entries const totalItems = cartEntries.reduce( - (sum, entry) => sum + (Array.isArray(entry.items) ? entry.items.reduce((itemSum, item) => itemSum + item.quantity, 0) : 0), + (sum, entry) => + sum + + (Array.isArray(entry.items) + ? entry.items.reduce((s, item) => s + item.quantity, 0) + : 0), 0 ); const totalCost = cartEntries.reduce( - (sum, entry) => sum + (Array.isArray(entry.items) - ? entry.items.reduce((itemSum, item) => itemSum + item.quantity * (item.unitPrice ?? 1), 0) - : 0), + (sum, entry) => + sum + + (Array.isArray(entry.items) + ? entry.items.reduce((s, item) => s + item.quantity * (item.unitPrice ?? 1), 0) + : 0), 0 ); - // Debug: log cartEntries on every render - console.log('[CartPage] render, cartEntries:', cartEntries); - return ( -
-
-

- Your Cart -

- {cartEntries.length > 0 && ( - - )} -
+
+
+

Step 2 of 3

+
+

+ Your cart +

+ {cartEntries.length > 0 && ( + + )} +
+
{cartEntries.length === 0 ? ( - // Empty cart state -
- Empty cart -

- Your cart is empty -

-

- Add equipment lists to your cart to get started. +

+
+ + + + + +
+

Your cart is empty

+

+ Once you save a school's equipment list, it will appear here ready + for checkout.

- - Browse Equipment + + Browse equipment lists
) : ( <> - {/* Cart summary */} -
-

- {cartEntries.length} - {cartEntries.length === 1 ? ' equipment list' : ' equipment lists'} •{' '} - {totalItems} - {totalItems === 1 ? ' item' : ' items'} total •{' '} - {formatCurrency(totalCost)} - {' '}total cost -

+ {/* Summary band */} +
+ + +
- {/* Cart entries */} -
- {cartEntries.map((entry) => ( -
- {/* Entry header */} -
-
-

- {entry.school.name} -

-

- {entry.grade.name} -

-

- Added {formatDate(entry.timestamp)} -

-
- -
- - {/* Items list */} -
-
-
- Item - Qty - Unit Price - Line Total + {/* Entries */} +
+ {cartEntries.map((entry, idx) => { + const subtotal = entry.items.reduce( + (s, item) => s + item.quantity * (item.unitPrice ?? 1), + 0 + ); + return ( +
+
+
+

+ {entry.school.name} +

+

{entry.grade.name}

+

+ Added {formatDate(entry.timestamp)} +

- {entry.items.map((item) => { - const unitPrice = item.unitPrice ?? 1; - const lineTotal = unitPrice * item.quantity; - - return ( -
- - {item.name} - - - ×{item.quantity} - - - {formatCurrency(unitPrice)} - - - {formatCurrency(lineTotal)} - -
- ); - })} -
+ + -
- Subtotal: {formatCurrency( - entry.items.reduce( - (sum, item) => sum + item.quantity * (item.unitPrice ?? 1), - 0 - ) - )} +
+
+
+ Item + Qty + Unit + Total +
+ {entry.items.map(item => { + const unitPrice = item.unitPrice ?? 1; + return ( +
+ {item.name} + ×{item.quantity} + {formatCurrency(unitPrice)} + {formatCurrency(unitPrice * item.quantity)} +
+ ); + })} +
+ +
+ + List subtotal + + + {formatCurrency(subtotal)} + +
-
-
- ))} + + ); + })}
{/* Checkout */} -
+
+
+

+ Ready to complete your order? You'll be redirected to a secure + payment page. +

+
+

Order total

+

+ {formatCurrency(totalCost)} +

+
+
- Proceed to Checkout + Proceed to checkout +
)}
- {/* Confirmation dialog */} ); -} \ No newline at end of file +} + +function Stat({ + label, + value, + accent = false, +}: { + label: string; + value: string | number; + accent?: boolean; +}) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx index f7c814c..cff6ee2 100644 --- a/src/app/checkout/page.tsx +++ b/src/app/checkout/page.tsx @@ -16,32 +16,31 @@ export default function CheckoutPage() { const formatCurrency = (amount: number) => `${amount.toFixed(2)} ILS`; const totalItems = cartEntries.reduce( - (sum, entry) => sum + (Array.isArray(entry.items) ? entry.items.reduce((itemSum, item) => itemSum + item.quantity, 0) : 0), + (sum, entry) => + sum + (Array.isArray(entry.items) ? entry.items.reduce((s, i) => s + i.quantity, 0) : 0), 0 ); const totalCost = cartEntries.reduce( - (sum, entry) => sum + (Array.isArray(entry.items) - ? entry.items.reduce((itemSum, item) => itemSum + item.quantity * (item.unitPrice ?? 1), 0) - : 0), + (sum, entry) => + sum + + (Array.isArray(entry.items) + ? entry.items.reduce((s, i) => s + i.quantity * (i.unitPrice ?? 1), 0) + : 0), 0 ); const handleCheckout = async () => { if (!userid || cartEntries.length === 0) return; - setIsProcessing(true); setError(null); - try { const totalAmountCents = Math.max(1, Math.round(totalCost * 100)); - const session = await createCheckoutSession({ productName: 'Motzklist Order', quantity: 1, amount: totalAmountCents, }); - window.location.href = session.url; } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Something went wrong. Please try again.'; @@ -53,19 +52,16 @@ export default function CheckoutPage() { if (cartEntries.length === 0) { return ( -
-
-

- Your cart is empty +
+
+

+ Nothing to check out

-

- Add equipment lists to your cart before checking out. +

+ Your cart is empty. Add at least one equipment list before continuing.

- - Go to Cart + + Go to cart
@@ -75,116 +71,157 @@ export default function CheckoutPage() { return ( -
-

- Checkout -

- - {/* Order summary */} -
-

- {cartEntries.length} - {cartEntries.length === 1 ? ' equipment list' : ' equipment lists'} •{' '} - {totalItems} - {totalItems === 1 ? ' item' : ' items'} total •{' '} - {formatCurrency(totalCost)} - {' '}total cost +

+
+

Step 3 of 3

+

+ Review & pay +

+

+ Please review the lists below. Payment is handled by our secure payment + provider. No card details are stored on this site.

+
+ + {/* Summary */} +
+ + +
- {/* Order entries (read-only) */} -
- {cartEntries.map((entry) => ( -
-
-

- {entry.school.name} -

-

- {entry.grade.name} -

-
-
-
-
- Item - Qty - Unit Price - Line Total + {/* Entries (read-only) */} +
+ {cartEntries.map((entry, idx) => { + const subtotal = entry.items.reduce( + (s, item) => s + item.quantity * (item.unitPrice ?? 1), + 0 + ); + return ( +
+
+

+ {entry.school.name} +

+

{entry.grade.name}

+
+
+
+
+ Item + Qty + Unit + Total +
+ {entry.items.map(item => { + const unitPrice = item.unitPrice ?? 1; + return ( +
+ {item.name} + ×{item.quantity} + {formatCurrency(unitPrice)} + {formatCurrency(unitPrice * item.quantity)} +
+ ); + })} +
+
+ + List subtotal + + + {formatCurrency(subtotal)} +
- {entry.items.map((item) => { - const unitPrice = item.unitPrice ?? 1; - const lineTotal = unitPrice * item.quantity; - - return ( -
- - {item.name} - - - ×{item.quantity} - - - {formatCurrency(unitPrice)} - - - {formatCurrency(lineTotal)} - -
- ); - })} -
- -
- Subtotal: {formatCurrency( - entry.items.reduce( - (sum, item) => sum + item.quantity * (item.unitPrice ?? 1), - 0 - ) - )}
-
-
- ))} + + ); + })}
- {/* Error message */} + {/* Error */} {error && ( -
-

{error}

+
+ + + + + + {error}
)} - {/* Actions */} -
- - Back to Cart - - + {/* Grand total + actions */} +
+
+
+ + + + + Secure payment via Stripe +
+
+

Amount to pay

+

+ {formatCurrency(totalCost)} +

+
+
+ +
+ + ← Back to cart + + +
); } + +function Stat({ + label, + value, + accent = false, +}: { + label: string; + value: string | number; + accent?: boolean; +}) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index b320922..000f600 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -6,129 +6,153 @@ import Link from 'next/link'; export default function ContactPage() { return ( -
- {/* Hero Section */} -
-

- Contact Us +
+ {/* Hero */} +
+

Get in touch

+

+ We're here to help.

-

- Have questions or need assistance? Get in touch with us through the channels below. +

+ For questions about school equipment orders, payments, or + technical issues, reach out to the municipality's service centre + or to the developer team.

-
+ - {/* Contact Information Grid */} -
- {/* Kiryat Motzkin Contact Card */} -
-

- Kiryat Motzkin Municipality -

-
-
-

- Address -

+
+ {/* Municipality — primary card, spans wider */} +
+
+

Municipality

+

+ Kiryat Motzkin City Hall +

+
+ + -
-

- Municipal Service Center -

-
-

Phone: 04-878-0900

-

Quick Dial: *5470

-

WhatsApp: - +972 54-222-3352 -

-
-
-
-

- General Inquiries -

-

+ + + +

+ + + + 04-878-0222 -

-
- +
-
+ - {/* Development Team Contact Card */} -
-

- Contact the Development Team -

-

- For technical support, bug reports, or feature requests, please reach out to our development team through GitHub. -

-

- You can find all team members and their GitHub profiles on our About page. -

- - View Team on About Page - -
-
- - {/* Office Hours Section */} -
-

- Office Hours -

-
-
-

- Sunday - Tuesday, Thursday -

-

8:00 AM - 3:30 PM

+ {/* Development team */} +
+
+

Development

+

+ Site & technical support +

-
-

- Wednesday +

+

+ Found a bug or have a feature suggestion? Our development team + handles it on GitHub.

-

8:00 AM - 1:00 PM
4:00 PM - 6:00 PM

-
-
-

- Friday - Saturday +

+ Team profiles and direct links are on the About page.

-

Closed

+ + Meet the team → +
-
+
+ + {/* Hours */} +
+
+

+ Office hours +

+

All times Israel Time (IST)

+
+
+ + + +
+
); } +function ContactRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+

+ {label} +

+
{children}
+
+ ); +} + +function Hours({ title, hours, muted = false }: { title: string; hours: string; muted?: boolean }) { + return ( +
+

+ {title} +

+

+ {hours.replace(/\\n/g, '\n')} +

+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 4c5c5f5..0a04594 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,111 +1,398 @@ -@import "tailwindcss"; - -:root { - --background: #fafafa; - --foreground: #171717; -} - -/* Tailwind CSS v4 inline theme configuration for custom CSS variables */ -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -html, body { - height: 100%; -} - -body { - background: var(--background); - color: var(--foreground); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -} - -/* Line timer animation - shrinks from full width to zero */ -@keyframes timer-line { - from { - width: 100%; - } - to { - width: 0; - } -} - -.animate-timer-line { - animation: timer-line linear forwards; -} - -/* Toast entrance animation - slides up and fades in */ -@keyframes toast-enter { - 0% { - opacity: 0; - transform: translateY(20px); - } - 100% { - opacity: 1; - transform: translateY(0); - } -} - -/* Toast fade-out animation - stays visible then fades away */ -@keyframes toast-fade-out { - 0% { - opacity: 1; - transform: translateY(0); - } - 70% { - opacity: 1; - transform: translateY(0); - } - 100% { - opacity: 0; - transform: translateY(10px); - } -} - -/* Combined entrance + fade-out animation for toast lifecycle */ -@keyframes toast-lifecycle { - /* Entrance phase: 0-8% (roughly 250ms of a 3000ms animation) */ - 0% { - opacity: 0; - transform: translateY(20px); - } - 8% { - opacity: 1; - transform: translateY(0); - } - /* Stay visible */ - 70% { - opacity: 1; - transform: translateY(0); - } - /* Fade out */ - 100% { - opacity: 0; - transform: translateY(10px); - } -} - -.animate-toast-lifecycle { - animation: toast-lifecycle linear forwards; -} - -.animate-toast-enter { - animation: toast-enter 250ms ease-out forwards; -} - -.animate-toast-enter-fade-out { - animation: toast-enter 250ms ease-out forwards, toast-fade-out linear forwards; -} - -.animate-toast-fade-out { - animation: toast-fade-out linear forwards; -} - +@import "tailwindcss"; + +/* ────────────────────────────────────────────────────────────────────── + Motzkin Store · Design Tokens + A warm, restrained municipal palette — deep teal of the coast, + sand-paper background, clay-warm accents for sums and emphasis. + ────────────────────────────────────────────────────────────────────── */ +:root { + /* Surfaces — warm paper, never cold white */ + --surface-page: #FAF7EE; /* page background, like good municipal stationery */ + --surface-card: #FFFFFF; + --surface-sunken: #F2EDDF; + --surface-muted: #F6F1E3; + --surface-band: #EFE9D8; + + /* Ink */ + --ink-1: #1B2230; /* primary text, deep slate (not pure black) */ + --ink-2: #4A5160; /* secondary */ + --ink-3: #7A8294; /* tertiary, meta */ + --ink-inverse: #FAF7EE; + + /* Brand — Mediterranean teal */ + --brand-900: #0B3A48; + --brand-700: #14586C; + --brand-500: #1F7A92; + --brand-200: #BFD8DF; + --brand-50: #E8F0F2; + + /* Accent — sun-baked clay (used for prices, totals, key sums) */ + --clay-900: #7A3C1E; + --clay-700: #B5663A; + --clay-500: #C97B3E; + --clay-200: #F1D7B8; + --clay-50: #FAEFDF; + + /* Semantic */ + --ok-700: #2F5D3D; + --ok-500: #4C8160; + --ok-50: #E3EDE3; + --bad-700: #7E2D26; + --bad-500: #A8463C; + --bad-50: #F3DDD7; + + /* Lines & focus */ + --line: #E5DECC; + --line-strong: #C9C0A6; + --focus: #1F7A92; + + /* Type */ + --font-display: 'Fraunces', 'Iowan Old Style', 'Hoefler Text', Georgia, 'Times New Roman', serif; + --font-body: 'Geist', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'Geist Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; + + /* Shadows — soft, paper-like */ + --shadow-sm: 0 1px 0 rgba(27, 34, 48, 0.04), 0 1px 2px rgba(27, 34, 48, 0.04); + --shadow-md: 0 1px 0 rgba(27, 34, 48, 0.04), 0 6px 18px -8px rgba(27, 34, 48, 0.12); + --shadow-lg: 0 2px 0 rgba(27, 34, 48, 0.05), 0 24px 48px -16px rgba(27, 34, 48, 0.18); +} + +@theme inline { + --color-background: var(--surface-page); + --color-foreground: var(--ink-1); + --color-page: var(--surface-page); + --color-card: var(--surface-card); + --color-sunken: var(--surface-sunken); + --color-band: var(--surface-band); + --color-ink: var(--ink-1); + --color-ink-2: var(--ink-2); + --color-ink-3: var(--ink-3); + --color-brand: var(--brand-700); + --color-brand-deep: var(--brand-900); + --color-brand-soft: var(--brand-50); + --color-clay: var(--clay-700); + --color-clay-soft: var(--clay-50); + --color-ok: var(--ok-500); + --color-bad: var(--bad-500); + --color-line: var(--line); + --color-line-strong: var(--line-strong); + --font-display: var(--font-display); + --font-body: var(--font-body); + --font-mono: var(--font-mono); +} + +/* Base ─────────────────────────────────────────────────────────────── */ +html, body { + height: 100%; +} + +html { + color-scheme: light; +} + +body { + background: var(--surface-page); + color: var(--ink-1); + font-family: var(--font-body); + font-feature-settings: "ss01", "cv11"; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* Subtle paper texture — barely perceptible, gives warmth */ +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background-image: + radial-gradient(rgba(27, 34, 48, 0.022) 1px, transparent 1px), + radial-gradient(rgba(27, 34, 48, 0.015) 1px, transparent 1px); + background-size: 24px 24px, 7px 7px; + background-position: 0 0, 3px 3px; + opacity: 0.7; +} + +/* Display type — Fraunces with its soft optical sizing */ +.font-display { + font-family: var(--font-display); + font-optical-sizing: auto; + font-variation-settings: "SOFT" 50, "WONK" 0, "opsz" 96; + letter-spacing: -0.02em; +} + +/* Tabular numerals for prices, quantities, totals */ +.nums-tabular, +.tabular-nums { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1, "lnum" 1; +} + +/* Focus rings — quiet but visible */ +*:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; + border-radius: 2px; +} + +/* Selection */ +::selection { + background: var(--clay-200); + color: var(--ink-1); +} + +/* Disable iOS tap highlight */ +* { -webkit-tap-highlight-color: transparent; } + +/* Scrollbar — subtle */ +* { + scrollbar-width: thin; + scrollbar-color: var(--line-strong) transparent; +} + +/* ────────────────────────────────────────────────────────────────────── + Reusable surfaces & primitives + ────────────────────────────────────────────────────────────────────── */ + +.surface-card { + background: var(--surface-card); + border: 1px solid var(--line); + border-radius: 6px; + box-shadow: var(--shadow-sm); +} + +.divider-soft { + height: 1px; + background: linear-gradient(to right, transparent, var(--line) 12%, var(--line) 88%, transparent); +} + +/* Vertical hair rule used between header brand and tagline */ +.hair-v { + width: 1px; + background: var(--line-strong); + align-self: stretch; +} + +/* Tiny eyebrow label — used above section titles */ +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 11px; + font-weight: 600; + color: var(--brand-700); +} + +/* Municipal crest mark — used in header */ +.crest { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 4px; + background: var(--brand-900); + color: var(--surface-page); + font-family: var(--font-display); + font-weight: 600; + font-size: 17px; + letter-spacing: -0.04em; + flex-shrink: 0; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08), inset 0 -8px 16px rgba(0,0,0,0.18); +} + +.crest::after { + content: ""; + position: absolute; +} + +/* ────────────────────────────────────────────────────────────────────── + Buttons + ────────────────────────────────────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.7rem 1.15rem; + font-family: var(--font-body); + font-weight: 500; + font-size: 0.95rem; + letter-spacing: -0.005em; + border-radius: 4px; + border: 1px solid transparent; + cursor: pointer; + transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 140ms ease, box-shadow 140ms ease; + user-select: none; + line-height: 1.2; +} + +.btn:active:not(:disabled) { transform: translateY(0.5px); } +.btn:disabled { cursor: not-allowed; opacity: 0.55; } + +.btn-primary { + background: var(--brand-900); + color: var(--surface-page); + border-color: var(--brand-900); + box-shadow: var(--shadow-sm); +} +.btn-primary:hover:not(:disabled) { background: var(--brand-700); border-color: var(--brand-700); } + +.btn-clay { + background: var(--clay-700); + color: #FFF8EF; + border-color: var(--clay-700); +} +.btn-clay:hover:not(:disabled) { background: var(--clay-900); border-color: var(--clay-900); } + +.btn-quiet { + background: transparent; + color: var(--ink-1); + border-color: var(--line-strong); +} +.btn-quiet:hover:not(:disabled) { background: var(--surface-sunken); border-color: var(--ink-3); } + +.btn-ghost { + background: transparent; + color: var(--ink-2); + border-color: transparent; +} +.btn-ghost:hover:not(:disabled) { color: var(--ink-1); background: var(--surface-sunken); } + +.btn-danger-quiet { + background: transparent; + color: var(--bad-500); + border-color: transparent; +} +.btn-danger-quiet:hover:not(:disabled) { background: var(--bad-50); color: var(--bad-700); } + +/* ────────────────────────────────────────────────────────────────────── + Form fields + ────────────────────────────────────────────────────────────────────── */ + +.field-label { + display: block; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 0.4rem; +} + +.field-input { + width: 100%; + padding: 0.78rem 0.95rem; + background: var(--surface-card); + border: 1px solid var(--line-strong); + border-radius: 4px; + color: var(--ink-1); + font-family: var(--font-body); + font-size: 1rem; + line-height: 1.4; + box-shadow: inset 0 1px 0 rgba(27,34,48,0.02); + transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease; +} + +.field-input::placeholder { color: var(--ink-3); } + +.field-input:hover:not(:disabled) { border-color: var(--ink-3); } + +.field-input:focus { + outline: none; + border-color: var(--brand-700); + box-shadow: 0 0 0 3px rgba(31, 122, 146, 0.18); +} + +.field-input:disabled { background: var(--surface-sunken); color: var(--ink-3); cursor: not-allowed; } + +/* ────────────────────────────────────────────────────────────────────── + Header link underline animation + ────────────────────────────────────────────────────────────────────── */ +.nav-link { + position: relative; + color: var(--ink-2); + font-size: 0.93rem; + font-weight: 500; + transition: color 140ms ease; +} +.nav-link:hover { color: var(--ink-1); } +.nav-link::after { + content: ""; + position: absolute; + left: 0; right: 0; bottom: -6px; + height: 1.5px; + background: var(--brand-900); + transform: scaleX(0); + transform-origin: left center; + transition: transform 220ms cubic-bezier(0.4, 0, 0.2, 1); +} +.nav-link:hover::after, +.nav-link[aria-current="page"]::after { transform: scaleX(1); } +.nav-link[aria-current="page"] { color: var(--ink-1); } + +/* ────────────────────────────────────────────────────────────────────── + Animations + ────────────────────────────────────────────────────────────────────── */ + +@keyframes timer-line { + from { width: 100%; } + to { width: 0; } +} +.animate-timer-line { animation: timer-line linear forwards; } + +@keyframes toast-enter { + 0% { opacity: 0; transform: translateY(14px); } + 100% { opacity: 1; transform: translateY(0); } +} + +@keyframes toast-fade-out { + 0% { opacity: 1; transform: translateY(0); } + 70% { opacity: 1; transform: translateY(0); } + 100% { opacity: 0; transform: translateY(8px); } +} + +@keyframes toast-lifecycle { + 0% { opacity: 0; transform: translateY(14px); } + 8% { opacity: 1; transform: translateY(0); } + 70% { opacity: 1; transform: translateY(0); } + 100% { opacity: 0; transform: translateY(8px); } +} + +.animate-toast-lifecycle { animation: toast-lifecycle linear forwards; } +.animate-toast-enter { animation: toast-enter 220ms ease-out forwards; } +.animate-toast-enter-fade-out { animation: toast-enter 220ms ease-out forwards, toast-fade-out linear forwards; } +.animate-toast-fade-out { animation: toast-fade-out linear forwards; } + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes scale-in { + from { opacity: 0; transform: scale(0.96); } + to { opacity: 1; transform: scale(1); } +} +@keyframes rise-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.animate-fade-in { animation: fade-in 200ms ease-out both; } +.animate-scale-in { animation: scale-in 200ms cubic-bezier(0.16, 1, 0.3, 1) both; } +.animate-rise-in { animation: rise-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both; } +.delay-1 { animation-delay: 60ms; } +.delay-2 { animation-delay: 140ms; } +.delay-3 { animation-delay: 220ms; } + +@keyframes spin { + to { transform: rotate(360deg); } +} +.animate-spin-slow { animation: spin 900ms linear infinite; } + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b2ca012..75d7469 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,30 +1,53 @@ -import type {Metadata} from "next"; -import "./globals.css"; -import {AuthProvider} from "@/contexts/AuthContext"; -import AuthenticatedProviders from "@/components/AuthenticatedProviders"; -import ProtectedRoute from '@/components/ProtectedRoute'; - -export const metadata: Metadata = { - title: "Motzkin Store - School Equipment", - description: "Select your school, grade, and class to view your equipment list", -}; - -export default function RootLayout({ - children, - }: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - - - - {children} - - - - - - ); -} +import type { Metadata } from "next"; +import { Fraunces, Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import { AuthProvider } from "@/contexts/AuthContext"; +import AuthenticatedProviders from "@/components/AuthenticatedProviders"; +import ProtectedRoute from "@/components/ProtectedRoute"; + +const fraunces = Fraunces({ + subsets: ["latin"], + display: "swap", + variable: "--font-display-loaded", + axes: ["SOFT", "WONK", "opsz"], +}); + +const geist = Geist({ + subsets: ["latin"], + display: "swap", + variable: "--font-body-loaded", +}); + +const geistMono = Geist_Mono({ + subsets: ["latin"], + display: "swap", + variable: "--font-mono-loaded", +}); + +export const metadata: Metadata = { + title: "Motzkin Store · School Equipment", + description: + "Order the school supply list for your child in Kiryat Motzkin.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + {children} + + + + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 7eee1ee..93517b9 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,9 +2,28 @@ import LoginForm from '@/components/LoginForm'; export default function LoginPage() { return ( -
- +
+ {/* Background ambiance — two soft warm shapes */} +

+

+ {message} +

+ +
+ + +
); } - diff --git a/src/components/EquipmentList.tsx b/src/components/EquipmentList.tsx index f10dce3..c49bce9 100644 --- a/src/components/EquipmentList.tsx +++ b/src/components/EquipmentList.tsx @@ -1,131 +1,169 @@ -'use client'; - -export interface EquipmentItem { - id: number; - name: string; - quantity: number; - unitPrice?: number; -} - -export interface EquipmentData { - classId: number; - className: string; - items: EquipmentItem[]; -} - -interface EquipmentListProps { - data: EquipmentData; - selectedIds: Set; - quantities: Map; - onToggle: (id: number) => void; - onQuantityChange: (id: number, quantity: number) => void; -} - -const MIN_QUANTITY = 0; -const formatCurrency = (amount: number) => `${amount.toFixed(2)} ILS`; - -export default function EquipmentList({ - data, - selectedIds, - quantities, - onToggle, - onQuantityChange - }: EquipmentListProps) { - return ( -
-

- {data.className} -

- -
- {/* Header */} -
-
Item
-
Price
-
Quantity
-
-
- - {/* Items */} -
- {data.items.map((item) => { - const isSelected = selectedIds.has(item.id); - const currentQuantity = quantities.get(item.id) ?? item.quantity; - const maxQuantity = item.quantity; - // TODO: This max is enforced only in the frontend; backend should validate as well. - - return ( -
- {/* Item Name */} -
onToggle(item.id)} - className="cursor-pointer flex items-center text-zinc-900 dark:text-white" - > - {item.name} -
- - {/* Price */} -
- {formatCurrency(item.unitPrice ?? 1)} -
- - {/* Quantity Input */} -
- { - const val = parseInt(e.target.value) || 0; - const clamped = Math.max(MIN_QUANTITY, Math.min(maxQuantity, val)); - onQuantityChange(item.id, clamped); - }} - disabled={!isSelected} - className="w-full px-2 py-1 text-center border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
- - {/* Checkmark */} -
- -
-
- ); - })} -
-
-
- ); -} +'use client'; + +export interface EquipmentItem { + id: number; + name: string; + quantity: number; + unitPrice?: number; +} + +export interface EquipmentData { + classId: number; + className: string; + items: EquipmentItem[]; +} + +interface EquipmentListProps { + data: EquipmentData; + selectedIds: Set; + quantities: Map; + onToggle: (id: number) => void; + onQuantityChange: (id: number, quantity: number) => void; +} + +const MIN_QUANTITY = 0; +const formatCurrency = (amount: number) => `${amount.toFixed(2)} ILS`; + +export default function EquipmentList({ + data, + selectedIds, + quantities, + onToggle, + onQuantityChange, +}: EquipmentListProps) { + // Subtotal for currently selected items + const subtotal = data.items.reduce((sum, item) => { + if (!selectedIds.has(item.id)) return sum; + const qty = quantities.get(item.id) ?? item.quantity; + return sum + qty * (item.unitPrice ?? 0); + }, 0); + + const selectedCount = data.items.filter(i => selectedIds.has(i.id)).length; + + return ( +
+
+
+

Equipment list

+

+ {data.className} +

+
+

+ {selectedCount} of {data.items.length} selected +

+
+ +
+ {/* Header row */} +
+
+ + {/* Items */} +
    + {data.items.map(item => { + const isSelected = selectedIds.has(item.id); + const currentQty = quantities.get(item.id) ?? item.quantity; + const maxQty = item.quantity; + + return ( +
  • + {/* Checkbox */} + + + {/* Name */} + + + {/* Price */} + + {formatCurrency(item.unitPrice ?? 0)} + + + {/* Quantity */} +
    +
    + + { + const val = parseInt(e.target.value) || 0; + onQuantityChange(item.id, Math.max(MIN_QUANTITY, Math.min(maxQty, val))); + }} + disabled={!isSelected} + className="w-9 h-8 text-center text-[0.9rem] tabular-nums bg-transparent text-(--ink-1) disabled:text-(--ink-3) border-x border-(--line) outline-none focus:bg-(--brand-50)" + /> + +
    +
    +
  • + ); + })} +
+ + {/* Subtotal band */} +
+ + List subtotal + + + {formatCurrency(subtotal)} + +
+
+
+ ); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 556eab4..973c58d 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,32 +1,83 @@ -export default function Footer() { - const currentYear = new Date().getFullYear(); - - return ( - - ); -} +import Link from 'next/link'; + +export default function Footer() { + const currentYear = new Date().getFullYear(); + + return ( + + ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index bb31117..e7dd549 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,85 +1,225 @@ -'use client'; - -import Link from 'next/link'; -import Image from 'next/image'; -import {useRouter} from 'next/navigation'; -import { useAuth } from '@/contexts/AuthContext'; -import { useCart } from '@/contexts/CartContext'; - -export default function Header() { - const router = useRouter(); - const { isAuthenticated, logout } = useAuth(); - - // Only access cart context when authenticated (CartProvider is available) - let cartEntries: { id: string }[] = []; - try { - const cart = useCart(); - cartEntries = cart.cartEntries; - } catch { - // CartProvider not available (not authenticated) - } - - const hasItems = cartEntries.length > 0; - - const handleLogout = () => { - logout(); - router.replace('/login'); - }; - - return ( -
- -
- ); -} +'use client'; + +import Link from 'next/link'; +import { useRouter, usePathname } from 'next/navigation'; +import { useState } from 'react'; +import { useAuth } from '@/contexts/AuthContext'; +import { useCart } from '@/contexts/CartContext'; + +export default function Header() { + const router = useRouter(); + const pathname = usePathname(); + const { isAuthenticated, logout } = useAuth(); + const [mobileOpen, setMobileOpen] = useState(false); + + // Cart count — gracefully unavailable when not authenticated + let cartCount = 0; + try { + const cart = useCart(); + cartCount = cart.cartEntries.length; + } catch { /* CartProvider not mounted */ } + + const handleLogout = () => { + logout(); + router.replace('/login'); + }; + + const navLinks = [ + { href: '/', label: 'Home' }, + { href: '/about', label: 'About' }, + { href: '/contact', label: 'Contact' }, + ]; + + const isCurrent = (href: string) => + href === '/' ? pathname === '/' : pathname?.startsWith(href); + + return ( +
+ {/* fine top accent rule */} +
+ + +
+ ); +} + +function CartIcon({ filled }: { filled: boolean }) { + return ( + + ); +} diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 7698b47..73c17c7 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -1,104 +1,146 @@ 'use client'; -import {useState, FormEvent} from 'react'; -import {useRouter} from 'next/navigation'; -import {useAuth} from '@/contexts/AuthContext'; +import { useState, FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useAuth } from '@/contexts/AuthContext'; export default function LoginForm() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - // NEW: Add local error state to show login failures to the user const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [showPwd, setShowPwd] = useState(false); const router = useRouter(); - const {login} = useAuth(); + const { login } = useAuth(); const isFormValid = username.trim() !== '' && password.trim() !== ''; - // NEW: Make handleSubmit async const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setError(''); + if (!isFormValid) return; - if (isFormValid) { - setIsLoading(true); - try { - // CRITICAL FIX: Await the login action - await login(username, password); - // Only redirect if login succeeds (no error thrown) - router.push('/'); - } catch (err: any) { - // Show the error message from the backend (e.g., "Invalid password") - setError(err.message || 'Failed to login'); - } finally { - setIsLoading(false); - } + setIsLoading(true); + try { + await login(username, password); + router.push('/'); + } catch (err: any) { + setError(err.message || 'Failed to sign in. Please try again.'); + } finally { + setIsLoading(false); } }; return (
-
-

- Login -

+ {/* Brand mark */} +
+ + M + + + Motzkin Store + + + City of Kiryat Motzkin + + + +
+ +
+
+

Parent sign-in

+

+ Welcome back +

+

+ Sign in to access this year's equipment lists and complete your order. +

+
-
- {/* NEW: Error Alert */} + {error && ( -
- {error} +
+ + + + + + {error}
)}
- + setUsername(e.target.value)} + onChange={e => setUsername(e.target.value)} disabled={isLoading} - className="w-full px-3 py-2 border rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-zinc-800 dark:border-zinc-700 dark:text-white disabled:opacity-50" - placeholder="Enter your username" + className="field-input" + placeholder="e.g. parent.name" autoComplete="username" + autoFocus />
- - setPassword(e.target.value)} - disabled={isLoading} - className="w-full px-3 py-2 border rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-zinc-800 dark:border-zinc-700 dark:text-white disabled:opacity-50" - placeholder="Enter your password" - autoComplete="current-password" - /> + +
+ setPassword(e.target.value)} + disabled={isLoading} + className="field-input pr-20" + placeholder="••••••••" + autoComplete="current-password" + /> + +
+ +
+ +

+ Trouble signing in? Call the municipal service centre at{' '} + + 04-878-0900 + {' '} + or dial *5470. +

); -} \ No newline at end of file +} diff --git a/src/components/SaveToCartButton.tsx b/src/components/SaveToCartButton.tsx index 945001d..98cf3b2 100644 --- a/src/components/SaveToCartButton.tsx +++ b/src/components/SaveToCartButton.tsx @@ -14,7 +14,7 @@ interface SaveToCartButtonProps { disabled?: boolean; } -const DISABLED_DURATION = 3000; // 3 seconds +const DISABLED_DURATION = 3000; export default function SaveToCartButton({ school, @@ -31,11 +31,10 @@ export default function SaveToCartButton({ const handleSaveToCart = useCallback(() => { if (!school || !grade) return; - // Filter only selected items and map with current quantities const cartItems: CartEntryPayload['items'] = items .filter(item => selectedIds.has(item.id)) .map(item => ({ - id: Number(item.id), // Ensure id is number + id: Number(item.id), name: item.name, quantity: quantities.get(item.id) ?? item.quantity, unitPrice: item.unitPrice, @@ -50,22 +49,16 @@ export default function SaveToCartButton({ items: cartItems, }); - // Show toast and disable button temporarily setShowToast(true); setIsTemporarilyDisabled(true); - - setTimeout(() => { - setIsTemporarilyDisabled(false); - }, DISABLED_DURATION); + setTimeout(() => setIsTemporarilyDisabled(false), DISABLED_DURATION); }, [school, grade, selectedIds, quantities, items, addToCart]); - const handleCloseToast = useCallback(() => { - setShowToast(false); - }, []); + const handleCloseToast = useCallback(() => setShowToast(false), []); - const isButtonDisabled = disabled || isTemporarilyDisabled || !school || !grade || selectedIds.size === 0; + const isButtonDisabled = + disabled || isTemporarilyDisabled || !school || !grade || selectedIds.size === 0; - // Count selected items with quantity > 0 const validItemCount = items .filter(item => selectedIds.has(item.id)) .filter(item => (quantities.get(item.id) ?? item.quantity) > 0) @@ -77,23 +70,31 @@ export default function SaveToCartButton({ + ) : ( + + )} +
+ + {hint && !selectedItem && ( +

{hint}

+ )} + + {/* Inline list — always visible until a choice is made */} + {showList && ( +
+ {hasNoMatches ? ( +

+ No matches for "{query}" +

+ ) : ( +
    + {filteredItems.map(item => ( +
  • handleSelect(item)} + className="group relative px-4 py-3 text-[0.95rem] text-(--ink-2) cursor-pointer hover:text-(--ink-1) hover:bg-(--surface-page)/60 transition-colors" + > + {/* Left accent bar — slides in on hover */} +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 0bcb2d5..b0bec5e 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -16,12 +16,10 @@ export default function Toast({ message, isVisible, onClose, duration = 3000, de const delayTimerRef = useRef(null); const dismissTimerRef = useRef(null); - // Keep the ref up to date with the latest callback useEffect(() => { onCloseRef.current = onClose; }); - // Cleanup function to clear all timers const clearAllTimers = useCallback(() => { if (delayTimerRef.current) { clearTimeout(delayTimerRef.current); @@ -33,13 +31,10 @@ export default function Toast({ message, isVisible, onClose, duration = 3000, de } }, []); - // Handle visibility changes useEffect(() => { if (isVisible) { - // Start delay timer delayTimerRef.current = setTimeout(() => { setShowContent(true); - // Start dismiss timer after delay completes dismissTimerRef.current = setTimeout(() => { onCloseRef.current(); }, duration); @@ -47,9 +42,7 @@ export default function Toast({ message, isVisible, onClose, duration = 3000, de } return () => { - // Cleanup: clear timers and reset state clearAllTimers(); - // Use setTimeout to avoid synchronous setState in effect setTimeout(() => setShowContent(false), 0); }; }, [isVisible, delay, duration, clearAllTimers]); @@ -59,34 +52,31 @@ export default function Toast({ message, isVisible, onClose, duration = 3000, de return (
- {/* Success checkmark icon */} - - - - {message} + + + + {message}
- {/* Line timer on bottom edge - darker shade of emerald */}
); } - From 6cbb6dacaefecbb2ed2fc1247a6f31ed57a3be6d Mon Sep 17 00:00:00 2001 From: Noam Ben Shimon Date: Wed, 20 May 2026 09:04:08 +0300 Subject: [PATCH 2/6] Fix test failures Signed-off-by: Noam Ben Shimon --- src/components/SearchableSelect.tsx | 43 +++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/components/SearchableSelect.tsx b/src/components/SearchableSelect.tsx index 9b435eb..d5a64ca 100644 --- a/src/components/SearchableSelect.tsx +++ b/src/components/SearchableSelect.tsx @@ -21,7 +21,7 @@ interface SearchableSelectProps { export default function SearchableSelect({ label, items, - placeholder = 'Search…', + placeholder = 'Search...', onSelect, onClear, disabled = false, @@ -29,21 +29,36 @@ export default function SearchableSelect({ }: SearchableSelectProps) { const [query, setQuery] = useState(''); const [selectedItem, setSelectedItem] = useState(null); + const [isOpen, setIsOpen] = useState(false); const inputRef = useRef(null); + const blurTimeoutRef = useRef | null>(null); // Reset whenever the source list changes (e.g. parent reloaded options) useEffect(() => { setQuery(''); setSelectedItem(null); + setIsOpen(false); }, [items]); + useEffect(() => { + return () => { + if (blurTimeoutRef.current) { + clearTimeout(blurTimeoutRef.current); + } + }; + }, []); + const filteredItems = selectedItem ? items : items.filter(item => item.name.toLowerCase().includes(query.toLowerCase())); const handleSelect = (item: SelectItem) => { + if (blurTimeoutRef.current) { + clearTimeout(blurTimeoutRef.current); + } setSelectedItem(item); setQuery(item.name); + setIsOpen(false); onSelect(item); inputRef.current?.blur(); }; @@ -51,12 +66,29 @@ export default function SearchableSelect({ const handleClear = () => { setSelectedItem(null); setQuery(''); + setIsOpen(true); onClear?.(); // Re-focus so keyboard users can immediately search again requestAnimationFrame(() => inputRef.current?.focus()); }; - const showList = !selectedItem && !disabled && items.length > 0; + const handleFocus = () => { + if (blurTimeoutRef.current) { + clearTimeout(blurTimeoutRef.current); + } + setIsOpen(true); + }; + + const handleBlur = () => { + if (blurTimeoutRef.current) { + clearTimeout(blurTimeoutRef.current); + } + blurTimeoutRef.current = setTimeout(() => { + setIsOpen(false); + }, 200); + }; + + const showList = isOpen && !selectedItem && !disabled && items.length > 0; const hasNoMatches = showList && filteredItems.length === 0; return ( @@ -85,6 +117,8 @@ export default function SearchableSelect({ placeholder={placeholder} value={query} onChange={e => setQuery(e.target.value)} + onFocus={handleFocus} + onBlur={handleBlur} autoComplete="off" aria-expanded={showList} aria-controls={showList ? `${label}-options` : undefined} @@ -150,7 +184,10 @@ export default function SearchableSelect({ key={item.id} role="option" aria-selected={false} - onClick={() => handleSelect(item)} + onMouseDown={e => { + e.preventDefault(); + handleSelect(item); + }} className="group relative px-4 py-3 text-[0.95rem] text-(--ink-2) cursor-pointer hover:text-(--ink-1) hover:bg-(--surface-page)/60 transition-colors" > {/* Left accent bar — slides in on hover */} From 1b9cf4130f1607e405f76d8644554c01e850df3f Mon Sep 17 00:00:00 2001 From: Noam Ben Shimon Date: Wed, 20 May 2026 10:37:50 +0300 Subject: [PATCH 3/6] Fixed testing errors and fixed back-from-payment issue Signed-off-by: Noam Ben Shimon --- src/app/cart/page.tsx | 4 +- src/app/contact/page.tsx | 4 +- src/app/page.tsx | 60 +++++++++++++++-------- src/app/payment/cancel/page.tsx | 2 +- src/components/LoginForm.tsx | 11 +++-- src/components/SearchableSelect.tsx | 2 +- src/contexts/CartContext.tsx | 74 ++++++++++++++++++----------- src/services/api.ts | 38 +++++++++++---- 8 files changed, 127 insertions(+), 68 deletions(-) diff --git a/src/app/cart/page.tsx b/src/app/cart/page.tsx index b9edae7..6abec27 100644 --- a/src/app/cart/page.tsx +++ b/src/app/cart/page.tsx @@ -101,7 +101,7 @@ export default function CartPage() {

Your cart is empty

- Once you save a school's equipment list, it will appear here ready + Once you save a school's equipment list, it will appear here ready for checkout.

@@ -191,7 +191,7 @@ export default function CartPage() {

- Ready to complete your order? You'll be redirected to a secure + Ready to complete your order? You'll be redirected to a secure payment page.

diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index 000f600..db6b80c 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -11,11 +11,11 @@ export default function ContactPage() {

Get in touch

- We're here to help. + We're here to help.

For questions about school equipment orders, payments, or - technical issues, reach out to the municipality's service centre + technical issues, reach out to the municipality's service centre or to the developer team.

diff --git a/src/app/page.tsx b/src/app/page.tsx index cd0c610..9ceef8f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -21,6 +21,33 @@ interface EquipmentItemResponse { unitPrice?: number; } +type RawEquipmentItem = Omit & { + id: number | string; + price?: number; +}; + +interface RawEquipmentResponse { + items?: RawEquipmentItem[]; + [key: string]: unknown; +} + +const isRawEquipmentResponse = (value: unknown): value is RawEquipmentResponse => { + if (!value || typeof value !== 'object') return false; + return Array.isArray((value as { items?: unknown }).items); +}; + +const normalizeEquipmentItems = (items: RawEquipmentItem[]): EquipmentItemResponse[] => + items.map(item => ({ + ...item, + id: typeof item.id === 'string' ? parseInt(item.id, 10) : item.id, + unitPrice: + typeof item.unitPrice === 'number' + ? item.unitPrice + : typeof item.price === 'number' + ? item.price + : item.unitPrice, + })); + export default function Home() { const { isAuthenticated } = useAuth(); @@ -34,13 +61,13 @@ export default function Home() { const [isLoading, setIsLoading] = useState(false); const fetchData = useCallback( - async ( + async ( endpoint: string, - setter: (data: SelectItem[] | EquipmentItemResponse[] | any) => void, + setter: (data: T) => void, resetSelections: boolean = true ) => { if (resetSelections) { - setter([]); + setter([] as T); setEquipmentData(null); } @@ -49,22 +76,15 @@ export default function Home() { const url = `${API_BASE_URL}${endpoint}`; const response = await fetch(url); if (!response.ok) throw new Error(`Failed to fetch ${endpoint}. Status: ${response.status}`); - const data = await response.json(); - if (endpoint.startsWith('/api/equipment')) { - if (data.items) { - data.items = data.items.map((item: any) => ({ - ...item, - id: typeof item.id === 'string' ? parseInt(item.id, 10) : item.id, - unitPrice: - typeof item.unitPrice === 'number' - ? item.unitPrice - : typeof item.price === 'number' - ? item.price - : item.unitPrice, - })); - } + const data = (await response.json()) as unknown; + let payload: unknown = data; + if (endpoint.startsWith('/api/equipment') && isRawEquipmentResponse(data)) { + payload = { + ...data, + items: data.items ? normalizeEquipmentItems(data.items) : [], + }; } - setter(data); + setter(payload as T); } catch (error) { console.error(`Error fetching ${endpoint}:`, error); } finally { @@ -158,7 +178,7 @@ export default function Home() {

School supplies

- Order your child's + Order your child's
school list @@ -166,7 +186,7 @@ export default function Home() { , in one go.

- Pick your school and your child's grade to see the equipment list, + Pick your school and your child's grade to see the equipment list, then check out in one payment.

diff --git a/src/app/payment/cancel/page.tsx b/src/app/payment/cancel/page.tsx index b98a701..c758687 100644 --- a/src/app/payment/cancel/page.tsx +++ b/src/app/payment/cancel/page.tsx @@ -26,7 +26,7 @@ function PaymentCancelContent() {

We did not charge your card. Your saved equipment lists are still - in your cart whenever you're ready to try again. + in your cart whenever you're ready to try again.

diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 73c17c7..0d6f4da 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -26,8 +26,9 @@ export default function LoginForm() { try { await login(username, password); router.push('/'); - } catch (err: any) { - setError(err.message || 'Failed to sign in. Please try again.'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : ''; + setError(message || 'Failed to login. Please try again.'); } finally { setIsLoading(false); } @@ -54,10 +55,10 @@ export default function LoginForm() {

Parent sign-in

- Welcome back + Login

- Sign in to access this year's equipment lists and complete your order. + Sign in to access this year's equipment lists and complete your order.

@@ -126,7 +127,7 @@ export default function LoginForm() { Signing in… ) : ( - 'Sign in' + 'Login' )} diff --git a/src/components/SearchableSelect.tsx b/src/components/SearchableSelect.tsx index d5a64ca..240de2c 100644 --- a/src/components/SearchableSelect.tsx +++ b/src/components/SearchableSelect.tsx @@ -5,7 +5,7 @@ import { useState, useEffect, useRef } from 'react'; export interface SelectItem { id: string | number; name: string; - [key: string]: any; + [key: string]: unknown; } interface SearchableSelectProps { diff --git a/src/contexts/CartContext.tsx b/src/contexts/CartContext.tsx index 74fe20c..e8e97cd 100644 --- a/src/contexts/CartContext.tsx +++ b/src/contexts/CartContext.tsx @@ -23,6 +23,7 @@ import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from 'react'; import * as api from '@/services/api'; +import type { CartApiEntry, CartApiItem } from '@/services/api'; import { CartEntryPayload, CartItem } from '@/types/cart'; import { useAuth } from './AuthContext'; @@ -62,6 +63,31 @@ export function CartProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const normalizeEntry = (entry: CartApiEntry, index: number): CartEntry => { + const school = entry.school ?? { id: 0, name: 'Unknown school' }; + const grade = entry.grade ?? { id: 0, name: 'Unknown grade' }; + const items = Array.isArray(entry.items) ? entry.items : []; + + return { + id: typeof entry.id === 'string' && entry.id.trim() !== '' ? entry.id : `${Date.now()}-${index}`, + timestamp: typeof entry.timestamp === 'number' ? entry.timestamp : Date.now(), + school: { + id: Number(school.id), + name: school.name ?? 'Unknown school', + }, + grade: { + id: Number(grade.id), + name: grade.name ?? 'Unknown grade', + }, + items: items.map((item: CartApiItem) => ({ + id: Number(item.id), + name: item.name ?? 'Unknown item', + quantity: typeof item.quantity === 'number' ? item.quantity : 0, + unitPrice: typeof item.unitPrice === 'number' ? item.unitPrice : undefined, + })), + }; + }; + // Serialize all cart mutations so concurrent add/remove/clear calls never // overlap and overwrite each other (e.g. two rapid "Save to Cart" clicks // both reading the same server snapshot and racing on the write). @@ -84,30 +110,14 @@ export function CartProvider({ children }: { children: ReactNode }) { setError(null); try { const data = await api.getCart(userid); - // Normalize all ids to number for CartEntry and CartItem - const normalized = Array.isArray(data) - ? data.map((entry: any) => ({ - ...entry, - school: { - ...entry.school, - id: Number(entry.school.id), - }, - grade: { - ...entry.grade, - id: Number(entry.grade.id), - }, - items: Array.isArray(entry.items) - ? entry.items.map((item: any) => ({ - ...item, - id: Number(item.id), - })) - : [], - })) + const normalized: CartEntry[] = Array.isArray(data) + ? data.map((entry: CartApiEntry, index: number) => normalizeEntry(entry, index)) : []; setCartEntries(normalized); console.log('[CartContext] fetchCart: setCartEntries', normalized); - } catch (err: any) { - setError(err.message || 'Failed to fetch cart'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : ''; + setError(message || 'Failed to fetch cart'); setCartEntries([]); console.log('[CartContext] fetchCart: setCartEntries([]) after error'); } finally { @@ -116,7 +126,12 @@ export function CartProvider({ children }: { children: ReactNode }) { }, [userid]); useEffect(() => { - fetchCart(); + const timeoutId = setTimeout(() => { + void fetchCart(); + }, 0); + return () => { + clearTimeout(timeoutId); + }; }, [fetchCart, isAuthenticated]); const addToCart = useCallback(async (entry: CartEntryPayload) => { @@ -147,8 +162,9 @@ export function CartProvider({ children }: { children: ReactNode }) { setCartEntries(next); // Optimistic update. await api.updateCart(userid, next); await fetchCart(); // Reconcile with backend (gets real ids/prices). - } catch (err: any) { - setError(err.message || 'Failed to add to cart'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : ''; + setError(message || 'Failed to add to cart'); await fetchCart(); // Roll back to authoritative server state. } }); @@ -164,8 +180,9 @@ export function CartProvider({ children }: { children: ReactNode }) { setCartEntries(next); await api.updateCart(userid, next); await fetchCart(); - } catch (err: any) { - setError(err.message || 'Failed to remove from cart'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : ''; + setError(message || 'Failed to remove from cart'); await fetchCart(); } }); @@ -179,8 +196,9 @@ export function CartProvider({ children }: { children: ReactNode }) { try { await api.updateCart(userid, []); await fetchCart(); - } catch (err: any) { - setError(err.message || 'Failed to clear cart'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : ''; + setError(message || 'Failed to clear cart'); await fetchCart(); } }); diff --git a/src/services/api.ts b/src/services/api.ts index 56f0b47..3c1e330 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -12,6 +12,24 @@ import { CartEntryPayload } from '@/types/cart'; import { CreateOrderPayload, Order, PaymentSession, PaymentResult } from '@/types/order'; +export type CartApiItem = { + id: number | string; + name?: string; + quantity?: number; + unitPrice?: number; + price?: number; + [key: string]: unknown; +}; + +export type CartApiEntry = { + id?: string; + timestamp?: number; + school?: { id: number | string; name: string }; + grade?: { id: number | string; name: string }; + items?: CartApiItem[]; + [key: string]: unknown; +}; + /** * Gets the API base URL. * Uses a getter to avoid issues during SSR/module evaluation. @@ -122,7 +140,7 @@ export async function checkAuth() { * @returns Promise resolving to the user's cart data * @throws {Error} If cart fetch fails */ -export async function getCart(userid: string) { +export async function getCart(userid: string): Promise { const res = await fetch(`${getApiBase()}/api/cart?userid=${encodeURIComponent(userid)}`, { method: 'GET', credentials: 'include', @@ -170,23 +188,25 @@ export async function updateCart(userid: string, items: CartEntryPayload[]) { return res.json(); } -function applyCartPricing(data: any) { +function applyCartPricing(data: unknown): CartApiEntry[] { const placeholderUnitPrice = 1; // TODO: Replace with backend-provided per-item prices. if (!Array.isArray(data)) return []; - return data.map((entry: any) => ({ - ...entry, - items: Array.isArray(entry.items) - ? entry.items.map((item: any) => ({ + return data.map(entry => { + const safeEntry = entry as CartApiEntry; + const items = Array.isArray(safeEntry.items) ? safeEntry.items : []; + return { + ...safeEntry, + items: items.map(item => ({ ...item, unitPrice: typeof item.unitPrice === 'number' ? item.unitPrice : (typeof item.price === 'number' ? item.price : placeholderUnitPrice), - })) - : [], - })); + })), + }; + }); } // ============================================================================= From 45da331e9f92ccbfcfa4fcbfcd09009d0cf1e18f Mon Sep 17 00:00:00 2001 From: Noam Ben Shimon Date: Wed, 20 May 2026 10:52:27 +0300 Subject: [PATCH 4/6] Refactor ProtectedRoute component to improve loading state handling and reduce blank flashes; introduce AuthSpinner for better user experience during authentication. Signed-off-by: Noam Ben Shimon --- src/components/ProtectedRoute.tsx | 47 ++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 97e86fb..afebedb 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -3,35 +3,50 @@ import { useAuth } from '@/contexts/AuthContext'; import { usePathname, useRouter } from 'next/navigation'; import { useEffect } from 'react'; +function AuthSpinner() { + return ( +
+ + + + +
+ ); +} + export default function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuth(); const pathname = usePathname(); const router = useRouter(); + const needsRedirect = !isLoading && !isAuthenticated && pathname !== '/login'; + useEffect(() => { // Wait for the initial auth check to settle before redirecting. // Otherwise a cold mount (e.g. browser back from Stripe) would bounce - // through /login for a moment, producing a blank flash. - if (isLoading) return; - if (!isAuthenticated && pathname !== '/login') { + // through /login for a moment. + if (needsRedirect) { router.replace('/login'); } - }, [isAuthenticated, isLoading, pathname, router]); + }, [needsRedirect, router]); - if (isLoading) { - return ( -
- - - - -
- ); + // Cover three blank-screen windows with a single spinner: + // 1. Initial auth check still in flight (cold mount). + // 2. Auth resolved as unauthenticated -> redirect to /login pending. + // 3. Anything else where we'd otherwise paint nothing. + // Crucially, this avoids the historical `return null` flash that showed a + // white page on browser-back from Stripe when the session cookie was lost + // and the redirect to /login hadn't committed yet. + if (isLoading || needsRedirect) { + return ; } - if (!isAuthenticated && pathname !== '/login') { - return null; - } return <>{children}; } From 3d6d397316e4cbd963af88cd28e10300c030aab8 Mon Sep 17 00:00:00 2001 From: Noam Ben Shimon Date: Wed, 20 May 2026 11:25:19 +0300 Subject: [PATCH 5/6] Fixed back issue Signed-off-by: Noam Ben Shimon --- src/app/checkout/page.tsx | 5 ++++ src/app/layout.tsx | 34 +++++++++++++++++++++++++ src/contexts/AuthContext.tsx | 48 ++++++++++++++++++++++++------------ 3 files changed, 71 insertions(+), 16 deletions(-) diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx index cff6ee2..05f53df 100644 --- a/src/app/checkout/page.tsx +++ b/src/app/checkout/page.tsx @@ -13,6 +13,11 @@ export default function CheckoutPage() { const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null); + // Note: the bfcache/back-forward redirect for this route runs as an + // inline pre-hydration script in src/app/layout.tsx, because doing it in + // a useEffect here is too late — ProtectedRoute keeps CheckoutPage + // unmounted while it shows the auth spinner. + const formatCurrency = (amount: number) => `${amount.toFixed(2)} ILS`; const totalItems = cartEntries.reduce( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 75d7469..032a017 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -30,6 +30,37 @@ export const metadata: Metadata = { "Order the school supply list for your child in Kiryat Motzkin.", }; +// Pre-hydration guard for the Stripe back-button hang. +// +// When the user is on /checkout, clicks "Confirm & pay", lands on Stripe, then +// presses browser back, the browser brings them back to /checkout via one of: +// - bfcache restore (pageshow.persisted === true): React state is frozen +// with isProcessing=true, the page reappears stuck on the spinner button. +// - Cold reload (Navigation Timing type === 'back_forward'): the page +// re-fetches, but Next.js dev mode and our auth gate can leave it stuck on +// the full-page AuthSpinner before CheckoutPage ever mounts. +// +// Both paths reach /checkout in an unrecoverable state. Doing the redirect +// inside CheckoutPage is too late — its effect doesn't run while ProtectedRoute +// is still showing the spinner. We run this as a synchronous, pre-hydration +// inline script so it fires before React/Next does anything, independent of +// auth/cart state, the router, or whether the page bfcached. +const BACK_NAV_REDIRECT_SCRIPT = ` +(function () { + function onCheckout() { + return location.pathname === '/checkout' || location.pathname.indexOf('/checkout/') === 0; + } + function goHome() { location.replace('/'); } + try { + var nav = performance.getEntriesByType('navigation')[0]; + if (nav && nav.type === 'back_forward' && onCheckout()) { goHome(); return; } + } catch (e) {} + window.addEventListener('pageshow', function (e) { + if (e.persisted && onCheckout()) goHome(); + }); +})(); +`; + export default function RootLayout({ children, }: Readonly<{ @@ -41,6 +72,9 @@ export default function RootLayout({ dir="ltr" className={`${fraunces.variable} ${geist.variable} ${geistMono.variable}`} > + +