diff --git a/src/app/[country]/[locale]/(storefront)/cart/page.tsx b/src/app/[country]/[locale]/(storefront)/cart/page.tsx index dfca163f..a17154cb 100644 --- a/src/app/[country]/[locale]/(storefront)/cart/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/cart/page.tsx @@ -23,7 +23,7 @@ const ExpressCheckoutButton = dynamic( ); export default function CartPage() { - const { cart, loading, updateItem, removeItem } = useCart(); + const { cart, loading, updating, updateItem, removeItem } = useCart(); const [expressProcessing, setExpressProcessing] = useState(false); const pathname = usePathname(); const basePath = extractBasePath(pathname); @@ -135,12 +135,14 @@ export default function CartPage() { onQuantityChange={(quantity) => updateItem(item.id, quantity) } + disabled={updating} /> diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleGuestBrowse.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleGuestBrowse.tsx index 8a2a691d..e3d25d8b 100644 --- a/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleGuestBrowse.tsx +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleGuestBrowse.tsx @@ -1,7 +1,9 @@ "use client"; import { usePathname, useSearchParams } from "next/navigation"; +import { useMemo } from "react"; import { HiddenPricingProvider } from "@/contexts/HiddenPricingContext"; +import { wholesaleSignInHref } from "@/lib/wholesale"; import { WholesaleHeader } from "./WholesaleHeader"; interface WholesaleGuestBrowseProps { @@ -22,17 +24,21 @@ export function WholesaleGuestBrowse({ basePath, children, }: WholesaleGuestBrowseProps) { - const wholesaleBase = `${basePath}/wholesale`; const pathname = usePathname(); const searchParams = useSearchParams(); // Return the buyer to exactly where they were, query string included. - const query = searchParams.toString(); - const returnTo = query ? `${pathname}?${query}` : pathname; - const signInHref = `${wholesaleBase}?redirect=${encodeURIComponent(returnTo)}`; + const signInHref = useMemo(() => { + const query = searchParams.toString(); + return wholesaleSignInHref( + basePath, + query ? `${pathname}?${query}` : pathname, + ); + }, [basePath, pathname, searchParams]); + const hiddenPricing = useMemo(() => ({ signInHref }), [signInHref]); return ( - + - + {t("nav.signIn")} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleSignInWall.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleSignInWall.tsx index aeb9de61..7ea52831 100644 --- a/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleSignInWall.tsx +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleSignInWall.tsx @@ -18,6 +18,7 @@ import { import { Field, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { useAuth } from "@/contexts/AuthContext"; +import { safeRedirectPath } from "@/lib/utils/path"; interface WholesaleSignInWallProps { basePath: string; @@ -41,13 +42,10 @@ export function WholesaleSignInWall({ const { login } = useAuth(); const wholesaleBase = `${basePath}/wholesale`; - // Only follow same-origin relative paths after login. Reject absolute URLs - // and protocol-relative values ("//host") to avoid an open redirect. - const redirectParam = searchParams.get("redirect"); - const redirectUrl = - redirectParam?.startsWith("/") && !redirectParam.startsWith("//") - ? redirectParam - : wholesaleBase; + const redirectUrl = safeRedirectPath( + searchParams.get("redirect"), + wholesaleBase, + ); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/apply/page.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/apply/page.tsx index def949b8..e26a7de5 100644 --- a/src/app/[country]/[locale]/(wholesale)/wholesale/apply/page.tsx +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/apply/page.tsx @@ -25,6 +25,7 @@ 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 { wholesaleSignInHref } from "@/lib/wholesale"; /** * Wholesale application form. Registers a customer via the shared register flow @@ -240,7 +241,7 @@ export default function WholesaleApplyPage() {

{t("apply.alreadyMember")}{" "} {t("signInWall.submit")} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/cart/WholesaleCartView.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/cart/WholesaleCartView.tsx index 413ff11b..06f30128 100644 --- a/src/app/[country]/[locale]/(wholesale)/wholesale/cart/WholesaleCartView.tsx +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/cart/WholesaleCartView.tsx @@ -19,7 +19,7 @@ import { WHOLESALE_MIN_QUANTITY } from "@/lib/wholesale"; * (checkout) flow, which resolves the wholesale surface from the cart id. */ export function WholesaleCartView() { - const { cart, loading, updateItem, removeItem } = useCart(); + const { cart, loading, updating, updateItem, removeItem } = useCart(); const pathname = usePathname(); // extractBasePath strips to /{country}/{locale}; the shared checkout lives there. const storeBase = extractBasePath(pathname); @@ -125,12 +125,14 @@ export function WholesaleCartView() { onQuantityChange={(quantity) => updateItem(item.id, quantity) } + disabled={updating} /> diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/sign-in/page.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/sign-in/page.tsx new file mode 100644 index 00000000..ff068484 --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/sign-in/page.tsx @@ -0,0 +1,45 @@ +import { redirect } from "next/navigation"; +import { getCustomer } from "@/lib/data/customer"; +import { getWholesaleChannel } from "@/lib/data/wholesale"; +import { safeRedirectPath } from "@/lib/utils/path"; +import { WholesaleSignInWall } from "../_components/WholesaleSignInWall"; + +interface WholesaleSignInPageProps { + params: Promise<{ country: string; locale: string }>; + // Repeated query keys arrive as an array, so accept what Next.js can deliver. + searchParams: Promise<{ redirect?: string | string[] }>; +} + +/** + * Dedicated sign-in destination for the portal. On a `prices_hidden` channel + * the catalog root renders for guests, so "sign in" affordances can't point + * there — they'd land right back on the catalog (and, with `?redirect=` + * re-appended on every click, loop forever). This page always shows the + * sign-in wall (which also links to the apply form) and honours the same + * `?redirect=` contract; an already-authenticated buyer is bounced into the + * portal, where the gate resolves their approval state. + */ +export default async function WholesaleSignInPage({ + params, + searchParams, +}: WholesaleSignInPageProps) { + const { country, locale } = await params; + const { redirect: redirectParam } = await searchParams; + const basePath = `/${country}/${locale}`; + + const [customer, channel] = await Promise.all([ + getCustomer(), + getWholesaleChannel(), + ]); + + if (customer) { + redirect(safeRedirectPath(redirectParam, `${basePath}/wholesale`)); + } + + return ( + + ); +} diff --git a/src/components/products/HiddenPricePrompt.tsx b/src/components/products/HiddenPricePrompt.tsx index 042ecc33..806761e6 100644 --- a/src/components/products/HiddenPricePrompt.tsx +++ b/src/components/products/HiddenPricePrompt.tsx @@ -4,6 +4,7 @@ import { Lock } from "lucide-react"; import Link from "next/link"; import { useTranslations } from "next-intl"; import { useHiddenPricing } from "@/contexts/HiddenPricingContext"; +import { cn } from "@/lib/utils"; /** * Rendered in place of a price when the viewer isn't entitled to see it (a guest @@ -20,11 +21,13 @@ export function HiddenPricePrompt({ className }: { className?: string }) { return ( e.stopPropagation()} + "inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 underline underline-offset-4 hover:text-slate-900", + )} > {t("hiddenPrice.signInForPricing")} diff --git a/src/components/products/ProductCard.tsx b/src/components/products/ProductCard.tsx index b41f1ec5..c97627c5 100644 --- a/src/components/products/ProductCard.tsx +++ b/src/components/products/ProductCard.tsx @@ -61,11 +61,7 @@ export const ProductCard = memo(function ProductCard({ }; return ( - +

{/* Image */}

- {product.name} + {/* Stretched link: the ::after overlay keeps the whole card clickable + without wrapping the content in an — HiddenPricePrompt renders + its own link, and anchors can't nest. */} + + {product.name} +

@@ -111,6 +116,6 @@ export const ProductCard = memo(function ProductCard({ {t("outOfStock")} )}
- +
); }); diff --git a/src/components/ui/quantity-picker.tsx b/src/components/ui/quantity-picker.tsx index 2b32a474..4dedb1e9 100644 --- a/src/components/ui/quantity-picker.tsx +++ b/src/components/ui/quantity-picker.tsx @@ -74,7 +74,11 @@ export function QuantityPicker({ if (e.key === "Enter") { e.preventDefault(); e.currentTarget.blur(); - } else if (e.key === "Escape") { + } else if (e.key === "Escape" && draft !== null) { + // While editing, Escape cancels the edit and nothing else — the + // picker can sit inside a dialog (the cart drawer) that would + // otherwise dismiss on the same keypress. + e.stopPropagation(); setDraft(null); } }} diff --git a/src/lib/__tests__/wholesale.test.ts b/src/lib/__tests__/wholesale.test.ts new file mode 100644 index 00000000..7fda91f5 --- /dev/null +++ b/src/lib/__tests__/wholesale.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { wholesaleSignInHref } from "../wholesale"; + +describe("wholesaleSignInHref", () => { + const basePath = "/us/en"; + + it("points at the dedicated sign-in page", () => { + expect(wholesaleSignInHref(basePath)).toBe("/us/en/wholesale/sign-in"); + }); + + it("carries a return target", () => { + expect( + wholesaleSignInHref(basePath, "/us/en/wholesale/products/mug?ref=grid"), + ).toBe( + "/us/en/wholesale/sign-in?redirect=%2Fus%2Fen%2Fwholesale%2Fproducts%2Fmug%3Fref%3Dgrid", + ); + }); + + it("drops a stale redirect instead of nesting it", () => { + expect( + wholesaleSignInHref( + basePath, + "/us/en/wholesale?redirect=%2Fus%2Fen%2Fwholesale", + ), + ).toBe("/us/en/wholesale/sign-in?redirect=%2Fus%2Fen%2Fwholesale"); + }); + + it.each([ + "https://example.com/us/en/wholesale", + "//example.com", + "/us/en/wholesale/sign-in", + null, + ])("omits an unusable return target: %s", (returnTo) => { + expect(wholesaleSignInHref(basePath, returnTo)).toBe( + "/us/en/wholesale/sign-in", + ); + }); +}); diff --git a/src/lib/utils/__tests__/path.test.ts b/src/lib/utils/__tests__/path.test.ts new file mode 100644 index 00000000..644c23e4 --- /dev/null +++ b/src/lib/utils/__tests__/path.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { resolveLocalPath, safeRedirectPath } from "../path"; + +describe("resolveLocalPath", () => { + it.each([ + ["/us/en/wholesale", "/us/en/wholesale"], + [ + "/us/en/wholesale/products/mug?category_id=3#specs", + "/us/en/wholesale/products/mug?category_id=3#specs", + ], + [["/us/en/wholesale", "/us/en/account"], "/us/en/wholesale"], + // An encoded path is ordinary data inside a query value. + [ + "/us/en/wholesale?redirect=%2Fus%2Fen%2Fwholesale", + "/us/en/wholesale?redirect=%2Fus%2Fen%2Fwholesale", + ], + ])("resolves a same-origin path: %s", (value, expected) => { + expect(resolveLocalPath(value)).toBe(expected); + }); + + it.each([ + "https://example.com/us/en/wholesale", + "//example.com/us/en/wholesale", + "/\\example.com/us/en/wholesale", + "/us/en/wholesale%2f%2fexample.com", + "us/en/wholesale", + "//[", + "", + ])("rejects a value that is not a local path: %s", (value) => { + expect(resolveLocalPath(value)).toBeNull(); + }); + + it.each([null, undefined, []])("rejects %s", (value) => { + expect(resolveLocalPath(value)).toBeNull(); + }); +}); + +describe("safeRedirectPath", () => { + const fallback = "/us/en/wholesale"; + + it("returns the resolved path when it is local", () => { + expect(safeRedirectPath("/us/en/wholesale/cart", fallback)).toBe( + "/us/en/wholesale/cart", + ); + }); + + it("returns the fallback when the value points elsewhere", () => { + expect(safeRedirectPath("https://example.com", fallback)).toBe(fallback); + }); +}); diff --git a/src/lib/utils/account-redirect.ts b/src/lib/utils/account-redirect.ts index 5cb25c64..d03f1dfe 100644 --- a/src/lib/utils/account-redirect.ts +++ b/src/lib/utils/account-redirect.ts @@ -1,5 +1,4 @@ -const INTERNAL_ORIGIN = "https://storefront.invalid"; -const ENCODED_PATH_SEPARATOR = /%(?:2f|5c)/i; +import { INTERNAL_ORIGIN, resolveLocalPath } from "./path"; function isAllowedLocalizedDestination( pathname: string, @@ -18,35 +17,18 @@ function isAllowedLocalizedDestination( /** * Resolve a login return target without allowing cross-origin or cross-market - * navigation. Account and checkout are the only flows that send users through - * the account sign-in page today. + * navigation: a safe local path, narrowed to the destinations that send users + * through the account sign-in page. Account and checkout are the only two today. */ export function resolveAccountRedirect( redirect: string | null | undefined, basePath: string, ): string | null { - if ( - !redirect?.startsWith("/") || - redirect.startsWith("//") || - redirect.includes("\\") || - ENCODED_PATH_SEPARATOR.test(redirect) - ) { - return null; - } - - try { - const target = new URL(redirect, INTERNAL_ORIGIN); - if ( - target.origin !== INTERNAL_ORIGIN || - !isAllowedLocalizedDestination(target.pathname, basePath) - ) { - return null; - } + const target = resolveLocalPath(redirect); + if (!target) return null; - return `${target.pathname}${target.search}${target.hash}`; - } catch { - return null; - } + const { pathname } = new URL(target, INTERNAL_ORIGIN); + return isAllowedLocalizedDestination(pathname, basePath) ? target : null; } export function buildAccountLoginHref( diff --git a/src/lib/utils/path.ts b/src/lib/utils/path.ts index 1f4cd8ef..183e360a 100644 --- a/src/lib/utils/path.ts +++ b/src/lib/utils/path.ts @@ -8,6 +8,59 @@ export function extractBasePath(pathname: string): string { return `/${segments[0]}/${segments[1]}`; } +/** Any candidate that resolves off this placeholder origin is not a local path. */ +export const INTERNAL_ORIGIN = "https://storefront.invalid"; +/** + * Survives URL parsing intact, but decodes to a path separator downstream. + * Only ever tested against the path — a query value may legitimately carry an + * encoded path of its own (a nested `?redirect=` target). + */ +const ENCODED_PATH_SEPARATOR = /%(?:2f|5c)/i; + +/** + * Resolve a candidate into a same-origin path, or null when it points anywhere + * else. The single guard behind every post-login return target: a leading-slash + * check alone is not enough, because the URL parser treats a backslash as a + * slash for http(s), so "/\evil.com" resolves to the off-site "//evil.com". + * Repeated query keys arrive as an array — only the first is considered. + */ +export function resolveLocalPath( + value: string | string[] | undefined | null, +): string | null { + const candidate = Array.isArray(value) ? value[0] : value; + if ( + !candidate?.startsWith("/") || + candidate.startsWith("//") || + candidate.includes("\\") + ) { + return null; + } + + try { + const url = new URL(candidate, INTERNAL_ORIGIN); + if ( + url.origin !== INTERNAL_ORIGIN || + ENCODED_PATH_SEPARATOR.test(url.pathname) + ) { + return null; + } + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return null; + } +} + +/** + * Resolve a `?redirect=` value into a safe same-origin path, falling back when + * it points anywhere else. + */ +export function safeRedirectPath( + value: string | string[] | undefined | null, + fallback: string, +): string { + return resolveLocalPath(value) ?? fallback; +} + /** * Get the path portion after the /country/locale prefix. * e.g. "/us/en/products/shoes" -> "/products/shoes" diff --git a/src/lib/wholesale.ts b/src/lib/wholesale.ts index e56612c8..fe8e4296 100644 --- a/src/lib/wholesale.ts +++ b/src/lib/wholesale.ts @@ -1,4 +1,5 @@ import type { Customer } from "@spree/sdk"; +import { INTERNAL_ORIGIN, resolveLocalPath } from "@/lib/utils/path"; /** * Name of the customer group whose members are approved wholesale buyers. @@ -22,3 +23,27 @@ export function isWholesaleApproved(customer: Customer | null): boolean { customer?.customer_groups?.some((g) => g.name === WHOLESALE_GROUP_NAME), ); } + +/** + * The portal's sign-in destination, optionally carrying `returnTo` as the + * `?redirect=` target. On a `prices_hidden` channel the catalog root renders for + * guests, so sign-in affordances need this dedicated page — pointing them at the + * catalog would land the buyer back where they started. A `redirect` already on + * `returnTo` is dropped: it is a stale return target from an earlier round trip, + * and keeping it would nest redirects inside redirects. + */ +export function wholesaleSignInHref( + basePath: string, + returnTo?: string | null, +): string { + const signInPath = `${basePath}/wholesale/sign-in`; + const target = resolveLocalPath(returnTo); + if (!target) return signInPath; + + const url = new URL(target, INTERNAL_ORIGIN); + url.searchParams.delete("redirect"); + const returnPath = `${url.pathname}${url.search}${url.hash}`; + if (returnPath === signInPath) return signInPath; + + return `${signInPath}?redirect=${encodeURIComponent(returnPath)}`; +}