From f2955c7ffebbe81a3569de6a357988efbcef266d Mon Sep 17 00:00:00 2001 From: thinh Date: Fri, 24 Oct 2025 14:50:37 +0700 Subject: [PATCH 1/3] Changed: UI order page --- public/sitemap.xml | 10 +- .../Checkout/CheckoutPage/CheckoutPage.tsx | 23 ++- src/components/Order/OrderCard/OrderCard.scss | 153 +++++++++++++++ src/components/Order/OrderCard/OrderCard.tsx | 67 +++++++ src/components/Order/OrderCard/index.ts | 1 + src/components/Order/OrderPage/OrderPage.scss | 184 ------------------ src/components/Order/OrderPage/OrderPage.tsx | 139 +++++-------- .../Order/OrderSummary/OrderSummary.scss | 58 ++++++ .../Order/OrderSummary/OrderSummary.tsx | 55 ++++++ src/components/Order/OrderSummary/index.ts | 1 + src/components/index.ts | 4 + src/context/CartContext.tsx | 58 +++++- 12 files changed, 467 insertions(+), 286 deletions(-) create mode 100644 src/components/Order/OrderCard/OrderCard.scss create mode 100644 src/components/Order/OrderCard/OrderCard.tsx create mode 100644 src/components/Order/OrderCard/index.ts create mode 100644 src/components/Order/OrderSummary/OrderSummary.scss create mode 100644 src/components/Order/OrderSummary/OrderSummary.tsx create mode 100644 src/components/Order/OrderSummary/index.ts diff --git a/public/sitemap.xml b/public/sitemap.xml index 5d98da0..74f63e9 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -1,7 +1,9 @@ -https://trinhquocthinh.github.io/foodhub2025-10-21T10:49:52.341Zdaily1 -https://trinhquocthinh.github.io/foodhub/sitemap.xml2025-10-21T10:49:52.341Zmonthly0.7 -https://trinhquocthinh.github.io/foodhub/robots.txt2025-10-21T10:49:52.341Zmonthly0.7 -https://trinhquocthinh.github.io/foodhub/menu2025-10-21T10:49:52.341Zmonthly0.7 +https://trinhquocthinh.github.io/foodhub/menu2025-10-23T03:15:18.758Zmonthly0.7 +https://trinhquocthinh.github.io/foodhub2025-10-23T03:15:18.758Zdaily1 +https://trinhquocthinh.github.io/foodhub/robots.txt2025-10-23T03:15:18.758Zmonthly0.7 +https://trinhquocthinh.github.io/foodhub/order2025-10-23T03:15:18.758Zmonthly0.7 +https://trinhquocthinh.github.io/foodhub/checkout2025-10-23T03:15:18.758Zmonthly0.7 +https://trinhquocthinh.github.io/foodhub/sitemap.xml2025-10-23T03:15:18.758Zmonthly0.7 \ No newline at end of file diff --git a/src/components/Checkout/CheckoutPage/CheckoutPage.tsx b/src/components/Checkout/CheckoutPage/CheckoutPage.tsx index b7d90ae..b51a09a 100644 --- a/src/components/Checkout/CheckoutPage/CheckoutPage.tsx +++ b/src/components/Checkout/CheckoutPage/CheckoutPage.tsx @@ -2,7 +2,8 @@ import Image from 'next/image'; import Link from 'next/link'; -import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; import type { ChangeEvent, FormEvent } from 'react'; import { IoArrowBack, @@ -40,11 +41,18 @@ const initialFormState: CheckoutFormState = { }; const CheckoutPage = () => { - const { items, subtotal } = useCart(); + const { items, subtotal, isHydrated } = useCart(); + const router = useRouter(); const [formState, setFormState] = useState(initialFormState); const isEmpty = items.length === 0; + + useEffect(() => { + if (isHydrated && isEmpty) { + router.push('/menu'); + } + }, [isHydrated, isEmpty, router]); const serviceFee = isEmpty ? 0 : SERVICE_FEE; const tax = isEmpty ? 0 : subtotal * TAX_RATE; const total = subtotal + serviceFee + tax; @@ -71,6 +79,17 @@ const CheckoutPage = () => { setFormState(initialFormState); }; + // Show loading state during hydration to prevent flash of empty state + if (!isHydrated) { + return ( +
+
+

