diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx index e3e485d7..5af11710 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx @@ -7,6 +7,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { Suspense, use, useCallback, useEffect, useRef, useState } from "react"; import { AddressSection } from "@/components/checkout/AddressSection"; +import { CheckoutPageSkeleton } from "@/components/checkout/CheckoutPageSkeleton"; import { CouponCode } from "@/components/checkout/CouponCode"; import { DeliveryMethodSection } from "@/components/checkout/DeliveryMethodSection"; import { @@ -543,19 +544,8 @@ function CheckoutPageContent({ params }: CheckoutPageProps) { // PaymentSection handles setProcessing(false) on error internally }; - // Loading state if (loading || authLoading) { - return ( -
-
-
-
-
-
-
-
-
- ); + return ; } // Error state (no cart loaded) @@ -613,7 +603,7 @@ function CheckoutPageContent({ params }: CheckoutPageProps) { countries={countries} savedAddresses={savedAddresses} isAuthenticated={isAuthenticated} - signInUrl={`${basePath}/account?redirect=${encodeURIComponent(pathname)}`} + signInUrl={`${basePath}/account/login?redirect=${encodeURIComponent(pathname)}`} fetchStates={fetchStates} onEmailBlur={handleEmailBlur} onAutoSave={handleAutoSave} diff --git a/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx index 1559cd22..f6a9410c 100644 --- a/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx @@ -7,6 +7,7 @@ import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; import { use, useEffect, useRef, useState } from "react"; import { AddressBlock } from "@/components/order/AddressBlock"; +import { OrderPlacedSkeleton } from "@/components/order/OrderPlacedSkeleton"; import { OrderTotals } from "@/components/order/OrderTotals"; import { PaymentInfo } from "@/components/order/PaymentInfo"; import { Button } from "@/components/ui/button"; @@ -90,14 +91,7 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) { }, [cartId]); if (loading) { - return ( -
-
-
-
-
-
- ); + return ; } if (error || !order) { diff --git a/src/app/[country]/[locale]/(storefront)/account/addresses/loading.tsx b/src/app/[country]/[locale]/(storefront)/account/addresses/loading.tsx new file mode 100644 index 00000000..b2f41627 --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/account/addresses/loading.tsx @@ -0,0 +1,5 @@ +import { AddressesSkeleton } from "@/components/account/AddressesSkeleton"; + +export default function AddressesLoading() { + return ; +} diff --git a/src/app/[country]/[locale]/(storefront)/account/credit-cards/loading.tsx b/src/app/[country]/[locale]/(storefront)/account/credit-cards/loading.tsx new file mode 100644 index 00000000..f5a616b2 --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/account/credit-cards/loading.tsx @@ -0,0 +1,5 @@ +import { CreditCardsSkeleton } from "@/components/account/CreditCardsSkeleton"; + +export default function CreditCardsLoading() { + return ; +} diff --git a/src/app/[country]/[locale]/(storefront)/account/forgot-password/page.tsx b/src/app/[country]/[locale]/(storefront)/account/forgot-password/page.tsx index e34bc043..2bed0aec 100644 --- a/src/app/[country]/[locale]/(storefront)/account/forgot-password/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/forgot-password/page.tsx @@ -88,7 +88,7 @@ export default function ForgotPasswordPage() { {t("backToSignIn")} @@ -145,7 +145,7 @@ export default function ForgotPasswordPage() { {t("backToSignIn")} diff --git a/src/app/[country]/[locale]/(storefront)/account/gift-cards/loading.tsx b/src/app/[country]/[locale]/(storefront)/account/gift-cards/loading.tsx new file mode 100644 index 00000000..cd95d161 --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/account/gift-cards/loading.tsx @@ -0,0 +1,5 @@ +import { GiftCardsSkeleton } from "@/components/account/GiftCardsSkeleton"; + +export default function GiftCardsLoading(): React.JSX.Element { + return ; +} diff --git a/src/app/[country]/[locale]/(storefront)/account/layout.tsx b/src/app/[country]/[locale]/(storefront)/account/layout.tsx index 407c631b..26d0de34 100644 --- a/src/app/[country]/[locale]/(storefront)/account/layout.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/layout.tsx @@ -14,6 +14,19 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect } from "react"; +import { AccountDashboardSkeleton } from "@/components/account/AccountDashboardSkeleton"; +import { AddressesSkeleton } from "@/components/account/AddressesSkeleton"; +import { AuthFallbackSkeleton } from "@/components/account/AuthFallbackSkeleton"; +import { ContentSkeleton } from "@/components/account/ContentSkeleton"; +import { CreditCardsSkeleton } from "@/components/account/CreditCardsSkeleton"; +import { ForgotPasswordFormSkeleton } from "@/components/account/ForgotPasswordFormSkeleton"; +import { GiftCardsSkeleton } from "@/components/account/GiftCardsSkeleton"; +import { LoginFormSkeleton } from "@/components/account/LoginFormSkeleton"; +import { OrdersListSkeleton } from "@/components/account/OrdersListSkeleton"; +import { ProfileSkeleton } from "@/components/account/ProfileSkeleton"; +import { RegisterFormSkeleton } from "@/components/account/RegisterFormSkeleton"; +import { ResetPasswordFormSkeleton } from "@/components/account/ResetPasswordFormSkeleton"; +import { SidebarUserInfoSkeleton } from "@/components/account/SidebarUserInfoSkeleton"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/contexts/AuthContext"; import { extractBasePath } from "@/lib/utils/path"; @@ -37,17 +50,6 @@ function getNavItems(t: ReturnType>): { ]; } -function ContentSkeleton() { - return ( -
-
-
-
-
-
- ); -} - interface AccountShellProps { children: React.ReactNode; basePath: string; @@ -80,10 +82,7 @@ function AccountShell({ {/* User Info */}
{isLoading ? ( -
-
-
-
+ ) : ( <>

@@ -155,49 +154,63 @@ export default function AccountLayout({ // Pages that don't require authentication const authPagePaths = new Set([ + `${basePath}/account/login`, `${basePath}/account/register`, `${basePath}/account/forgot-password`, `${basePath}/account/reset-password`, ]); const isAuthPage = authPagePaths.has(pathname); - const isMainAccountPage = pathname === `${basePath}/account`; - // Redirect to login if not authenticated and trying to access protected sub-pages + // Redirect to login if not authenticated and trying to access protected pages. + // Preserve the originally requested path via `?redirect=` so LoginPage can + // send the user back after they sign in. useEffect(() => { - if (!loading && !isAuthenticated && !isAuthPage && !isMainAccountPage) { - router.replace(`${basePath}/account`); + if (!loading && !isAuthenticated && !isAuthPage) { + const redirect = encodeURIComponent(pathname); + router.replace(`${basePath}/account/login?redirect=${redirect}`); } - }, [ - loading, - isAuthenticated, - isAuthPage, - isMainAccountPage, - basePath, - router, - ]); + }, [loading, isAuthenticated, isAuthPage, basePath, pathname, router]); // Show loading or redirect-in-progress skeleton - if (loading || (!isAuthenticated && !isAuthPage && !isMainAccountPage)) { - if (isAuthPage || isMainAccountPage) { - return ( -

-
-
-
-
-
-
- ); + if (loading || (!isAuthenticated && !isAuthPage)) { + if (pathname === `${basePath}/account/login`) { + return ; + } + if (pathname === `${basePath}/account/register`) { + return ; + } + if (pathname === `${basePath}/account/forgot-password`) { + return ; + } + if (pathname === `${basePath}/account/reset-password`) { + return ; + } + if (isAuthPage) { + // generic fallback for any future auth page + return ; } + const isDashboardPage = pathname === `${basePath}/account`; + const isProfilePage = pathname === `${basePath}/account/profile`; + const isOrdersPage = pathname === `${basePath}/account/orders`; + const isGiftCardsPage = pathname === `${basePath}/account/gift-cards`; + const isCreditCardsPage = pathname === `${basePath}/account/credit-cards`; + const isAddressesPage = pathname === `${basePath}/account/addresses`; + let content: React.ReactNode = ; + if (isDashboardPage) content = ; + else if (isProfilePage) content = ; + else if (isOrdersPage) content = ; + else if (isGiftCardsPage) content = ; + else if (isCreditCardsPage) content = ; + else if (isAddressesPage) content = ; return ( - + {content} ); } - // Don't show nav for login/register pages - if (isAuthPage || !isAuthenticated) { + // Don't show nav for login/register/forgot/reset pages + if (isAuthPage) { return <>{children}; } diff --git a/src/app/[country]/[locale]/(storefront)/account/login/page.tsx b/src/app/[country]/[locale]/(storefront)/account/login/page.tsx new file mode 100644 index 00000000..7b9d9e3e --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/account/login/page.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { CircleAlert, Eye, EyeOff } from "lucide-react"; +import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { useAuth } from "@/contexts/AuthContext"; +import { extractBasePath } from "@/lib/utils/path"; +import { safeRedirect } from "@/lib/utils/redirect"; + +export default function LoginPage() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const basePath = extractBasePath(pathname); + const t = useTranslations("account"); + const { login, isAuthenticated, loading: authLoading } = useAuth(); + + // Get redirect URL from query params (e.g., from checkout) + const redirectUrl = searchParams.get("redirect"); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const destination = safeRedirect(redirectUrl, `${basePath}/account`); + + // Redirect if already authenticated + // useEffect is needed here to prevent rendering issues. + useEffect(() => { + if (!authLoading && isAuthenticated) { + router.push(destination); + } + }, [authLoading, isAuthenticated, destination, router]); + if (authLoading || isAuthenticated) { + return null; + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + const result = await login(email, password); + if (result.success) { + router.push(destination); + } else { + setError(result.error || t("invalidCredentials")); + } + } catch { + setError(t("invalidCredentials")); + } finally { + setLoading(false); + } + }; + + return ( +
+ + + {t("myAccount")} + {t("signInDescription")} + + + +
+ {error && ( + + + {error} + + )} + + + {t("email")} + setEmail(e.target.value)} + required + placeholder="you@example.com" + /> + + + + {t("password")} +
+ setPassword(e.target.value)} + required + placeholder="••••••••" + className="pr-10" + /> +
+ +
+
+
+ +
+ + {t("forgotPassword")} + +
+ +
+ +
+
+
+ + +

+ {t("dontHaveAccount")}{" "} + + {t("signUp")} + +

+
+
+
+ ); +} diff --git a/src/app/[country]/[locale]/(storefront)/account/orders/loading.tsx b/src/app/[country]/[locale]/(storefront)/account/orders/loading.tsx new file mode 100644 index 00000000..f370edaf --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/account/orders/loading.tsx @@ -0,0 +1,5 @@ +import { OrdersListSkeleton } from "@/components/account/OrdersListSkeleton"; + +export default function OrdersLoading() { + return ; +} diff --git a/src/app/[country]/[locale]/(storefront)/account/page.tsx b/src/app/[country]/[locale]/(storefront)/account/page.tsx index 539605f6..09d40de3 100644 --- a/src/app/[country]/[locale]/(storefront)/account/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/page.tsx @@ -1,186 +1,32 @@ "use client"; -import { - CircleAlert, - CreditCard, - Eye, - EyeOff, - MapPin, - ShoppingBag, - User, -} from "lucide-react"; +import { CreditCard, MapPin, ShoppingBag, User } from "lucide-react"; import Link from "next/link"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useState } from "react"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Field, FieldLabel } from "@/components/ui/field"; -import { Input } from "@/components/ui/input"; +import { useEffect } from "react"; +import { Card, CardContent } from "@/components/ui/card"; import { useAuth } from "@/contexts/AuthContext"; import { extractBasePath } from "@/lib/utils/path"; export default function AccountPage() { const router = useRouter(); const pathname = usePathname(); - const searchParams = useSearchParams(); const basePath = extractBasePath(pathname); const t = useTranslations("account"); - const { login, isAuthenticated, loading: authLoading } = useAuth(); + const { isAuthenticated, loading: authLoading } = useAuth(); - // Get redirect URL from query params (e.g., from checkout) - const redirectUrl = searchParams.get("redirect"); - - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [showPassword, setShowPassword] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(null); - setLoading(true); - - const result = await login(email, password); - if (result.success) { - // Redirect to the specified URL or stay on account page - if (redirectUrl) { - router.push(redirectUrl); - } - } else { - setError(result.error || t("invalidCredentials")); + // Redirect unauthenticated users to the login page + // useEffect is needed here to prevent rendering issues. + useEffect(() => { + if (!authLoading && !isAuthenticated) { + router.replace(`${basePath}/account/login`); } - setLoading(false); - }; - - // Show loading state while auth is initializing - if (authLoading) { - return ( -
-
-
-
-
-
-
- ); - } - - // Show login form if not authenticated - if (!isAuthenticated) { - return ( -
- - - {t("myAccount")} - {t("signInDescription")} - - - -
- {error && ( - - - {error} - - )} - - - {t("email")} - setEmail(e.target.value)} - required - placeholder="you@example.com" - /> - - - - {t("password")} -
- setPassword(e.target.value)} - required - placeholder="••••••••" - className="pr-10" - /> -
- -
-
-
- -
- - {t("forgotPassword")} - -
- -
- -
-
-
- - -

