Skip to content

Commit b1a61ba

Browse files
(SP: 3) [SHOP] CP-01 commercial policy refactor: decouple locale, enforce UAH storefront, align checkout, and admin pricing cleanup (#441)
1 parent 716b4df commit b1a61ba

File tree

55 files changed

+2611
-499
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+2611
-499
lines changed

frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx

Lines changed: 160 additions & 120 deletions
Large diffs are not rendered by default.

frontend/app/[locale]/shop/cart/CartPageClient.tsx

Lines changed: 19 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ import {
1414
type CheckoutDeliveryMethodCode,
1515
type ShippingAvailabilityReasonCode,
1616
} from '@/lib/services/shop/shipping/checkout-payload';
17+
import { resolveStandardStorefrontShippingCountry } from '@/lib/shop/commercial-policy';
1718
import { formatMoney } from '@/lib/shop/currency';
1819
import { generateIdempotencyKey } from '@/lib/shop/idempotency';
19-
import { localeToCountry } from '@/lib/shop/locale';
2020
import {
2121
SHOP_CART_CARD,
2222
SHOP_CART_FIELD,
@@ -42,6 +42,11 @@ import {
4242
} from '@/lib/shop/ui-classes';
4343
import { cn } from '@/lib/utils';
4444

45+
import {
46+
resolveDefaultMethodForProvider,
47+
resolveInitialProvider,
48+
} from './provider-policy';
49+
4550
type Props = {
4651
stripeEnabled: boolean;
4752
monobankEnabled: boolean;
@@ -81,31 +86,6 @@ type ShippingWarehouse = {
8186
isPostMachine: boolean;
8287
};
8388

84-
function resolveInitialProvider(args: {
85-
stripeEnabled: boolean;
86-
monobankEnabled: boolean;
87-
currency: string | null | undefined;
88-
}): CheckoutProvider {
89-
const isUah = args.currency === 'UAH';
90-
const canUseStripe = args.stripeEnabled;
91-
const canUseMonobank = args.monobankEnabled && isUah;
92-
93-
if (canUseMonobank) return 'monobank';
94-
if (canUseStripe) return 'stripe';
95-
return 'stripe';
96-
}
97-
98-
function resolveDefaultMethodForProvider(args: {
99-
provider: CheckoutProvider;
100-
currency: string | null | undefined;
101-
}): CheckoutPaymentMethod | null {
102-
if (args.provider === 'stripe') return 'stripe_card';
103-
if (args.provider === 'monobank') {
104-
return args.currency === 'UAH' ? 'monobank_invoice' : null;
105-
}
106-
return null;
107-
}
108-
10989
function normalizeLookupValue(value: string): string {
11090
return value.trim().toLocaleLowerCase();
11191
}
@@ -295,36 +275,27 @@ function isWarehouseMethod(
295275
function resolveShippingMethodCardCopy(args: {
296276
methodCode: CheckoutDeliveryMethodCode;
297277
fallbackTitle: string;
298-
safeT: (key: string, fallback: string) => string;
278+
translate: (key: string) => string;
299279
}): { title: string; description: string } {
300-
const { methodCode, fallbackTitle, safeT } = args;
280+
const { methodCode, fallbackTitle, translate } = args;
301281

302282
switch (methodCode) {
303283
case 'NP_WAREHOUSE':
304284
return {
305-
title: safeT('delivery.methodCards.warehouse.title', fallbackTitle),
306-
description: safeT(
307-
'delivery.methodCards.warehouse.description',
308-
'Pick up at a Nova Poshta branch'
309-
),
285+
title: translate('delivery.methodCards.warehouse.title'),
286+
description: translate('delivery.methodCards.warehouse.description'),
310287
};
311288

312289
case 'NP_LOCKER':
313290
return {
314-
title: safeT('delivery.methodCards.locker.title', fallbackTitle),
315-
description: safeT(
316-
'delivery.methodCards.locker.description',
317-
'Pick up from a Nova Poshta parcel locker'
318-
),
291+
title: translate('delivery.methodCards.locker.title'),
292+
description: translate('delivery.methodCards.locker.description'),
319293
};
320294

321295
case 'NP_COURIER':
322296
return {
323-
title: safeT('delivery.methodCards.courier.title', fallbackTitle),
324-
description: safeT(
325-
'delivery.methodCards.courier.description',
326-
'Nova Poshta door-to-door delivery'
327-
),
297+
title: translate('delivery.methodCards.courier.title'),
298+
description: translate('delivery.methodCards.courier.description'),
328299
};
329300

330301
default:
@@ -502,12 +473,11 @@ export default function CartPage({
502473
const params = useParams<{ locale?: string }>();
503474
const locale = params.locale ?? 'en';
504475
const shopBase = '/shop';
505-
const isUahCheckout = cart.summary.currency === 'UAH';
506476
const canUseStripe = stripeEnabled;
507-
const canUseMonobank = monobankEnabled && isUahCheckout;
477+
const canUseMonobank = monobankEnabled;
508478
const canUseMonobankGooglePay = canUseMonobank && monobankGooglePayEnabled;
509479
const hasSelectableProvider = canUseStripe || canUseMonobank;
510-
const country = localeToCountry(locale);
480+
const country = resolveStandardStorefrontShippingCountry();
511481

512482
const shippingUnavailableHardBlock =
513483
shippingReasonCode === 'SHOP_SHIPPING_DISABLED' ||
@@ -1121,11 +1091,7 @@ export default function CartPage({
11211091
}
11221092

11231093
if (selectedProvider === 'monobank' && !canUseMonobank) {
1124-
setCheckoutError(
1125-
monobankEnabled
1126-
? t('checkout.paymentMethod.monobankUahOnlyHint')
1127-
: t('checkout.paymentMethod.monobankUnavailable')
1128-
);
1094+
setCheckoutError(t('checkout.paymentMethod.monobankUnavailable'));
11291095
return;
11301096
}
11311097

@@ -1747,7 +1713,7 @@ export default function CartPage({
17471713
const cardCopy = resolveShippingMethodCardCopy({
17481714
methodCode: method.methodCode,
17491715
fallbackTitle: method.title,
1750-
safeT,
1716+
translate: key => t(key as any),
17511717
});
17521718

17531719
return (
@@ -2238,9 +2204,7 @@ export default function CartPage({
22382204

22392205
{!canUseMonobank ? (
22402206
<p className="text-muted-foreground mt-3 text-xs">
2241-
{monobankEnabled
2242-
? t('checkout.paymentMethod.monobankUahOnlyHint')
2243-
: t('checkout.paymentMethod.monobankUnavailable')}
2207+
{t('checkout.paymentMethod.monobankUnavailable')}
22442208
</p>
22452209
) : null}
22462210

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,14 @@
1-
import { isMonobankEnabled } from '@/lib/env/monobank';
2-
import { readServerEnv } from '@/lib/env/server-env';
3-
import { isPaymentsEnabled as isStripePaymentsEnabled } from '@/lib/env/stripe';
4-
5-
function isFlagEnabled(value: string | undefined): boolean {
6-
return (value ?? '').trim() === 'true';
7-
}
1+
import { resolveStandardStorefrontProviderCapabilities } from '@/lib/shop/commercial-policy.server';
82

93
export function resolveStripeCheckoutEnabled(): boolean {
10-
try {
11-
return isStripePaymentsEnabled({
12-
requirePublishableKey: true,
13-
});
14-
} catch {
15-
return false;
16-
}
4+
return resolveStandardStorefrontProviderCapabilities().stripeCheckoutEnabled;
175
}
186

197
export function resolveMonobankCheckoutEnabled(): boolean {
20-
const paymentsEnabled = isFlagEnabled(readServerEnv('PAYMENTS_ENABLED'));
21-
if (!paymentsEnabled) return false;
22-
23-
try {
24-
return isMonobankEnabled();
25-
} catch {
26-
return false;
27-
}
8+
return resolveStandardStorefrontProviderCapabilities().monobankCheckoutEnabled;
289
}
2910

3011
export function resolveMonobankGooglePayEnabled(): boolean {
31-
if (!resolveMonobankCheckoutEnabled()) return false;
32-
33-
const raw = (readServerEnv('SHOP_MONOBANK_GPAY_ENABLED') ?? '')
34-
.trim()
35-
.toLowerCase();
36-
return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on';
12+
return resolveStandardStorefrontProviderCapabilities()
13+
.monobankGooglePayEnabled;
3714
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
type CheckoutProvider = 'stripe' | 'monobank';
2+
type CheckoutPaymentMethod =
3+
| 'stripe_card'
4+
| 'monobank_invoice'
5+
| 'monobank_google_pay';
6+
7+
export function resolveInitialProvider(args: {
8+
stripeEnabled: boolean;
9+
monobankEnabled: boolean;
10+
currency: string | null | undefined;
11+
}): CheckoutProvider {
12+
void args.currency;
13+
14+
const canUseStripe = args.stripeEnabled;
15+
const canUseMonobank = args.monobankEnabled;
16+
17+
if (canUseMonobank) return 'monobank';
18+
if (canUseStripe) return 'stripe';
19+
return 'stripe';
20+
}
21+
22+
export function resolveDefaultMethodForProvider(args: {
23+
provider: CheckoutProvider;
24+
currency: string | null | undefined;
25+
}): CheckoutPaymentMethod | null {
26+
void args.currency;
27+
28+
if (args.provider === 'stripe') return 'stripe_card';
29+
if (args.provider === 'monobank') {
30+
return 'monobank_invoice';
31+
}
32+
return null;
33+
}

frontend/app/[locale]/shop/checkout/error/page.tsx

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { getTranslations } from 'next-intl/server';
44
import { Link } from '@/i18n/routing';
55
import { OrderNotFoundError } from '@/lib/services/errors';
66
import { getOrderSummary } from '@/lib/services/orders';
7-
import { formatMoney, resolveCurrencyFromLocale } from '@/lib/shop/currency';
7+
import { resolveCheckoutDisplayCurrency } from '@/lib/shop/checkout-display-currency';
8+
import { formatMoney } from '@/lib/shop/currency';
89
import {
910
SHOP_CTA_BASE,
1011
SHOP_CTA_INSET,
@@ -19,17 +20,32 @@ import {
1920
import { cn } from '@/lib/utils';
2021
import { orderIdParamSchema } from '@/lib/validation/shop';
2122

22-
export const metadata: Metadata = {
23-
title: 'Checkout Error | DevLovers',
24-
description:
25-
'We couldn’t complete the checkout. Try again or contact support.',
26-
};
23+
export async function generateMetadata({
24+
params,
25+
}: {
26+
params: Promise<{ locale: string }>;
27+
}): Promise<Metadata> {
28+
const { locale } = await params;
29+
const t = await getTranslations({
30+
locale,
31+
namespace: 'shop.checkout.errorPage',
32+
});
33+
34+
return {
35+
title: t('metaTitle'),
36+
description: t('metaDescription'),
37+
};
38+
}
2739

2840
export const dynamic = 'force-dynamic';
2941
export const revalidate = 0;
3042

3143
type SearchParams = Record<string, string | string[] | undefined>;
3244

45+
function isPromise<T>(value: unknown): value is Promise<T> {
46+
return !!value && typeof (value as any).then === 'function';
47+
}
48+
3349
function getStringParam(params: SearchParams | undefined, key: string): string {
3450
if (!params) return '';
3551
const raw = params[key];
@@ -75,10 +91,9 @@ export default async function CheckoutErrorPage({
7591
const { locale } = await params;
7692
const t = await getTranslations('shop.checkout');
7793

78-
const resolvedSearchParams: SearchParams | undefined =
79-
searchParams && typeof (searchParams as any).then === 'function'
80-
? await (searchParams as Promise<SearchParams>)
81-
: (searchParams as SearchParams | undefined);
94+
const resolvedSearchParams = isPromise<SearchParams>(searchParams)
95+
? await searchParams
96+
: searchParams;
8297

8398
const orderId = parseOrderId(resolvedSearchParams);
8499
const statusToken = parseStatusToken(resolvedSearchParams);
@@ -102,7 +117,7 @@ export default async function CheckoutErrorPage({
102117

103118
<nav
104119
className="mt-6 flex flex-wrap justify-center gap-3"
105-
aria-label="Checkout navigation"
120+
aria-label={t('errorPage.checkoutNavigation')}
106121
>
107122
<Link href="/shop/cart" className={SHOP_OUTLINE_BTN}>
108123
{t('actions.backToCart')}
@@ -161,7 +176,7 @@ export default async function CheckoutErrorPage({
161176

162177
<nav
163178
className="mt-6 flex flex-wrap justify-center gap-3"
164-
aria-label="Checkout navigation"
179+
aria-label={t('errorPage.checkoutNavigation')}
165180
>
166181
<Link href="/shop/cart" className={SHOP_OUTLINE_BTN}>
167182
{t('actions.backToCart')}
@@ -219,11 +234,9 @@ export default async function CheckoutErrorPage({
219234
const isFailed = order.paymentStatus === 'failed';
220235

221236
const totalMinor =
222-
typeof (order as any).totalAmountMinor === 'number'
223-
? (order as any).totalAmountMinor
224-
: null;
237+
typeof order.totalAmountMinor === 'number' ? order.totalAmountMinor : null;
225238

226-
const currency = (order as any).currency ?? resolveCurrencyFromLocale(locale);
239+
const currency = resolveCheckoutDisplayCurrency(order.currency);
227240

228241
return (
229242
<main
@@ -247,7 +260,7 @@ export default async function CheckoutErrorPage({
247260

248261
<section
249262
className="border-border bg-muted/30 text-foreground mt-6 rounded-md border p-4 text-sm"
250-
aria-label="Order details"
263+
aria-label={t('errorPage.orderDetails')}
251264
>
252265
<dl className="space-y-2">
253266
<div className="flex items-center justify-between gap-4">
@@ -277,7 +290,10 @@ export default async function CheckoutErrorPage({
277290
</dl>
278291
</section>
279292

280-
<nav className="mt-6 flex flex-wrap gap-3" aria-label="Next steps">
293+
<nav
294+
className="mt-6 flex flex-wrap gap-3"
295+
aria-label={t('errorPage.nextSteps')}
296+
>
281297
<Link href="/shop/cart" className={SHOP_OUTLINE_BTN}>
282298
{t('actions.backToCart')}
283299
</Link>

0 commit comments

Comments
 (0)