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
8 changes: 5 additions & 3 deletions frontend/app/[locale]/shop/cart/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
'use client';

import { useMemo, useState } from 'react';
import { useState } from 'react';
import Image from 'next/image';
import { useParams, useRouter } from 'next/navigation';
import { useParams } from 'next/navigation';
import { useRouter } from '@/i18n/routing';

import { Minus, Plus, Trash2, ShoppingBag } from 'lucide-react';

import { Link } from '@/i18n/routing';
Expand All @@ -19,7 +21,7 @@ export default function CartPage() {

const params = useParams<{ locale?: string }>();
const locale = params.locale ?? 'en';
const shopBase = useMemo(() => `/${locale}/shop`, [locale]);
const shopBase = '/shop';

async function handleCheckout() {
setCheckoutError(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,31 +48,55 @@ function toCurrencyCode(
: resolveCurrencyFromLocale(locale);
}

function buildShopBase(locale: string) {
return `/${locale}/shop`;
/**
* IMPORTANT:
* - In-app navigation uses next-intl routing -> DO NOT prefix locale manually.
* - Stripe return_url is an external redirect -> MUST include locale exactly once.
*/
const IN_APP_SHOP_BASE = '/shop';

function normalizeLocale(raw: string): string {
return (raw ?? '').trim().replace(/^\/+/, '').replace(/\/+$/, '');
}

function nextRouteForPaymentResult(params: {
function buildInAppPath(path: string): string {
const p = path.startsWith('/') ? path : `/${path}`;
return `${IN_APP_SHOP_BASE}${p}`;
}

function buildStripeReturnUrl(params: {
locale: string;
inAppPath: string; // must be "/shop/..."
}): string {
const loc = normalizeLocale(params.locale);
const p = params.inAppPath.startsWith('/')
? params.inAppPath
: `/${params.inAppPath}`;
// Note: p can contain query string; URL() supports it.
return new URL(`/${loc}${p}`, window.location.origin).toString();
}

function nextRouteForPaymentResult(params: {
orderId: string;
status?: string | null;
}) {
const { locale, orderId, status } = params;
const shopBase = buildShopBase(locale);
const { orderId, status } = params;
const id = encodeURIComponent(orderId);

const success = `${shopBase}/checkout/success?orderId=${id}`;
const failure = `${shopBase}/checkout/error?orderId=${id}`;
const success = buildInAppPath(`/checkout/success?orderId=${id}`);
const failure = buildInAppPath(`/checkout/error?orderId=${id}`);

if (!status) return success;
if (
status === 'succeeded' ||
status === 'processing' ||
status === 'requires_capture'
)
) {
return success;
if (status === 'requires_payment_method' || status === 'canceled')
}
if (status === 'requires_payment_method' || status === 'canceled') {
return failure;
}
return success;
}

Expand All @@ -81,8 +105,6 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
const elements = useElements();
const router = useRouter();

const shopBase = useMemo(() => buildShopBase(locale), [locale]);

const [submitting, setSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);

Expand All @@ -102,23 +124,25 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
try {
const id = encodeURIComponent(orderId);

const inAppSuccess = buildInAppPath(`/checkout/success?orderId=${id}`);
const inAppFailure = buildInAppPath(`/checkout/error?orderId=${id}`);

const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: 'if_required',
confirmParams: {
// Stripe redirect comes from outside Next.js routing — must include locale.
return_url: `${window.location.origin}${shopBase}/checkout/success?orderId=${id}`,
// Stripe redirect comes from outside Next.js routing — MUST include locale exactly once.
return_url: buildStripeReturnUrl({ locale, inAppPath: inAppSuccess }),
},
});

if (error) {
setErrorMessage(error.message ?? 'Unable to confirm payment.');
router.push(`${shopBase}/checkout/error?orderId=${id}`);
router.push(inAppFailure);
return;
}

const next = nextRouteForPaymentResult({
locale,
orderId,
status: paymentIntent?.status ?? null,
});
Expand All @@ -128,7 +152,7 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
logError('stripe_payment_confirm_failed', error, { orderId });
setErrorMessage('We couldn’t confirm your payment. Please try again.');
router.push(
`${shopBase}/checkout/error?orderId=${encodeURIComponent(orderId)}`
buildInAppPath(`/checkout/error?orderId=${encodeURIComponent(orderId)}`)
);
} finally {
setSubmitting(false);
Expand Down Expand Up @@ -175,8 +199,6 @@ export default function StripePaymentClient({
[currency, locale]
);

const shopBase = useMemo(() => buildShopBase(locale), [locale]);

const stripePromise = useMemo(() => {
if (!paymentsEnabled || !publishableKey) return null;
return loadStripe(publishableKey);
Expand All @@ -199,15 +221,15 @@ export default function StripePaymentClient({
<p>Payments are disabled in this environment.</p>
<nav className="flex gap-3" aria-label="Next steps">
<Link
href={`${shopBase}/checkout/success?orderId=${encodeURIComponent(
orderId
)}`}
href={buildInAppPath(
`/checkout/success?orderId=${encodeURIComponent(orderId)}`
)}
className="inline-flex items-center justify-center rounded-md bg-accent px-4 py-2 text-sm font-semibold uppercase tracking-wide text-accent-foreground hover:bg-accent/90"
>
Continue
</Link>
<Link
href={`${shopBase}/cart`}
href={buildInAppPath('/cart')}
className="inline-flex items-center justify-center rounded-md border border-border px-4 py-2 text-sm font-semibold uppercase tracking-wide text-foreground hover:bg-secondary"
>
Back to cart
Expand All @@ -225,7 +247,7 @@ export default function StripePaymentClient({
>
<p>Payment cannot be initialized. Please try again later.</p>
<Link
href={`${shopBase}/cart`}
href={buildInAppPath('/cart')}
className="inline-flex items-center justify-center rounded-md border border-border px-4 py-2 text-sm font-semibold uppercase tracking-wide text-foreground hover:bg-secondary"
>
Return to cart
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export default async function PaymentPage(props: PaymentPageProps) {
const clearCart = shouldClearCart(searchParams);
const cc = clearCart ? '&clearCart=1' : '';
const { locale } = params;
const shopBase = `/${locale}/shop`;
const shopBase = `/shop`;

const orderId = getOrderId(params);

Expand Down
2 changes: 1 addition & 1 deletion frontend/app/[locale]/shop/orders/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export default async function MyOrdersPage({
const user = await getCurrentUser();
if (!user) {
redirect(
`/${locale}/login?next=${encodeURIComponent(`/${locale}/shop/orders`)}`
`/login?next=${encodeURIComponent(`/shop/orders`)}`
);
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/app/[locale]/shop/products/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export default async function ProductsPage({
}

const qs = qsParams.toString();
const basePath = `/${locale}/shop/products`;
const basePath = `/shop/products`;

redirect(qs ? `${basePath}?${qs}` : basePath);
}
Expand Down
8 changes: 4 additions & 4 deletions frontend/components/shop/product-sort.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
'use client';

import { useId } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useRouter } from '@/i18n/routing';
import { useSearchParams } from 'next/navigation';

import { SORT_OPTIONS } from '@/lib/config/catalog';
import { cn } from '@/lib/utils';
Expand All @@ -16,9 +17,8 @@ export function ProductSort({ className }: ProductSortProps) {
const router = useRouter();
const searchParams = useSearchParams();

const params = useParams<{ locale?: string }>();
const locale = params.locale ?? 'en';
const basePath = `/${locale}/shop/products`;
// IMPORTANT: when using '@/i18n/routing' router, do NOT prefix locale manually.
const basePath = '/shop/products';

const currentSort = searchParams.get('sort') || 'featured';
const isActive = currentSort !== 'featured';
Expand Down