- {t("dontHaveAccount")}{" "} - - {t("signUp")} - -

-
-
-
- ); + }, [authLoading, isAuthenticated, router, basePath]); + if (authLoading || !isAuthenticated) { + return null; } - // Show account dashboard if authenticated return (

diff --git a/src/app/[country]/[locale]/(storefront)/account/register/page.tsx b/src/app/[country]/[locale]/(storefront)/account/register/page.tsx index 79fae3f3..1a1ab322 100644 --- a/src/app/[country]/[locale]/(storefront)/account/register/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/register/page.tsx @@ -252,7 +252,7 @@ export default function RegisterPage() {

{t("alreadyHaveAccount")}{" "} {t("signIn")} diff --git a/src/app/[country]/[locale]/(storefront)/account/reset-password/page.tsx b/src/app/[country]/[locale]/(storefront)/account/reset-password/page.tsx index fbc558d8..f17ca698 100644 --- a/src/app/[country]/[locale]/(storefront)/account/reset-password/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/reset-password/page.tsx @@ -107,7 +107,7 @@ export default function ResetPasswordPage() { @@ -223,7 +223,7 @@ export default function ResetPasswordPage() { {t("backToSignIn")} diff --git a/src/app/[country]/[locale]/(storefront)/cart/page.tsx b/src/app/[country]/[locale]/(storefront)/cart/page.tsx index 9ef8e6da..b02bb024 100644 --- a/src/app/[country]/[locale]/(storefront)/cart/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/cart/page.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useRef } from "react"; +import { CartSkeleton } from "@/components/cart/CartSkeleton"; import { Button } from "@/components/ui/button"; import { ProductImage } from "@/components/ui/product-image"; import { QuantityPicker } from "@/components/ui/quantity-picker"; @@ -42,18 +43,7 @@ export default function CartPage() { }; if (loading) { - return ( -

-
-
-
- {[1, 2, 3].map((i) => ( -
- ))} -
-
-
- ); + return ; } if (!cart || !cart.items || cart.items.length === 0) { diff --git a/src/components/account/AccountDashboardSkeleton.tsx b/src/components/account/AccountDashboardSkeleton.tsx new file mode 100644 index 00000000..74863ef5 --- /dev/null +++ b/src/components/account/AccountDashboardSkeleton.tsx @@ -0,0 +1,34 @@ +import { Card, CardContent } from "@/components/ui/card"; + +/** + * Skeleton mirroring `account/page.tsx` (dashboard overview): + * - h1 `text-2xl` (32px line-height), `mb-6` + * - 2×2 grid of Cards (`grid-cols-1 md:grid-cols-2 gap-6`), each Card wraps + * CardContent (py-0) with an icon box (`p-3 rounded-xl`, size-6 icon = 48px) + * and a text column (h2 text-lg + mt-1 description text-sm) + */ +export function AccountDashboardSkeleton() { + return ( +
+ {/* h1 Account overview */} +
+ +
+ {[0, 1, 2, 3].map((i) => ( + + + {/* Icon box: p-3 (12px) + size-6 icon (24px) = 48px square */} +
+
+ {/* h2 text-lg = 28px line-height */} +
+ {/* description text-sm, mt-1 */} +
+
+ + + ))} +
+
+ ); +} diff --git a/src/components/account/AddressesSkeleton.tsx b/src/components/account/AddressesSkeleton.tsx new file mode 100644 index 00000000..4bd16a46 --- /dev/null +++ b/src/components/account/AddressesSkeleton.tsx @@ -0,0 +1,54 @@ +/** + * Skeleton mirroring `account/addresses/page.tsx` + `AddressManagement.tsx`: + * - Title bar: h1 `text-2xl` (32px) + mb-6 + * - Add button row: mb-6, default Button h-11 + * - Grid `grid-cols-1 md:grid-cols-2 gap-4` of address cards + * - Each card: `rounded-xl border p-6` with `flex justify-between items-start` + * → address block (text-sm space-y-0.5, 4 lines) + Edit/Delete buttons + */ +function AddressCardSkeleton() { + return ( +
+
+ {/* Address lines: text-sm (20px) space-y-0.5 (2px) */} +
+ {/* full_name font-medium */} +
+ {/* address1 */} +
+ {/* city, state zip */} +
+ {/* country */} +
+
+ {/* Edit (link sm) + Delete (destructive sm), gap-2 */} +
+
+
+
+
+
+ ); +} + +export function AddressesSkeleton() { + return ( +
+ {/* Title row */} +
+
+
+ + {/* Add address button (default size h-11) */} +
+
+
+ + {/* Grid of address cards — 2 items */} +
+ + +
+
+ ); +} diff --git a/src/components/account/AuthFallbackSkeleton.tsx b/src/components/account/AuthFallbackSkeleton.tsx new file mode 100644 index 00000000..4c66aae7 --- /dev/null +++ b/src/components/account/AuthFallbackSkeleton.tsx @@ -0,0 +1,16 @@ +/** + * Generic fallback skeleton for auth pages without a dedicated skeleton yet + * (forgot-password, reset-password). Kept minimal and centered to match the + * `max-w-md mx-auto py-16` container used by those pages. + */ +export function AuthFallbackSkeleton(): React.JSX.Element { + return ( +
+
+
+
+
+
+
+ ); +} diff --git a/src/components/account/ContentSkeleton.tsx b/src/components/account/ContentSkeleton.tsx new file mode 100644 index 00000000..5b21f1bd --- /dev/null +++ b/src/components/account/ContentSkeleton.tsx @@ -0,0 +1,14 @@ +/** + * Generic fallback skeleton for protected account sub-pages that don't yet have + * a dedicated skeleton (mirrors a simple header + two content blocks layout). + */ +export function ContentSkeleton(): React.JSX.Element { + return ( +
+
+
+
+
+
+ ); +} diff --git a/src/components/account/CreditCardsSkeleton.tsx b/src/components/account/CreditCardsSkeleton.tsx new file mode 100644 index 00000000..273631a2 --- /dev/null +++ b/src/components/account/CreditCardsSkeleton.tsx @@ -0,0 +1,47 @@ +/** + * Skeleton mirroring `account/credit-cards/page.tsx` + `CreditCardList.tsx`: + * - h1 `text-2xl font-bold` (32px) + `mb-6` + * - Card list: `space-y-4` of `rounded-xl border p-6` items with + * [PaymentIcon(48x32) + label/expiry] | [Remove button h-9] + * - Help text footer: `mt-6 p-4 bg-gray-50 rounded-xl` with text-sm + */ +function CreditCardItemSkeleton() { + return ( +
+
+
+ {/* PaymentIcon width=48, flatRounded aspect ≈ 48×30 */} +
+
+ {/* "Visa ending in 4242" text-sm */} +
+ {/* expires text-xs = 16px */} +
+
+
+ {/* Remove button size="sm" = h-9 */} +
+
+
+ ); +} + +export function CreditCardsSkeleton() { + return ( +
+ {/* h1 Payment methods */} +
+ + {/* Card list space-y-4 — 2 items */} +
+ + +
+ + {/* Help text footer */} +
+
+
+
+ ); +} diff --git a/src/components/account/ForgotPasswordFormSkeleton.tsx b/src/components/account/ForgotPasswordFormSkeleton.tsx new file mode 100644 index 00000000..2d48aa1e --- /dev/null +++ b/src/components/account/ForgotPasswordFormSkeleton.tsx @@ -0,0 +1,41 @@ +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from "@/components/ui/card"; + +/** + * Skeleton mirroring `account/forgot-password/page.tsx` (request form state): + * Card (py-6 gap-6) → Header (title text-2xl `leading-none` = 24px + description + * text-sm = 20px) → Content (space-y-4: email field + submit h-13) → Footer + * (centered text-sm "back to sign in" link). + */ +export function ForgotPasswordFormSkeleton() { + return ( +
+ + +
+
+
+ + +
+ {/* Email field: label text-sm (h-5) + Input h-11 */} +
+
+
+
+ {/* Submit Button size="lg" h-13 rounded-lg */} +
+
+ + + {/* "Back to sign in" link text-sm */} +
+ + +
+ ); +} diff --git a/src/components/account/GiftCardsSkeleton.tsx b/src/components/account/GiftCardsSkeleton.tsx new file mode 100644 index 00000000..7e815b3f --- /dev/null +++ b/src/components/account/GiftCardsSkeleton.tsx @@ -0,0 +1,71 @@ +/** + * Skeleton mirroring `account/gift-cards/page.tsx` + `GiftCardList.tsx`: + * - h1 `text-2xl font-bold` (32px) + `mb-6` + * - Active cards section: h2 text-lg (28px) + `mb-4`, then `space-y-4` list of GiftCardItems + * - Each item: `rounded-xl border p-6` with header row (code + copy + status badge | amount right) + * and progress bar section + * - Help text footer: `mt-6 p-4 bg-gray-50 rounded-xl` with text-sm + */ +function GiftCardItemSkeleton() { + return ( +
+ {/* Header row */} +
+
+
+ {/* code font-mono text-lg = 28px */} +
+ {/* Copy button size="sm" = h-9 */} +
+ {/* status badge: px-2.5 py-0.5 rounded-lg text-xs ≈ 20px */} +
+
+ {/* expires p text-sm mt-1 */} +
+
+
+ {/* amount_remaining text-2xl = 32px */} +
+ {/* "remaining" label text-sm */} +
+
+
+ + {/* Progress bar section mb-4 */} +
+
+
+
+
+ {/* bar h-2 rounded-lg */} +
+ {/* percent used text-xs = 16px */} +
+
+
+ ); +} + +export function GiftCardsSkeleton() { + return ( +
+ {/* h1 Gift cards */} +
+ + {/* Active cards section */} +
+ {/* h2 text-lg font-semibold + mb-4 */} +
+
+ + +
+
+ + {/* Help text footer: mt-6 p-4 bg-gray-50 rounded-xl */} +
+
+
+
+ ); +} diff --git a/src/components/account/LoginFormSkeleton.tsx b/src/components/account/LoginFormSkeleton.tsx new file mode 100644 index 00000000..f95dc268 --- /dev/null +++ b/src/components/account/LoginFormSkeleton.tsx @@ -0,0 +1,44 @@ +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from "@/components/ui/card"; + +/** + * Skeleton mirroring the login form in `account/login/page.tsx`: + * Card (py-6 gap-6) → Header (title text-2xl + description text-sm) → + * Content (space-y-4: email field, password field, forgot link, submit h-13) → + * Footer (centered text-sm). + */ +export function LoginFormSkeleton(): React.JSX.Element { + return ( +
+ + +
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ + +
+ ); +} diff --git a/src/components/account/OrdersListSkeleton.tsx b/src/components/account/OrdersListSkeleton.tsx new file mode 100644 index 00000000..b51e91dd --- /dev/null +++ b/src/components/account/OrdersListSkeleton.tsx @@ -0,0 +1,61 @@ +/** + * Skeleton mirroring `account/orders/page.tsx` + `OrderList.tsx`: + * - h1 `text-2xl font-bold` (32px line-height) + `mb-6` + * - Table card (`rounded-xl border`): thead bg-gray-50 with 6 cols (text-xs uppercase, py-3), + * tbody with 3 rows (px-6 py-4, text-sm or rounded-lg badges). + */ +export function OrdersListSkeleton() { + return ( +
+ {/* h1 Order history */} +
+ +
+
+ + + + {/* 6 columns: Order / Date / Payment / Shipment / Total / Actions */} + {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} + + + + {[0, 1, 2].map((i) => ( + + {/* #number (text-sm) */} + + {/* date */} + + {/* payment badge: px-2.5 py-0.5 rounded-lg text-xs ≈ 20px */} + + {/* shipment badge */} + + {/* total (right) */} + + {/* view link (right) */} + + + ))} + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/components/account/ProfileSkeleton.tsx b/src/components/account/ProfileSkeleton.tsx new file mode 100644 index 00000000..09cbef70 --- /dev/null +++ b/src/components/account/ProfileSkeleton.tsx @@ -0,0 +1,60 @@ +/** + * Skeleton mirroring `account/profile/page.tsx`: + * - h1 (text-2xl = 32px line-height, mb-6) + * - Form card: p-6 space-y-6 (first/last grid + email field), footer with save button + * - Account info card: header (text-lg = 28px) + 2-col grid of label/value pairs + */ +export function ProfileSkeleton(): React.JSX.Element { + return ( +
+ {/* h1 Profile */} +
+ + {/* Form card */} +
+
+ {/* first / last name grid */} +
+
+
+
+
+
+
+
+
+
+ {/* email */} +
+
+
+
+
+ {/* Save button footer */} +
+
+
+
+ + {/* Account info card */} +
+
+ {/* h2 Account Information (text-lg = 28px) */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/components/account/RegisterFormSkeleton.tsx b/src/components/account/RegisterFormSkeleton.tsx new file mode 100644 index 00000000..dee1ea09 --- /dev/null +++ b/src/components/account/RegisterFormSkeleton.tsx @@ -0,0 +1,62 @@ +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from "@/components/ui/card"; + +/** + * Skeleton mirroring the register form in `account/register/page.tsx`: + * Card → Header → Content (space-y-4: first/last name grid, email, password, + * password confirmation, policy consent, submit) → Footer. + */ +export function RegisterFormSkeleton() { + return ( +
+ + +
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Policy consent: checkbox (size-4.5) + wrapped text (text-sm 2 lines = 40px) */} +
+
+
+
+
+
+
+
+
+ + +
+ + +
+ ); +} diff --git a/src/components/account/ResetPasswordFormSkeleton.tsx b/src/components/account/ResetPasswordFormSkeleton.tsx new file mode 100644 index 00000000..94d24e36 --- /dev/null +++ b/src/components/account/ResetPasswordFormSkeleton.tsx @@ -0,0 +1,44 @@ +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from "@/components/ui/card"; + +/** + * Skeleton mirroring `account/reset-password/page.tsx` (reset form state): + * Card → Header (title text-2xl `leading-none` = 24px + description text-sm = 20px) + * → Content (space-y-4: new password field, confirm password field, submit h-13) + * → Footer (centered "back to sign in" link text-sm). + */ +export function ResetPasswordFormSkeleton() { + return ( +
+ + +
+
+ + +
+ {/* New password: label + Input h-11 */} +
+
+
+
+ {/* Confirm password: label + Input h-11 */} +
+
+
+
+ {/* Submit Button size="lg" h-13 rounded-lg */} +
+
+ + +
+ + +
+ ); +} diff --git a/src/components/account/SidebarUserInfoSkeleton.tsx b/src/components/account/SidebarUserInfoSkeleton.tsx new file mode 100644 index 00000000..e68606f7 --- /dev/null +++ b/src/components/account/SidebarUserInfoSkeleton.tsx @@ -0,0 +1,16 @@ +/** + * Skeleton for the sidebar user-info block inside `AccountShell`. + * Matches adjacent

line-heights (text-base = 24px, text-sm = 20px, no gap). + */ +export function SidebarUserInfoSkeleton() { + return ( +

+
+
+
+
+
+
+
+ ); +} diff --git a/src/components/cart/CartDrawer.tsx b/src/components/cart/CartDrawer.tsx index 525399bc..56ad01d9 100644 --- a/src/components/cart/CartDrawer.tsx +++ b/src/components/cart/CartDrawer.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useRef } from "react"; +import { CartDrawerSkeleton } from "@/components/cart/CartDrawerSkeleton"; import { Button } from "@/components/ui/button"; import { ProductImage } from "@/components/ui/product-image"; import { QuantityPicker } from "@/components/ui/quantity-picker"; @@ -98,17 +99,7 @@ export function CartDrawer() {
{loading ? ( -
- {[1, 2].map((i) => ( -
-
-
-
-
-
-
- ))} -
+ ) : isEmpty ? (
+ {[0, 1].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/src/components/cart/CartSkeleton.tsx b/src/components/cart/CartSkeleton.tsx new file mode 100644 index 00000000..86a0c5d7 --- /dev/null +++ b/src/components/cart/CartSkeleton.tsx @@ -0,0 +1,78 @@ +/** + * Skeleton mirroring `(storefront)/cart/page.tsx` (2-item scenario): + * - h1 `text-3xl font-bold` (36px line-height), `mb-8` + * - Grid `lg:grid-cols-3 gap-8`: items card (col-span-2) + summary sidebar (col-span-1) + * - Items: `rounded-xl border divide-y`, each row `p-6 flex gap-6` with + * image (w-24 h-24), details (title text-lg + options text-sm + price text-lg), + * and actions (QuantityPicker h-9 + Remove button sm h-9) + * - Summary: `sticky top-24 p-6 rounded-xl border` with header, subtotal/total rows, + * and CTA buttons (Button size="lg" h-13 + Button variant="link" h-11) + */ +export function CartSkeleton() { + return ( +
+ {/* h1 "Shopping cart" — text-3xl line-height 36px, mb-8 */} +
+ +
+ {/* Items list (2 rows) */} +
+
+ {[0, 1].map((i) => ( +
+ {/* Image w-24 h-24 rounded-xl */} +
+ + {/* Details */} +
+ {/* title text-lg = 28px */} +
+ {/* options text-sm, mt-1 */} +
+ {/* price text-lg, mt-2 */} +
+
+ + {/* Quantity + Remove */} +
+ {/* QuantityPicker ~h-9 */} +
+ {/* Remove Button size="sm" h-9 */} +
+
+
+ ))} +
+
+ + {/* Summary sidebar */} +
+
+ {/* h2 text-lg = 28px */} +
+ +
+ {/* Subtotal row — text-base line-height 24 */} +
+
+
+
+ {/* Total row — border-t pt-4, text-lg = 28 */} +
+
+
+
+
+ +
+ {/* Button size="lg" h-13 rounded-lg */} +
+ {/* Button variant="link" size default h-11 */} +
+
+
+
+
+
+ ); +} diff --git a/src/components/checkout/CheckoutPageSkeleton.tsx b/src/components/checkout/CheckoutPageSkeleton.tsx new file mode 100644 index 00000000..12e88849 --- /dev/null +++ b/src/components/checkout/CheckoutPageSkeleton.tsx @@ -0,0 +1,73 @@ +/** + * Skeleton mirroring `checkout/[id]/page.tsx` main content: + * - Contact (h2 text-lg + mb-3 + email input, mb-6) + * - Shipping address (h2 + 7 rows: country, first/last grid, address, apartment, + * city/state/zip grid, phone — all h-11 with `gap-3`) + * - Shipping method (mt-6, h2 + gray placeholder box) + * - Payment (mt-6, h2 + subline + note + bordered container with header + form area) + * - Pay now button (mt-8, h-[54px] rounded-sm) + * + * Headings use `text-lg font-bold` (28px = h-7), inputs `h-11`, button `h-[54px]`. + * Company and policy consent intentionally omitted (optional / guest-only — avoids + * layout shift across authed vs guest flows). + */ +export function CheckoutPageSkeleton() { + return ( +
+ {/* Contact */} +
+
+
+
+ + {/* Shipping address */} +
+
+
+ {/* Country */} +
+ {/* First / Last */} +
+
+
+
+ {/* Address */} +
+ {/* Apartment */} +
+ {/* City / State / Zip */} +
+
+
+
+
+ {/* Phone */} +
+
+
+ + {/* Shipping method */} +
+
+ {/* gray placeholder box (px-4 py-3.5 + text-sm line 20 = ~48px) */} +
+
+ + {/* Payment method */} +
+
+ {/* "Secure transactions" subline (text-sm, mt-0.5) */} +
+ {/* "Test card note" (text-xs, mb-3) */} +
+
+
+
+
+
+ + {/* Pay now button (h-[54px]) */} +
+
+ ); +} diff --git a/src/components/layout/AccountLink.tsx b/src/components/layout/AccountLink.tsx new file mode 100644 index 00000000..9ea4f0e5 --- /dev/null +++ b/src/components/layout/AccountLink.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Link from "next/link"; +import type { ComponentProps } from "react"; +import { useAuth } from "@/contexts/AuthContext"; + +type AccountLinkProps = Omit, "href"> & { + basePath: string; +}; + +/** + * Account link that routes directly to the correct page based on auth state: + * - authenticated → `/account` (dashboard) + * - unauthenticated → `/account/login` (skip the layout's redirect hop) + * - still loading → `/account` (safe default; layout handles the redirect) + * + * Accepts all props a `next/link` `` accepts (except `href`) and forwards + * them onto the underlying Link so it composes correctly with Radix Slot + * consumers like `

  • - {t("myAccount")} - +
diff --git a/src/components/layout/MobileMenu.tsx b/src/components/layout/MobileMenu.tsx index 877cf61a..4251e14b 100644 --- a/src/components/layout/MobileMenu.tsx +++ b/src/components/layout/MobileMenu.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { useTranslations } from "next-intl"; import { useRef, useState } from "react"; import { flushSync } from "react-dom"; +import { AccountLink } from "@/components/layout/AccountLink"; import { Button } from "@/components/ui/button"; import { Sheet, @@ -279,13 +280,13 @@ export function MobileMenu({ rootCategories, basePath }: MobileMenuProps) { - {t("myAccount")} - +
diff --git a/src/components/order/OrderPlacedSkeleton.tsx b/src/components/order/OrderPlacedSkeleton.tsx new file mode 100644 index 00000000..ea9eea9d --- /dev/null +++ b/src/components/order/OrderPlacedSkeleton.tsx @@ -0,0 +1,112 @@ +/** + * Skeleton mirroring `order-placed/[id]/page.tsx` (2 items scenario): + * - Success header (text-center mb-10): check icon (w-16 h-16) + thanks h1 (text-2xl) + * + order number (text-base) + email confirmation note (text-sm mt-2) + * - Order items card (rounded-xl border mb-6): header (text-lg) + list (divide-y) + * + totals footer (border-t) + * - Shipping & payment card: 2-col grid with icon/text pairs + * - Contact & addresses card: 2-col grid + email footer + * - Continue shopping Button (size="lg" h-13) + */ +export function OrderPlacedSkeleton(): React.JSX.Element { + return ( +
+ {/* Success header */} +
+
+
+
+
+
+ + {/* Order Items card — 2 items */} +
+
+
+
+
    + {[0, 1].map((i) => ( +
  • +
    +
    +
    +
    +
    +
    +
  • + ))} +
+ {/* Totals footer */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Shipping & Payment card */} +
+
+ {/* Shipping method */} +
+
+
+
+
+
+
+
+
+
+ {/* Payment */} +
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Contact & Addresses card */} +
+
+ {[0, 1].map((i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+
+
+
+
+ + {/* Continue shopping button */} +
+
+
+
+ ); +} diff --git a/src/components/products/ProductCardSkeleton.tsx b/src/components/products/ProductCardSkeleton.tsx index 7302c27d..56200927 100644 --- a/src/components/products/ProductCardSkeleton.tsx +++ b/src/components/products/ProductCardSkeleton.tsx @@ -10,8 +10,8 @@ export function ProductCardSkeleton(): React.JSX.Element {
-
-
+
+
); diff --git a/src/lib/utils/redirect.ts b/src/lib/utils/redirect.ts new file mode 100644 index 00000000..2aef39ec --- /dev/null +++ b/src/lib/utils/redirect.ts @@ -0,0 +1,24 @@ +/** + * Return `target` only if it is a safe in-app path; otherwise return `fallback`. + * + * Guards against open-redirect attacks when a redirect target comes from user + * input (e.g. `?redirect=...` on the login page). Accepted inputs are paths + * that start with a single `/`. Rejected: + * - `null` / `undefined` / empty string + * - Absolute URLs (`http://`, `https://`, `javascript:`, `data:`, etc.) + * - Protocol-relative URLs (`//evil.com/...`, `/\evil.com`) + * + * The function does not attempt to constrain the target to a specific + * `basePath`; locale/country prefixes are already part of in-app paths and + * should be allowed. + */ +export function safeRedirect( + target: string | null | undefined, + fallback: string, +): string { + if (!target || typeof target !== "string") return fallback; + if (!target.startsWith("/")) return fallback; + // Protocol-relative or backslash-prefixed — browsers may resolve these as external + if (target.startsWith("//") || target.startsWith("/\\")) return fallback; + return target; +}