Loading checkout...

+
+
+ ); + } + if (isEmpty) { return (
diff --git a/src/components/Order/OrderCard/OrderCard.scss b/src/components/Order/OrderCard/OrderCard.scss new file mode 100644 index 0000000..4561d69 --- /dev/null +++ b/src/components/Order/OrderCard/OrderCard.scss @@ -0,0 +1,153 @@ +@use '../../../styles/variables' as *; +@use '../../../styles/mixins' as *; + +.order-card { + display: grid; + grid-template-columns: 165px 1fr; + gap: 20px; + background: $white; + box-shadow: 0 22px 54px hsla(229, 31%, 18%, 0.08); + align-items: center; + + @include laptop-up { + grid-template-columns: 120px 1fr; + } + + &__media { + width: 165px; + height: 165px; + overflow: hidden; + + @include laptop-up { + width: 120px; + height: 120px; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &__content { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + padding-inline-end: 20px; + + @include laptop-up { + flex-direction: row; + } + } + + h2 { + font-size: 18px; + font-weight: 600; + color: $space-cadet; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; + text-align: center; + + @include laptop-up { + text-align: left; + } + } + + &__total { + font-weight: 600; + color: $space-cadet; + display: inline-flex; + align-items: baseline; + gap: 4px; + padding-block: 12px; + + @include laptop-up { + padding-block: 0; + order: 3; + } + + .currency { + font-size: 12px; + letter-spacing: 0.08em; + } + } + + &__actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 16px; + order: 3; + + @include laptop-up { + order: 2; + } + } + + &__quantity { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 8px; + + button { + @include button-reset; + width: 26px; + height: 26px; + border-radius: 50%; + background: $saffron; + color: $space-cadet; + display: inline-flex; + align-items: center; + justify-content: center; + transition: $default-transition; + + &:hover { + filter: saturate(1.05) brightness(1.05); + } + + svg { + width: 13px; + height: 13px; + } + } + + span { + min-width: 32px; + text-align: center; + font-weight: 600; + color: $space-cadet; + font-size: 16px; + } + } + + &__remove { + @include button-reset; + width: 32px; + height: 32px; + border-radius: 50%; + background: hsla(0, 0%, 96%, 1); + color: $rhythm; + display: inline-flex; + align-items: center; + justify-content: center; + transition: $default-transition; + + &:hover { + color: $red-orange-crayola; + background: hsla(0, 100%, 66%, 0.16); + } + + svg { + width: 18px; + height: 18px; + } + } +} diff --git a/src/components/Order/OrderCard/OrderCard.tsx b/src/components/Order/OrderCard/OrderCard.tsx new file mode 100644 index 0000000..8ee3df2 --- /dev/null +++ b/src/components/Order/OrderCard/OrderCard.tsx @@ -0,0 +1,67 @@ +'use client'; + +import Image from 'next/image'; +import { memo } from 'react'; +import { IoRemove, IoAdd } from 'react-icons/io5'; + +import { getImagePath } from '@/lib/getImagePath'; +import type { CartItem } from '@/types'; + +import './OrderCard.scss'; + +interface OrderCardProps { + item: CartItem; + removeItem: (id: string) => void; + incrementItem: (id: string) => void; + decrementItem: (id: string) => void; +} + +const OrderCard = ({ item, incrementItem, decrementItem }: OrderCardProps) => { + return ( +
+
+ {item.name} +
+ +
+

{item.name}

+ +
+
+ + {item.quantity} + +
+
+ +

+ $ + {(item.price * item.quantity).toFixed(2)} +

+
+
+ ); +}; + +export default memo(OrderCard); diff --git a/src/components/Order/OrderCard/index.ts b/src/components/Order/OrderCard/index.ts new file mode 100644 index 0000000..10faf34 --- /dev/null +++ b/src/components/Order/OrderCard/index.ts @@ -0,0 +1 @@ +export { default } from './OrderCard'; diff --git a/src/components/Order/OrderPage/OrderPage.scss b/src/components/Order/OrderPage/OrderPage.scss index d125668..a599176 100644 --- a/src/components/Order/OrderPage/OrderPage.scss +++ b/src/components/Order/OrderPage/OrderPage.scss @@ -72,190 +72,6 @@ $order-max-width: 1120px; } } -.order-card { - display: grid; - grid-template-columns: 96px 1fr; - gap: 20px; - padding: 20px; - border-radius: 24px; - background: $white; - box-shadow: 0 22px 54px hsla(229, 31%, 18%, 0.08); - align-items: center; - - &__media { - width: 96px; - height: 96px; - border-radius: 20px; - overflow: hidden; - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - } - - &__content { - display: flex; - flex-direction: column; - gap: 16px; - } - - &__header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 12px; - - h2 { - font-size: 18px; - font-weight: 600; - color: $space-cadet; - } - } - - &__price, - &__total { - font-weight: 600; - color: $space-cadet; - display: inline-flex; - align-items: baseline; - gap: 4px; - - .currency { - font-size: 12px; - letter-spacing: 0.08em; - } - } - - &__actions { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - align-items: center; - gap: 16px; - } - - &__quantity { - display: inline-flex; - align-items: center; - gap: 12px; - padding: 8px; - border-radius: 999px; - background: $cultured; - - button { - @include button-reset; - width: 36px; - height: 36px; - border-radius: 50%; - background: $saffron; - color: $space-cadet; - display: inline-flex; - align-items: center; - justify-content: center; - transition: $default-transition; - - &:hover { - filter: saturate(1.05) brightness(1.05); - } - - svg { - width: 18px; - height: 18px; - } - } - - span { - min-width: 32px; - text-align: center; - font-weight: 600; - color: $space-cadet; - font-size: 16px; - } - } - - &__remove { - @include button-reset; - width: 32px; - height: 32px; - border-radius: 50%; - background: hsla(0, 0%, 96%, 1); - color: $rhythm; - display: inline-flex; - align-items: center; - justify-content: center; - transition: $default-transition; - - &:hover { - color: $red-orange-crayola; - background: hsla(0, 100%, 66%, 0.16); - } - - svg { - width: 18px; - height: 18px; - } - } -} - -.order-summary { - background: $white; - border-radius: 28px; - box-shadow: 0 24px 58px hsla(229, 31%, 18%, 0.12); - padding: 32px 28px; - display: flex; - flex-direction: column; - gap: 24px; - - h2 { - font-size: 20px; - font-weight: 600; - color: $space-cadet; - } - - &__totals { - display: flex; - flex-direction: column; - gap: 12px; - - > div { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 15px; - color: $rhythm; - } - } - - &__grand { - margin-top: 8px; - padding-top: 12px; - border-top: 1px solid hsla(229, 21%, 23%, 0.1); - - dt { - color: $space-cadet; - font-weight: 600; - } - - dd { - color: $space-cadet; - font-weight: 700; - font-size: 20px; - } - } - - &__note { - font-size: 14px; - line-height: 1.6; - color: $rhythm; - } - - &__cta { - justify-content: center; - } -} - @include tablet-up { .order-page { padding-inline: 30px; diff --git a/src/components/Order/OrderPage/OrderPage.tsx b/src/components/Order/OrderPage/OrderPage.tsx index 862e413..0c03636 100644 --- a/src/components/Order/OrderPage/OrderPage.tsx +++ b/src/components/Order/OrderPage/OrderPage.tsx @@ -1,21 +1,39 @@ 'use client'; -import Image from 'next/image'; import Link from 'next/link'; -import { IoAdd, IoArrowBack, IoRemove, IoTrashOutline } from 'react-icons/io5'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { IoArrowBack } from 'react-icons/io5'; +import { OrderCard, OrderSummary } from '@/components'; import { useCart } from '@/context/CartContext'; -import { getImagePath } from '@/lib/getImagePath'; import './OrderPage.scss'; const SERVICE_FEE = 4.5; const OrderPage = () => { - const { items, subtotal, incrementItem, decrementItem, removeItem } = - useCart(); + const { + items, + subtotal, + isHydrated, + incrementItem, + decrementItem, + removeItem, + } = useCart(); + const router = useRouter(); const isEmpty = items.length === 0; + + useEffect(() => { + if (isHydrated && isEmpty) { + router.push('/menu'); + } + }, [isHydrated, isEmpty, router]); + + const totalItems = items + .map(item => item.quantity) + .reduce((a, b) => a + b, 0); const serviceFee = isEmpty ? 0 : SERVICE_FEE; const total = subtotal + serviceFee; const emptyCopy = @@ -25,6 +43,17 @@ const OrderPage = () => { const summaryNote = 'Need dietary tweaks or timing adjustments? Add a note during checkout and our team will tailor it.'; + // Show loading state during hydration to prevent flash of empty state + if (!isHydrated) { + return ( +
+
+

Loading your order...

+
+
+ ); + } + if (isEmpty) { return (
@@ -55,95 +84,23 @@ const OrderPage = () => {
{items.map(item => ( -
-
- {item.name} -
- -
-
-

{item.name}

- -
- -

- $ - {item.price} -

- -
-
- - {item.quantity} - -
- -

- $ - {(item.price * item.quantity).toFixed(2)} -

-
-
-
+ ))}
- +
); diff --git a/src/components/Order/OrderSummary/OrderSummary.scss b/src/components/Order/OrderSummary/OrderSummary.scss new file mode 100644 index 0000000..b1cac20 --- /dev/null +++ b/src/components/Order/OrderSummary/OrderSummary.scss @@ -0,0 +1,58 @@ +@use '../../../styles/variables' as *; +@use '../../../styles/mixins' as *; + +.order-summary { + background: $white; + box-shadow: 0 24px 58px hsla(229, 31%, 18%, 0.12); + padding: 32px 28px; + display: flex; + flex-direction: column; + gap: 24px; + + h2 { + font-size: 20px; + font-weight: 600; + color: $space-cadet; + } + + &__totals { + display: flex; + flex-direction: column; + gap: 12px; + + > div { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 15px; + color: $rhythm; + } + } + + &__grand { + margin-top: 8px; + padding-top: 12px; + border-top: 1px solid hsla(229, 21%, 23%, 0.1); + + dt { + color: $space-cadet; + font-weight: 600; + } + + dd { + color: $space-cadet; + font-weight: 700; + font-size: 20px; + } + } + + &__note { + font-size: 14px; + line-height: 1.6; + color: $rhythm; + } + + &__cta { + justify-content: center; + } +} diff --git a/src/components/Order/OrderSummary/OrderSummary.tsx b/src/components/Order/OrderSummary/OrderSummary.tsx new file mode 100644 index 0000000..cf22c49 --- /dev/null +++ b/src/components/Order/OrderSummary/OrderSummary.tsx @@ -0,0 +1,55 @@ +'use client'; + +import Link from 'next/link'; +import { memo } from 'react'; + +import './OrderSummary.scss'; + +interface OrderSummaryProps { + itemsCount: number; + subtotal: number; + serviceFee: number; + total: number; + summaryNote: string; +} + +const OrderSummary = ({ + itemsCount, + subtotal, + serviceFee, + total, + summaryNote, +}: OrderSummaryProps) => { + return ( + + ); +}; + +export default memo(OrderSummary); diff --git a/src/components/Order/OrderSummary/index.ts b/src/components/Order/OrderSummary/index.ts new file mode 100644 index 0000000..18088ef --- /dev/null +++ b/src/components/Order/OrderSummary/index.ts @@ -0,0 +1 @@ +export { default } from './OrderSummary'; diff --git a/src/components/index.ts b/src/components/index.ts index 0339b85..506aebf 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -11,3 +11,7 @@ export { default as CheckoutPage } from './Checkout/CheckoutPage'; export { default as ProductCard } from './Menu/ProductCard/ProductCard'; export { default as ProductRating } from './Menu/ProductRating/ProductRating'; + +// Order components +export { default as OrderSummary } from './Order/OrderSummary'; +export { default as OrderCard } from './Order/OrderCard'; diff --git a/src/context/CartContext.tsx b/src/context/CartContext.tsx index 4023dcc..e112ceb 100644 --- a/src/context/CartContext.tsx +++ b/src/context/CartContext.tsx @@ -4,19 +4,22 @@ import { createContext, useCallback, useContext, + useEffect, useMemo, + useRef, useState, } from 'react'; -import { useRef } from 'react'; import type { ReactNode } from 'react'; -import { cartItems as initialCartItems } from '@/constants/layout'; import type { CartItem, Product } from '@/types'; +const CART_STORAGE_KEY = 'foodhub-cart-items'; + type CartContextValue = { items: CartItem[]; cartCount: number; subtotal: number; + isHydrated: boolean; addToCart: (product: Product) => void; incrementItem: (id: string) => void; decrementItem: (id: string) => void; @@ -35,14 +38,57 @@ type CartNotification = { const CartContext = createContext(undefined); +const loadCartFromStorage = (): CartItem[] => { + // Always return empty array during SSR to prevent hydration mismatches + if (typeof window === 'undefined') { + return []; + } + + try { + const stored = localStorage.getItem(CART_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored) as CartItem[]; + return Array.isArray(parsed) ? parsed : []; + } + } catch { + // localStorage errors ignored; fall back to empty cart + } + + return []; +}; + +const saveCartToStorage = (items: CartItem[]) => { + if (typeof window === 'undefined') { + return; + } + + try { + localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(items)); + } catch { + // localStorage errors ignored + } +}; + export const CartProvider = ({ children }: { children: ReactNode }) => { - const [items, setItems] = useState( - initialCartItems.map(item => ({ ...item, quantity: item.quantity ?? 1 })) - ); + const [items, setItems] = useState([]); const [notification, setNotification] = useState( null ); const seqRef = useRef(0); + const [isHydrated, setIsHydrated] = useState(false); + + // Load cart from localStorage after initial mount to prevent hydration mismatch + useEffect(() => { + setItems(loadCartFromStorage()); + setIsHydrated(true); + }, []); + + // Save cart to localStorage whenever items change (after hydration) + useEffect(() => { + if (isHydrated) { + saveCartToStorage(items); + } + }, [items, isHydrated]); const addToCart = useCallback((product: Product) => { setItems(previousItems => { @@ -145,6 +191,7 @@ export const CartProvider = ({ children }: { children: ReactNode }) => { items, cartCount, subtotal, + isHydrated, addToCart, incrementItem, decrementItem, @@ -156,6 +203,7 @@ export const CartProvider = ({ children }: { children: ReactNode }) => { items, cartCount, subtotal, + isHydrated, addToCart, incrementItem, decrementItem, From bfc371557bcedae91fb8c2f023dbeaed3411dc5f Mon Sep 17 00:00:00 2001 From: thinh Date: Fri, 24 Oct 2025 16:43:09 +0700 Subject: [PATCH 2/3] Changed: UI checkout page --- .../Checkout/CheckoutPage/CheckoutPage.scss | 95 ++++- .../Checkout/CheckoutPage/CheckoutPage.tsx | 336 ++++++++++++------ 2 files changed, 321 insertions(+), 110 deletions(-) diff --git a/src/components/Checkout/CheckoutPage/CheckoutPage.scss b/src/components/Checkout/CheckoutPage/CheckoutPage.scss index 847295e..9fceba0 100644 --- a/src/components/Checkout/CheckoutPage/CheckoutPage.scss +++ b/src/components/Checkout/CheckoutPage/CheckoutPage.scss @@ -65,13 +65,42 @@ .checkout-form { background: $white; - border-radius: 28px; padding: 32px 28px; box-shadow: 0 24px 58px hsla(229, 31%, 18%, 0.12); display: flex; flex-direction: column; gap: 28px; + &__notice { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 18px 20px; + border-radius: 20px; + border: 1px solid hsla(88, 50%, 60%, 0.35); + background: hsla(88, 50%, 60%, 0.12); + + svg { + width: 24px; + height: 24px; + color: $pistachio; + flex-shrink: 0; + } + + strong { + display: block; + font-weight: 600; + color: $space-cadet; + } + + p { + margin-top: 4px; + font-size: 13px; + color: $rhythm; + line-height: 1.5; + } + } + fieldset { border: none; padding: 0; @@ -89,6 +118,7 @@ text-transform: uppercase; letter-spacing: 0.08em; font-size: 12px; + padding-bottom: 20px; svg { width: 18px; @@ -98,21 +128,30 @@ } } - label { + &__field { display: flex; flex-direction: column; gap: 8px; - font-size: 14px; - color: $space-cadet; + + label { + font-size: 14px; + font-weight: 500; + color: $space-cadet; + display: flex; + align-items: center; + gap: 5px; + } input, textarea { width: 100%; - border-radius: 16px; border: 1px solid hsla(229, 21%, 23%, 0.12); padding: 14px 16px; font-size: 15px; - transition: border-color 0.25s ease; + transition: + border-color 0.25s ease, + box-shadow 0.25s ease; + background: $white; &:focus-visible { border-color: $saffron; @@ -126,6 +165,30 @@ } } + &__field.is-invalid { + input, + textarea { + border-color: $red-orange-crayola; + box-shadow: 0 0 0 3px hsla(0, 100%, 66%, 0.16); + } + } + + &__error { + font-size: 13px; + color: $red-orange-crayola; + } + + &__meta { + display: flex; + justify-content: flex-end; + font-size: 12px; + color: $rhythm; + } + + &__helper { + font-size: 12px; + } + &__options { display: grid; gap: 16px; @@ -135,13 +198,15 @@ align-items: flex-start; gap: 12px; padding: 16px 18px; - border-radius: 20px; background: $cultured; + border: 1px solid transparent; transition: $default-transition; cursor: pointer; input { margin-top: 4px; + accent-color: $saffron; + width: fit-content; } span { @@ -159,18 +224,32 @@ &:hover { background: hsla(45, 91%, 58%, 0.2); } + + &:focus-within { + outline: 2px solid hsla(45, 91%, 58%, 0.35); + outline-offset: 2px; + } + + &.option--active { + background: hsla(45, 91%, 58%, 0.22); + border-color: hsla(45, 91%, 58%, 0.55); + } } } &__submit { align-self: flex-start; gap: 10px; + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } } } .checkout-summary { background: $white; - border-radius: 28px; padding: 32px 28px; box-shadow: 0 24px 58px hsla(229, 31%, 18%, 0.08); display: flex; diff --git a/src/components/Checkout/CheckoutPage/CheckoutPage.tsx b/src/components/Checkout/CheckoutPage/CheckoutPage.tsx index b51a09a..a786388 100644 --- a/src/components/Checkout/CheckoutPage/CheckoutPage.tsx +++ b/src/components/Checkout/CheckoutPage/CheckoutPage.tsx @@ -1,18 +1,21 @@ 'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; -import type { ChangeEvent, FormEvent } from 'react'; +import { useForm } from 'react-hook-form'; import { IoArrowBack, IoCallOutline, + IoCheckmarkCircleOutline, IoDocumentTextOutline, IoLocationOutline, IoPersonOutline, IoTimeOutline, } from 'react-icons/io5'; +import { z } from 'zod'; import { useCart } from '@/context/CartContext'; import { getImagePath } from '@/lib/getImagePath'; @@ -21,17 +24,48 @@ import './CheckoutPage.scss'; const SERVICE_FEE = 4.5; const TAX_RATE = 0.08; +const PHONE_NUMBER_PATTERN = /^[+()\d\s-]{8,}$/; +const NOTES_CHARACTER_LIMIT = 280; -type CheckoutFormState = { - fullName: string; - email: string; - phone: string; - diningOption: 'dine-in' | 'takeaway'; - arrivalTime: string; - notes: string; -}; +const CHECKOUT_COPY = { + intro: + 'Share your details and timing so we can have everything ready the moment you arrive.', + empty: + 'Add a few dishes to your order before heading to checkout so we can prep everything with care.', + dineIn: 'We will have your table staged before you arrive.', + takeaway: 'We will package everything to travel beautifully.', + followUp: + 'After submitting, a host will confirm your order details by phone or email within a few minutes.', +} as const; + +const checkoutSchema = z.object({ + fullName: z.string().trim().min(2, 'Please share the name for the booking.'), + email: z + .string() + .trim() + .email('Add a valid email so we can send confirmations.'), + phone: z + .string() + .trim() + .min(8, 'Phone number should include your area code.') + .regex( + PHONE_NUMBER_PATTERN, + 'Use digits, spaces, parentheses, plus or hyphen only.' + ), + diningOption: z.enum(['dine-in', 'takeaway']), + arrivalTime: z + .string() + .trim() + .regex(/^[0-2]\d:[0-5]\d$/, 'Choose an arrival time.'), + notes: z + .string() + .trim() + .max(NOTES_CHARACTER_LIMIT, 'Keep notes under 280 characters.'), +}); -const initialFormState: CheckoutFormState = { +type CheckoutFormData = z.infer; + +const defaultValues: CheckoutFormData = { fullName: '', email: '', phone: '', @@ -40,11 +74,52 @@ const initialFormState: CheckoutFormState = { notes: '', }; +const LoadingCheckoutState = () => ( +
+
+

Loading checkout...

+
+
+); + +const EmptyCheckoutState = ({ message }: { message: string }) => ( +
+
+

Your table is waiting

+

{message}

+ + Browse the menu + +
+
+); + +const CheckoutSuccessNotice = () => ( +
+
+); + const CheckoutPage = () => { const { items, subtotal, isHydrated } = useCart(); const router = useRouter(); - const [formState, setFormState] = - useState(initialFormState); + const [showSuccess, setShowSuccess] = useState(false); + + const { + register, + handleSubmit, + reset, + watch, + formState: { errors, isSubmitting, isSubmitSuccessful }, + } = useForm({ + resolver: zodResolver(checkoutSchema), + defaultValues, + mode: 'onBlur', + }); const isEmpty = items.length === 0; @@ -53,57 +128,61 @@ const CheckoutPage = () => { router.push('/menu'); } }, [isHydrated, isEmpty, router]); - const serviceFee = isEmpty ? 0 : SERVICE_FEE; - const tax = isEmpty ? 0 : subtotal * TAX_RATE; - const total = subtotal + serviceFee + tax; - const checkoutIntro = - 'Share your details and timing so we can have everything ready the moment you arrive.'; - const emptyCopy = - 'Add a few dishes to your order before heading to checkout so we can prep everything with care.'; - const dineInCopy = 'We will have your table staged before you arrive.'; - const takeawayCopy = 'We will package everything to travel beautifully.'; - const followUpCopy = - 'After submitting, a host will confirm your order details by phone or email within a few minutes.'; - - const handleChange = ( - event: ChangeEvent - ) => { - const { name, value } = event.target; - setFormState(previous => ({ ...previous, [name]: value })); - }; + useEffect(() => { + if (!isSubmitSuccessful) { + return; + } + + setShowSuccess(true); + const timer = window.setTimeout(() => setShowSuccess(false), 6000); + return () => window.clearTimeout(timer); + }, [isSubmitSuccessful]); - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - // This demo form simply resets after submit to keep the flow playful. - setFormState(initialFormState); + useEffect(() => { + if (isSubmitting) { + setShowSuccess(false); + } + }, [isSubmitting]); + + const onSubmit = async (_data: CheckoutFormData) => { + await new Promise(resolve => setTimeout(resolve, 600)); + reset(defaultValues); }; - // Show loading state during hydration to prevent flash of empty state if (!isHydrated) { - return ( -
-
-

Loading checkout...

-
-
- ); + return ; } if (isEmpty) { - return ( -
-
-

Your table is waiting

-

{emptyCopy}

- - Browse the menu - -
-
- ); + return ; } + const { intro, dineIn, takeaway, followUp } = CHECKOUT_COPY; + const notesValue = watch('notes'); + const diningOption = watch('diningOption'); + const remainingCharacters = NOTES_CHARACTER_LIMIT - (notesValue?.length ?? 0); + const fullNameError = errors.fullName?.message; + const emailError = errors.email?.message; + const phoneError = errors.phone?.message; + const arrivalTimeError = errors.arrivalTime?.message; + const notesError = errors.notes?.message; + + const fieldClassName = (hasError?: unknown) => + hasError ? 'checkout-form__field is-invalid' : 'checkout-form__field'; + + const notesHelperId = 'checkout-notes-helper'; + const notesErrorId = 'checkout-notes-error'; + const notesDescribedBy = notesError + ? `${notesHelperId} ${notesErrorId}` + : notesHelperId; + + const isDineIn = diningOption === 'dine-in'; + const isTakeaway = diningOption === 'takeaway'; + const serviceFee = SERVICE_FEE; + const tax = subtotal * TAX_RATE; + const total = subtotal + serviceFee + tax; + return (
@@ -117,55 +196,77 @@ const CheckoutPage = () => {

Checkout

-

{checkoutIntro}

+

{intro}

-
+ + {showSuccess && } +
-
@@ -174,45 +275,53 @@ const CheckoutPage = () => {
-
-
@@ -220,21 +329,44 @@ const CheckoutPage = () => {