diff --git a/src/__tests__/contexts/AuthContext.test.tsx b/src/__tests__/contexts/AuthContext.test.tsx index ab4a956..9f99da1 100644 --- a/src/__tests__/contexts/AuthContext.test.tsx +++ b/src/__tests__/contexts/AuthContext.test.tsx @@ -1,33 +1,29 @@ /** * @fileoverview AuthContext tests - * Tests that the useAuth hook throws when used outside provider - * and that the context provides the expected interface + * Tests that the useAuth hook throws when used outside provider. */ -import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import '@testing-library/jest-dom'; import { useAuth } from '@/contexts/AuthContext'; -// We'll create a simple test component -function TestAuthHookUsage() { - try { - const auth = useAuth(); - return
Has auth context: {auth.isAuthenticated ? 'yes' : 'no'}
; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - return
{message}
; - } +// Component that calls useAuth without a provider — it will throw on render. +// Rendering this with React Testing Library propagates the render error, which +// we assert via `expect(() => render(...)).toThrow(...)`. JSX is kept outside +// of any try/catch so the test plays nicely with React's error handling model +// (per the react-hooks/error-boundaries lint rule). +function HookUserOutsideProvider() { + useAuth(); + return null; } describe('AuthContext', () => { describe('useAuth Hook', () => { it('should throw error when used outside AuthProvider', () => { - // Suppress console.error for this test const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - render(); - - expect(screen.getByTestId('auth-error')).toHaveTextContent('useAuth must be used within an AuthProvider'); + expect(() => render()).toThrow( + 'useAuth must be used within an AuthProvider' + ); consoleSpy.mockRestore(); }); diff --git a/src/__tests__/contexts/CartContext.test.tsx b/src/__tests__/contexts/CartContext.test.tsx index b62bd9d..a6d45df 100644 --- a/src/__tests__/contexts/CartContext.test.tsx +++ b/src/__tests__/contexts/CartContext.test.tsx @@ -1,21 +1,16 @@ /** * @fileoverview CartContext tests - * Tests that the useCart hook throws when used outside provider + * Tests that the useCart hook throws when used outside provider. */ -import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import '@testing-library/jest-dom'; import { useCart } from '@/contexts/CartContext'; -// Test component that uses the cart context -function TestCartHookUsage() { - try { - const cart = useCart(); - return
Has cart context: {cart.cartEntries.length} items
; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - return
{message}
; - } +// See AuthContext.test.tsx for why this pattern (component that throws, +// asserted via expect(...).toThrow) replaces the JSX-in-try/catch approach. +function HookUserOutsideProvider() { + useCart(); + return null; } describe('CartContext', () => { @@ -23,9 +18,9 @@ describe('CartContext', () => { it('should throw error when used outside CartProvider', () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - render(); - - expect(screen.getByTestId('cart-error')).toHaveTextContent('useCart must be used within a CartProvider'); + expect(() => render()).toThrow( + 'useCart must be used within a CartProvider' + ); consoleSpy.mockRestore(); }); 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..6abec27 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..05f53df 100644 --- a/src/app/checkout/page.tsx +++ b/src/app/checkout/page.tsx @@ -13,35 +13,39 @@ 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( - (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 +57,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 +76,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..db6b80c 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..032a017 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,30 +1,87 @@ -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.", +}; + +// 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<{ + children: React.ReactNode; +}>) { + return ( + + +