From 8803fbfe7b952c130bcfbc9e63abc94de425093b Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Mon, 13 Apr 2026 13:28:14 +0200 Subject: [PATCH 1/3] Update skeleton loaders across storefront and checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace generic pulse placeholders with structure-matching skeletons that mirror each real component's heights, spacing and markup — so transitions from skeleton to loaded state don't cause layout shift. Extract every skeleton into its own file under `src/components/**/` for reuse between inline loading states, Next.js `loading.tsx` Suspense fallbacks, and layout-level dispatch in `account/layout.tsx`. Also split the login form out of `/account` into a dedicated `/account/login` route: `/account` is now dashboard-only (with an auth redirect to `/account/login`), and the account-button links in Header / Footer / MobileMenu route anonymous users straight to `/account/login` via a new `AccountLink` client component. Pages covered: homepage featured products, cart (2-item scenario), checkout, order-placed, category listing + filter bar, and every account sub-page (dashboard, profile, orders, gift cards, credit cards, addresses, login, register). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../(checkout)/checkout/[id]/page.tsx | 16 +- .../(checkout)/order-placed/[id]/page.tsx | 10 +- .../account/addresses/loading.tsx | 5 + .../account/credit-cards/loading.tsx | 5 + .../account/forgot-password/page.tsx | 4 +- .../account/gift-cards/loading.tsx | 5 + .../[locale]/(storefront)/account/layout.tsx | 84 ++++---- .../(storefront)/account/login/page.tsx | 167 ++++++++++++++++ .../(storefront)/account/orders/loading.tsx | 5 + .../[locale]/(storefront)/account/page.tsx | 180 ++---------------- .../(storefront)/account/register/page.tsx | 2 +- .../account/reset-password/page.tsx | 4 +- .../(storefront)/c/[...permalink]/loading.tsx | 38 +--- .../[locale]/(storefront)/cart/page.tsx | 14 +- .../account/AccountDashboardSkeleton.tsx | 34 ++++ src/components/account/AddressesSkeleton.tsx | 54 ++++++ .../account/AuthFallbackSkeleton.tsx | 16 ++ src/components/account/ContentSkeleton.tsx | 14 ++ .../account/CreditCardsSkeleton.tsx | 47 +++++ src/components/account/GiftCardsSkeleton.tsx | 71 +++++++ src/components/account/LoginFormSkeleton.tsx | 44 +++++ src/components/account/OrdersListSkeleton.tsx | 61 ++++++ src/components/account/ProfileSkeleton.tsx | 60 ++++++ .../account/RegisterFormSkeleton.tsx | 62 ++++++ .../account/SidebarUserInfoSkeleton.tsx | 16 ++ src/components/cart/CartSkeleton.tsx | 78 ++++++++ .../category/CategoryPageSkeleton.tsx | 50 +++++ .../checkout/CheckoutPageSkeleton.tsx | 73 +++++++ src/components/layout/AccountLink.tsx | 41 ++++ src/components/layout/Footer.tsx | 7 +- src/components/layout/Header.tsx | 5 +- src/components/layout/MobileMenu.tsx | 7 +- src/components/order/OrderPlacedSkeleton.tsx | 112 +++++++++++ .../products/ProductCardSkeleton.tsx | 4 +- .../products/filters/FilterBarSkeleton.tsx | 33 ++++ .../products/filters/ProductFilters.tsx | 10 +- 36 files changed, 1138 insertions(+), 300 deletions(-) create mode 100644 src/app/[country]/[locale]/(storefront)/account/addresses/loading.tsx create mode 100644 src/app/[country]/[locale]/(storefront)/account/credit-cards/loading.tsx create mode 100644 src/app/[country]/[locale]/(storefront)/account/gift-cards/loading.tsx create mode 100644 src/app/[country]/[locale]/(storefront)/account/login/page.tsx create mode 100644 src/app/[country]/[locale]/(storefront)/account/orders/loading.tsx create mode 100644 src/components/account/AccountDashboardSkeleton.tsx create mode 100644 src/components/account/AddressesSkeleton.tsx create mode 100644 src/components/account/AuthFallbackSkeleton.tsx create mode 100644 src/components/account/ContentSkeleton.tsx create mode 100644 src/components/account/CreditCardsSkeleton.tsx create mode 100644 src/components/account/GiftCardsSkeleton.tsx create mode 100644 src/components/account/LoginFormSkeleton.tsx create mode 100644 src/components/account/OrdersListSkeleton.tsx create mode 100644 src/components/account/ProfileSkeleton.tsx create mode 100644 src/components/account/RegisterFormSkeleton.tsx create mode 100644 src/components/account/SidebarUserInfoSkeleton.tsx create mode 100644 src/components/cart/CartSkeleton.tsx create mode 100644 src/components/category/CategoryPageSkeleton.tsx create mode 100644 src/components/checkout/CheckoutPageSkeleton.tsx create mode 100644 src/components/layout/AccountLink.tsx create mode 100644 src/components/order/OrderPlacedSkeleton.tsx create mode 100644 src/components/products/filters/FilterBarSkeleton.tsx 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..99feb9bd --- /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() { + return ; +} diff --git a/src/app/[country]/[locale]/(storefront)/account/layout.tsx b/src/app/[country]/[locale]/(storefront)/account/layout.tsx index 407c631b..351c22b7 100644 --- a/src/app/[country]/[locale]/(storefront)/account/layout.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/layout.tsx @@ -14,6 +14,17 @@ 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 { 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 { SidebarUserInfoSkeleton } from "@/components/account/SidebarUserInfoSkeleton"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/contexts/AuthContext"; import { extractBasePath } from "@/lib/utils/path"; @@ -37,17 +48,6 @@ function getNavItems(t: ReturnType>): { ]; } -function ContentSkeleton() { - return ( -
-
-
-
-
-
- ); -} - interface AccountShellProps { children: React.ReactNode; basePath: string; @@ -80,10 +80,7 @@ function AccountShell({ {/* User Info */}
{isLoading ? ( -
-
-
-
+ ) : ( <>

@@ -155,49 +152,54 @@ 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 useEffect(() => { - if (!loading && !isAuthenticated && !isAuthPage && !isMainAccountPage) { - router.replace(`${basePath}/account`); + if (!loading && !isAuthenticated && !isAuthPage) { + router.replace(`${basePath}/account/login`); } - }, [ - loading, - isAuthenticated, - isAuthPage, - isMainAccountPage, - basePath, - router, - ]); + }, [loading, isAuthenticated, isAuthPage, basePath, 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 (isAuthPage) { + // forgot-password / reset-password — generic fallback + 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..fe1de13e --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/account/login/page.tsx @@ -0,0 +1,167 @@ +"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"; + +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); + + // Redirect if already authenticated + // useEffect is needed here to prevent rendering issues. + useEffect(() => { + if (!authLoading && isAuthenticated) { + router.push(redirectUrl ?? `${basePath}/account`); + } + }, [authLoading, isAuthenticated, redirectUrl, router, basePath]); + if (authLoading || isAuthenticated) { + return 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 go to account dashboard + router.push(redirectUrl ?? `${basePath}/account`); + } else { + setError(result.error || t("invalidCredentials")); + } + 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)/c/[...permalink]/loading.tsx b/src/app/[country]/[locale]/(storefront)/c/[...permalink]/loading.tsx index 426cb9ef..f8a1de2e 100644 --- a/src/app/[country]/[locale]/(storefront)/c/[...permalink]/loading.tsx +++ b/src/app/[country]/[locale]/(storefront)/c/[...permalink]/loading.tsx @@ -1,39 +1,5 @@ -import { ProductGridSkeleton } from "@/components/products/ProductGridSkeleton"; +import { CategoryPageSkeleton } from "@/components/category/CategoryPageSkeleton"; export default function CategoryLoading() { - return ( -

- {/* Banner skeleton */} -
- -
- {/* Breadcrumb skeleton */} -
-
-
-
-
- - {/* Filter button skeleton (mobile) */} -
-
-
- -
- {/* Sidebar skeleton (desktop) */} -
-
-
-
-
-
- - {/* Product grid skeleton */} -
- -
-
-
-
- ); + return ; } 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..5b02718b --- /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() { + return ( +
+
+
+
+
+
+
+ ); +} diff --git a/src/components/account/ContentSkeleton.tsx b/src/components/account/ContentSkeleton.tsx new file mode 100644 index 00000000..95788006 --- /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() { + 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/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..7dbcf523 --- /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() { + 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..f2330d09 --- /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() { + 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/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/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/category/CategoryPageSkeleton.tsx b/src/components/category/CategoryPageSkeleton.tsx new file mode 100644 index 00000000..df3d98ee --- /dev/null +++ b/src/components/category/CategoryPageSkeleton.tsx @@ -0,0 +1,50 @@ +import { FilterBarSkeleton } from "@/components/products/filters/FilterBarSkeleton"; +import { ProductGridSkeleton } from "@/components/products/ProductGridSkeleton"; + +/** + * Full-page skeleton mirroring `/c/[...permalink]/page.tsx`: + * - Banner `min-h-[350px] flex flex-col justify-end bg-gray-50` with Breadcrumbs, + * h1 (text-4xl), optional description — all bottom-anchored inside container + * - Subcategories strip `container mt-4` + `flex flex-wrap gap-2 border-b pb-4` + * - Products section `container pt-4` with FilterBar + ProductGridSkeleton + */ +export function CategoryPageSkeleton() { + return ( +
+ {/* Banner */} +
+
+ {/* Breadcrumbs (nav mb-6, ol text-sm = 20px) */} +
+
+
+
+
+ {/* h1 text-4xl = 40px line-height, mb-4 wrapper */} +
+
+
+ {/* Description p text-base (24px), mb-4 */} +
+
+
+ + {/* Subcategories */} +
+
+ {[0, 1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+ +
+ + +
+
+ ); +} 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..7d6f7e57 --- /dev/null +++ b/src/components/layout/AccountLink.tsx @@ -0,0 +1,41 @@ +"use client"; + +import Link from "next/link"; +import type { ReactNode } from "react"; +import { useAuth } from "@/contexts/AuthContext"; + +interface AccountLinkProps { + basePath: string; + className?: string; + "aria-label"?: string; + children: ReactNode; +} + +/** + * 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) + * + * Used by Header / Footer / MobileMenu so anonymous users go straight to login + * without a flash of the dashboard skeleton. + */ +export function AccountLink({ + basePath, + className, + "aria-label": ariaLabel, + children, +}: AccountLinkProps) { + const { isAuthenticated, loading } = useAuth(); + + const href = + loading || isAuthenticated + ? `${basePath}/account` + : `${basePath}/account/login`; + + return ( + + {children} + + ); +} diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index fbcd5799..7c35b6c5 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -1,6 +1,7 @@ import type { Category } from "@spree/sdk"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; +import { AccountLink } from "@/components/layout/AccountLink"; import { POLICY_LINKS } from "@/lib/constants/policies"; import { getStoreDescription, getStoreName } from "@/lib/store"; @@ -101,12 +102,12 @@ export async function Footer({

  • - {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..a36e0357 --- /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() { + 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/components/products/filters/FilterBarSkeleton.tsx b/src/components/products/filters/FilterBarSkeleton.tsx new file mode 100644 index 00000000..32f4e370 --- /dev/null +++ b/src/components/products/filters/FilterBarSkeleton.tsx @@ -0,0 +1,33 @@ +/** + * Skeleton mirroring the loaded `FilterBar` shape: + * - Wrapper `mb-6` + * - Desktop (`hidden md:flex items-center justify-between pb-4 border-b`): + * left cluster of filter dropdowns (h-9) + right cluster with count (text-sm) + sort + * - Mobile (`flex items-center gap-3 md:hidden pb-4 border-b`): + * filters button (h-10) + sort dropdown (h-9) + */ +export function FilterBarSkeleton() { + return ( +
+
+
+
+
+
+
+
+ {/* "X products" count text-sm */} +
+ {/* Sort dropdown */} +
+
+
+ +
+ {/* Filters button: px-4 py-2 text-sm rounded-lg border = ~h-10 */} +
+
+
+
+ ); +} diff --git a/src/components/products/filters/ProductFilters.tsx b/src/components/products/filters/ProductFilters.tsx index 79b1aab6..00ad5f55 100644 --- a/src/components/products/filters/ProductFilters.tsx +++ b/src/components/products/filters/ProductFilters.tsx @@ -11,6 +11,7 @@ import { useLocale, useTranslations } from "next-intl"; import type { JSX } from "react"; import { memo, useCallback, useMemo, useState } from "react"; import { AvailabilityDropdownContent } from "@/components/products/filters/AvailabilityDropdownContent"; +import { FilterBarSkeleton } from "@/components/products/filters/FilterBarSkeleton"; import { FilterChips } from "@/components/products/filters/FilterChips"; import { FilterDropdown } from "@/components/products/filters/FilterDropdown"; import { MobileFilterDrawer } from "@/components/products/filters/MobileFilterDrawer"; @@ -137,14 +138,7 @@ export const FilterBar = memo(function FilterBar({ if (!filtersData) { if (filtersLoading) { - return ( -
-
-
-
-
-
- ); + return ; } return null; } From a0361879c0921bdc3b9ac497a196e5cd762a2aa0 Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Mon, 13 Apr 2026 13:47:19 +0200 Subject: [PATCH 2/3] Add dedicated skeletons for forgot/reset password and cart drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ForgotPasswordFormSkeleton and ResetPasswordFormSkeleton replace the generic AuthFallbackSkeleton for `/account/forgot-password` and `/account/reset-password` — each mirrors its real Card layout (header, field count, submit h-13, back-to-sign-in link). - CartDrawerSkeleton extracts the inline pulse placeholder from `CartDrawer.tsx` into its own reusable file, matching the project convention of keeping all skeletons in separate components. AuthFallbackSkeleton stays as a generic fallback for any future auth page. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../[locale]/(storefront)/account/layout.tsx | 10 ++++- .../account/ForgotPasswordFormSkeleton.tsx | 41 +++++++++++++++++ .../account/ResetPasswordFormSkeleton.tsx | 44 +++++++++++++++++++ src/components/cart/CartDrawer.tsx | 13 +----- src/components/cart/CartDrawerSkeleton.tsx | 20 +++++++++ 5 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 src/components/account/ForgotPasswordFormSkeleton.tsx create mode 100644 src/components/account/ResetPasswordFormSkeleton.tsx create mode 100644 src/components/cart/CartDrawerSkeleton.tsx diff --git a/src/app/[country]/[locale]/(storefront)/account/layout.tsx b/src/app/[country]/[locale]/(storefront)/account/layout.tsx index 351c22b7..70697234 100644 --- a/src/app/[country]/[locale]/(storefront)/account/layout.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/layout.tsx @@ -19,11 +19,13 @@ 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"; @@ -174,8 +176,14 @@ export default function AccountLayout({ if (pathname === `${basePath}/account/register`) { return ; } + if (pathname === `${basePath}/account/forgot-password`) { + return ; + } + if (pathname === `${basePath}/account/reset-password`) { + return ; + } if (isAuthPage) { - // forgot-password / reset-password — generic fallback + // generic fallback for any future auth page return ; } const isDashboardPage = pathname === `${basePath}/account`; 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/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/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) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} From 31aa5d672a02c39e83fe29ff0974df55a39265dd Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Mon, 13 Apr 2026 14:01:21 +0200 Subject: [PATCH 3/3] Address PR review: preserve redirect, guard open-redirect, forward Slot props MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `account/layout.tsx` — anonymous-user redirect now preserves the originally requested path via `?redirect=` so LoginPage can send the user back after they sign in. - `account/login/page.tsx` — `handleSubmit` wrapped in try/catch/finally so the submit state is always cleared even on exceptions; the `?redirect=` target is now validated through `safeRedirect()` to prevent open-redirect attacks (rejects absolute URLs, protocol-relative paths, `javascript:`, etc.). - `components/layout/AccountLink.tsx` — props changed to `Omit, "href"> & { basePath }` and spread onto ``, so the component composes correctly with Radix Slot consumers like `