Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/app/[country]/[locale]/(storefront)/cart/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -135,12 +135,14 @@ export default function CartPage() {
onQuantityChange={(quantity) =>
updateItem(item.id, quantity)
}
disabled={updating}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/>
<Button
variant="destructive"
size="sm"
aria-label={t("removeItemLabel", { name: item.name })}
onClick={() => handleRemove(item)}
disabled={updating}
>
{tc("remove")}
</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 (
<HiddenPricingProvider value={{ signInHref }}>
<HiddenPricingProvider value={hiddenPricing}>
<WholesaleHeader
basePath={basePath}
authenticated={false}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/contexts/AuthContext";
import { useCart } from "@/contexts/CartContext";
import { wholesaleSignInHref } from "@/lib/wholesale";

interface WholesaleHeaderProps {
basePath: string;
Expand Down Expand Up @@ -124,7 +125,7 @@ export function WholesaleHeader({
size="sm"
className="bg-white text-slate-900 hover:bg-slate-100"
>
<Link href={signInHref ?? `${wholesaleBase}`}>
<Link href={signInHref ?? wholesaleSignInHref(basePath)}>
{t("nav.signIn")}
</Link>
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -240,7 +241,7 @@ export default function WholesaleApplyPage() {
<p className="text-sm text-muted-foreground">
{t("apply.alreadyMember")}{" "}
<Link
href={wholesaleBase}
href={wholesaleSignInHref(storeBase)}
className="font-medium text-slate-900 hover:underline"
>
{t("signInWall.submit")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -125,12 +125,14 @@ export function WholesaleCartView() {
onQuantityChange={(quantity) =>
updateItem(item.id, quantity)
}
disabled={updating}
/>
<Button
variant="destructive"
size="sm"
aria-label={t("removeItemLabel", { name: item.name })}
onClick={() => handleRemove(item)}
disabled={updating}
>
{tc("remove")}
</Button>
Expand Down
45 changes: 45 additions & 0 deletions src/app/[country]/[locale]/(wholesale)/wholesale/sign-in/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<WholesaleSignInWall
basePath={basePath}
storefrontAccess={channel?.storefront_access}
/>
);
}
11 changes: 7 additions & 4 deletions src/components/products/HiddenPricePrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,11 +21,13 @@ export function HiddenPricePrompt({ className }: { className?: string }) {
return (
<Link
href={hiddenPricing.signInHref}
className={
className={cn(
// Keeps the prompt clickable when a host card covers itself with a
// stretched-link overlay (ProductCard).
"relative z-10",
className ??
"inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 underline underline-offset-4 hover:text-slate-900"
}
onClick={(e) => e.stopPropagation()}
"inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 underline underline-offset-4 hover:text-slate-900",
)}
>
<Lock className="h-3.5 w-3.5" />
{t("hiddenPrice.signInForPricing")}
Expand Down
19 changes: 12 additions & 7 deletions src/components/products/ProductCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,7 @@ export const ProductCard = memo(function ProductCard({
};

return (
<Link
href={`${basePath}/products/${product.slug}${categoryId ? `?category_id=${categoryId}` : ""}`}
className="group block"
onClick={handleClick}
>
<div className="group relative">
{/* Image */}
<div className="relative aspect-square bg-gray-100 rounded-md overflow-hidden">
<ProductImage
Expand All @@ -87,7 +83,16 @@ export const ProductCard = memo(function ProductCard({
{/* Content */}
<div className="p-4">
<h3 className="text-sm font-medium text-gray-900 group-hover:text-primary transition-colors line-clamp-2">
{product.name}
{/* Stretched link: the ::after overlay keeps the whole card clickable
without wrapping the content in an <a> — HiddenPricePrompt renders
its own link, and anchors can't nest. */}
<Link
href={`${basePath}/products/${product.slug}${categoryId ? `?category_id=${categoryId}` : ""}`}
className="after:absolute after:inset-0"
onClick={handleClick}
>
{product.name}
</Link>
</h3>

<div className="mt-2 flex items-center gap-2">
Expand All @@ -111,6 +116,6 @@ export const ProductCard = memo(function ProductCard({
<span className="mt-2 text-sm text-gray-500">{t("outOfStock")}</span>
)}
</div>
</Link>
</div>
);
});
6 changes: 5 additions & 1 deletion src/components/ui/quantity-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}}
Expand Down
38 changes: 38 additions & 0 deletions src/lib/__tests__/wholesale.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
50 changes: 50 additions & 0 deletions src/lib/utils/__tests__/path.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading