Skip to content

Commit d7c63a3

Browse files
Merge pull request #151 from DevLoversTeam/lso/feat/shop
2 parents dc5af0f + 9d89d65 commit d7c63a3

27 files changed

Lines changed: 4095 additions & 283 deletions

frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
// frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx
21
'use client';
32

43
import { useMemo, useState } from 'react';
5-
import { Link } from '@/i18n/routing';
4+
import { Link, useRouter } from '@/i18n/routing';
65

7-
import { useRouter } from 'next/navigation';
86
import {
97
Elements,
108
PaymentElement,
@@ -50,14 +48,21 @@ function toCurrencyCode(
5048
: resolveCurrencyFromLocale(locale);
5149
}
5250

51+
function buildShopBase(locale: string) {
52+
return `/${locale}/shop`;
53+
}
54+
5355
function nextRouteForPaymentResult(params: {
5456
locale: string;
5557
orderId: string;
5658
status?: string | null;
5759
}) {
58-
const { orderId, status } = params;
59-
const success = `/shop/checkout/success?orderId=${orderId}`;
60-
const failure = `/shop/checkout/error?orderId=${orderId}`;
60+
const { locale, orderId, status } = params;
61+
const shopBase = buildShopBase(locale);
62+
const id = encodeURIComponent(orderId);
63+
64+
const success = `${shopBase}/checkout/success?orderId=${id}`;
65+
const failure = `${shopBase}/checkout/error?orderId=${id}`;
6166

6267
if (!status) return success;
6368
if (
@@ -75,6 +80,9 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
7580
const stripe = useStripe();
7681
const elements = useElements();
7782
const router = useRouter();
83+
84+
const shopBase = useMemo(() => buildShopBase(locale), [locale]);
85+
7886
const [submitting, setSubmitting] = useState(false);
7987
const [errorMessage, setErrorMessage] = useState<string | null>(null);
8088

@@ -92,17 +100,20 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
92100
setSubmitting(true);
93101

94102
try {
103+
const id = encodeURIComponent(orderId);
104+
95105
const { error, paymentIntent } = await stripe.confirmPayment({
96106
elements,
97107
redirect: 'if_required',
98108
confirmParams: {
99-
return_url: `${window.location.origin}/shop/checkout/success?orderId=${orderId}`,
109+
// Stripe redirect comes from outside Next.js routing — must include locale.
110+
return_url: `${window.location.origin}${shopBase}/checkout/success?orderId=${id}`,
100111
},
101112
});
102113

103114
if (error) {
104115
setErrorMessage(error.message ?? 'Unable to confirm payment.');
105-
router.push(`/shop/checkout/error?orderId=${orderId}`);
116+
router.push(`${shopBase}/checkout/error?orderId=${id}`);
106117
return;
107118
}
108119

@@ -111,11 +122,14 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
111122
orderId,
112123
status: paymentIntent?.status ?? null,
113124
});
125+
114126
router.push(next);
115127
} catch (error) {
116128
logError('stripe_payment_confirm_failed', error, { orderId });
117129
setErrorMessage('We couldn’t confirm your payment. Please try again.');
118-
router.push(`/shop/checkout/error?orderId=${orderId}`);
130+
router.push(
131+
`${shopBase}/checkout/error?orderId=${encodeURIComponent(orderId)}`
132+
);
119133
} finally {
120134
setSubmitting(false);
121135
}
@@ -161,6 +175,8 @@ export default function StripePaymentClient({
161175
[currency, locale]
162176
);
163177

178+
const shopBase = useMemo(() => buildShopBase(locale), [locale]);
179+
164180
const stripePromise = useMemo(() => {
165181
if (!paymentsEnabled || !publishableKey) return null;
166182
return loadStripe(publishableKey);
@@ -183,13 +199,15 @@ export default function StripePaymentClient({
183199
<p>Payments are disabled in this environment.</p>
184200
<nav className="flex gap-3" aria-label="Next steps">
185201
<Link
186-
href={`/shop/checkout/success?orderId=${orderId}`}
202+
href={`${shopBase}/checkout/success?orderId=${encodeURIComponent(
203+
orderId
204+
)}`}
187205
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"
188206
>
189207
Continue
190208
</Link>
191209
<Link
192-
href="/shop/cart"
210+
href={`${shopBase}/cart`}
193211
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"
194212
>
195213
Back to cart
@@ -207,7 +225,7 @@ export default function StripePaymentClient({
207225
>
208226
<p>Payment cannot be initialized. Please try again later.</p>
209227
<Link
210-
href="/shop/cart"
228+
href={`${shopBase}/cart`}
211229
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"
212230
>
213231
Return to cart

frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx

Lines changed: 15 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@ import { getOrderSummary } from '@/lib/services/orders';
66
import { OrderNotFoundError } from '@/lib/services/errors';
77
import { orderIdParamSchema } from '@/lib/validation/shop';
88
import { getStripeEnv } from '@/lib/env/stripe';
9-
import { createPaymentIntent, retrievePaymentIntent } from '@/lib/psp/stripe';
10-
import { setOrderPaymentIntent } from '@/lib/services/orders';
119
import { logError } from '@/lib/logging';
10+
import { ensureStripePaymentIntentForOrder } from '@/lib/services/orders/payment-attempts';
1211

1312
export const dynamic = 'force-dynamic';
1413
export const revalidate = 0;
@@ -90,6 +89,7 @@ export default async function PaymentPage(props: PaymentPageProps) {
9089
const clearCart = shouldClearCart(searchParams);
9190
const cc = clearCart ? '&clearCart=1' : '';
9291
const { locale } = params;
92+
const shopBase = `/${locale}/shop`;
9393

9494
const orderId = getOrderId(params);
9595

@@ -101,13 +101,13 @@ export default async function PaymentPage(props: PaymentPageProps) {
101101
>
102102
<nav className="mt-6 flex justify-center gap-3" aria-label="Next steps">
103103
<Link
104-
href="/shop/cart"
104+
href={`${shopBase}/cart`}
105105
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"
106106
>
107107
Go to cart
108108
</Link>
109109
<Link
110-
href="/shop/products"
110+
href={`${shopBase}/products`}
111111
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"
112112
>
113113
Continue shopping
@@ -133,13 +133,13 @@ export default async function PaymentPage(props: PaymentPageProps) {
133133
aria-label="Next steps"
134134
>
135135
<Link
136-
href="/shop/cart"
136+
href={`${shopBase}/cart`}
137137
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"
138138
>
139139
Go to cart
140140
</Link>
141141
<Link
142-
href="/shop/products"
142+
href={`${shopBase}/products`}
143143
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"
144144
>
145145
Continue shopping
@@ -163,50 +163,28 @@ export default async function PaymentPage(props: PaymentPageProps) {
163163
let clientSecret = resolveClientSecret(searchParams);
164164
const publishableKey = paymentsEnabled ? stripeEnv.publishableKey : null;
165165

166-
// Ensure we have a clientSecret even when URL doesn't include ?clientSecret=...
167-
// Source of truth for payment finality is webhook; this only initializes Elements.
168166
if (
169167
paymentsEnabled &&
170168
publishableKey &&
171169
(!clientSecret || !clientSecret.trim())
172170
) {
173171
const existingPi = order.paymentIntentId?.trim() ?? '';
174-
let phase:
175-
| 'retrievePaymentIntent'
176-
| 'createPaymentIntent'
177-
| 'setOrderPaymentIntent'
178-
| 'unknown' = 'unknown';
172+
let phase: 'ensureStripePaymentIntentForOrder' | 'unknown' = 'unknown';
179173

180174
try {
181-
if (existingPi) {
182-
phase = 'retrievePaymentIntent';
183-
const retrieved = await retrievePaymentIntent(existingPi);
184-
clientSecret = retrieved.clientSecret;
185-
} else {
186-
phase = 'createPaymentIntent';
187-
const created = await createPaymentIntent({
188-
amount: order.totalAmountMinor,
189-
currency: order.currency,
190-
orderId: order.id,
191-
idempotencyKey: `pi:${order.id}`,
192-
});
193-
194-
phase = 'setOrderPaymentIntent';
195-
await setOrderPaymentIntent({
196-
orderId: order.id,
197-
paymentIntentId: created.paymentIntentId,
198-
});
175+
phase = 'ensureStripePaymentIntentForOrder';
176+
const ensured = await ensureStripePaymentIntentForOrder({
177+
orderId: order.id,
178+
existingPaymentIntentId: existingPi || null,
179+
});
199180

200-
clientSecret = created.clientSecret;
201-
}
181+
clientSecret = ensured.clientSecret;
202182
} catch (error) {
203183
logError('payment_page_failed', error, {
204184
orderId: order.id,
205185
existingPi,
206186
phase,
207187
});
208-
209-
// Leave clientSecret empty -> UI shows "Payment cannot be initialized"
210188
}
211189
}
212190

@@ -223,13 +201,13 @@ export default async function PaymentPage(props: PaymentPageProps) {
223201
aria-label="Next steps"
224202
>
225203
<Link
226-
href={`/shop/checkout/success?orderId=${order.id}${cc}`}
204+
href={`${shopBase}/checkout/success?orderId=${order.id}${cc}`}
227205
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"
228206
>
229207
View confirmation
230208
</Link>
231209
<Link
232-
href="/shop/products"
210+
href={`${shopBase}/products`}
233211
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"
234212
>
235213
Continue shopping

frontend/app/api/shop/admin/products/[id]/route.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ import {
77
AdminUnauthorizedError,
88
requireAdminApi,
99
} from '@/lib/auth/admin';
10+
import {
11+
InvalidPayloadError,
12+
SlugConflictError,
13+
PriceConfigError,
14+
} from '@/lib/services/errors';
1015

1116
import { parseAdminProductForm } from '@/lib/admin/parseAdminProductForm';
1217
import { logError } from '@/lib/logging';
13-
import { InvalidPayloadError, SlugConflictError } from '@/lib/services/errors';
1418
import {
1519
deleteProduct,
1620
getAdminProductByIdWithPrices,
@@ -217,6 +221,19 @@ export async function PATCH(
217221
} catch (error) {
218222
logError('Failed to update product', error);
219223

224+
if (error instanceof PriceConfigError) {
225+
return NextResponse.json(
226+
{
227+
error: error.message,
228+
code: error.code, // PRICE_CONFIG_ERROR
229+
productId: error.productId,
230+
currency: error.currency,
231+
field: 'prices',
232+
},
233+
{ status: 400 }
234+
);
235+
}
236+
220237
if (error instanceof InvalidPayloadError) {
221238
const anyErr = error as any;
222239
return NextResponse.json(

0 commit comments

Comments
 (0)