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
280 changes: 160 additions & 120 deletions frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx

Large diffs are not rendered by default.

74 changes: 19 additions & 55 deletions frontend/app/[locale]/shop/cart/CartPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import {
type CheckoutDeliveryMethodCode,
type ShippingAvailabilityReasonCode,
} from '@/lib/services/shop/shipping/checkout-payload';
import { resolveStandardStorefrontShippingCountry } from '@/lib/shop/commercial-policy';
import { formatMoney } from '@/lib/shop/currency';
import { generateIdempotencyKey } from '@/lib/shop/idempotency';
import { localeToCountry } from '@/lib/shop/locale';
import {
SHOP_CART_CARD,
SHOP_CART_FIELD,
Expand All @@ -42,6 +42,11 @@ import {
} from '@/lib/shop/ui-classes';
import { cn } from '@/lib/utils';

import {
resolveDefaultMethodForProvider,
resolveInitialProvider,
} from './provider-policy';

type Props = {
stripeEnabled: boolean;
monobankEnabled: boolean;
Expand Down Expand Up @@ -81,31 +86,6 @@ type ShippingWarehouse = {
isPostMachine: boolean;
};

function resolveInitialProvider(args: {
stripeEnabled: boolean;
monobankEnabled: boolean;
currency: string | null | undefined;
}): CheckoutProvider {
const isUah = args.currency === 'UAH';
const canUseStripe = args.stripeEnabled;
const canUseMonobank = args.monobankEnabled && isUah;

if (canUseMonobank) return 'monobank';
if (canUseStripe) return 'stripe';
return 'stripe';
}

function resolveDefaultMethodForProvider(args: {
provider: CheckoutProvider;
currency: string | null | undefined;
}): CheckoutPaymentMethod | null {
if (args.provider === 'stripe') return 'stripe_card';
if (args.provider === 'monobank') {
return args.currency === 'UAH' ? 'monobank_invoice' : null;
}
return null;
}

function normalizeLookupValue(value: string): string {
return value.trim().toLocaleLowerCase();
}
Expand Down Expand Up @@ -295,36 +275,27 @@ function isWarehouseMethod(
function resolveShippingMethodCardCopy(args: {
methodCode: CheckoutDeliveryMethodCode;
fallbackTitle: string;
safeT: (key: string, fallback: string) => string;
translate: (key: string) => string;
}): { title: string; description: string } {
const { methodCode, fallbackTitle, safeT } = args;
const { methodCode, fallbackTitle, translate } = args;

switch (methodCode) {
case 'NP_WAREHOUSE':
return {
title: safeT('delivery.methodCards.warehouse.title', fallbackTitle),
description: safeT(
'delivery.methodCards.warehouse.description',
'Pick up at a Nova Poshta branch'
),
title: translate('delivery.methodCards.warehouse.title'),
description: translate('delivery.methodCards.warehouse.description'),
};

case 'NP_LOCKER':
return {
title: safeT('delivery.methodCards.locker.title', fallbackTitle),
description: safeT(
'delivery.methodCards.locker.description',
'Pick up from a Nova Poshta parcel locker'
),
title: translate('delivery.methodCards.locker.title'),
description: translate('delivery.methodCards.locker.description'),
};

case 'NP_COURIER':
return {
title: safeT('delivery.methodCards.courier.title', fallbackTitle),
description: safeT(
'delivery.methodCards.courier.description',
'Nova Poshta door-to-door delivery'
),
title: translate('delivery.methodCards.courier.title'),
description: translate('delivery.methodCards.courier.description'),
};

default:
Expand Down Expand Up @@ -502,12 +473,11 @@ export default function CartPage({
const params = useParams<{ locale?: string }>();
const locale = params.locale ?? 'en';
const shopBase = '/shop';
const isUahCheckout = cart.summary.currency === 'UAH';
const canUseStripe = stripeEnabled;
const canUseMonobank = monobankEnabled && isUahCheckout;
const canUseMonobank = monobankEnabled;
const canUseMonobankGooglePay = canUseMonobank && monobankGooglePayEnabled;
const hasSelectableProvider = canUseStripe || canUseMonobank;
const country = localeToCountry(locale);
const country = resolveStandardStorefrontShippingCountry();

const shippingUnavailableHardBlock =
shippingReasonCode === 'SHOP_SHIPPING_DISABLED' ||
Expand Down Expand Up @@ -1121,11 +1091,7 @@ export default function CartPage({
}

if (selectedProvider === 'monobank' && !canUseMonobank) {
setCheckoutError(
monobankEnabled
? t('checkout.paymentMethod.monobankUahOnlyHint')
: t('checkout.paymentMethod.monobankUnavailable')
);
setCheckoutError(t('checkout.paymentMethod.monobankUnavailable'));
return;
}

Expand Down Expand Up @@ -1747,7 +1713,7 @@ export default function CartPage({
const cardCopy = resolveShippingMethodCardCopy({
methodCode: method.methodCode,
fallbackTitle: method.title,
safeT,
translate: key => t(key as any),
});

return (
Expand Down Expand Up @@ -2238,9 +2204,7 @@ export default function CartPage({

{!canUseMonobank ? (
<p className="text-muted-foreground mt-3 text-xs">
{monobankEnabled
? t('checkout.paymentMethod.monobankUahOnlyHint')
: t('checkout.paymentMethod.monobankUnavailable')}
{t('checkout.paymentMethod.monobankUnavailable')}
</p>
) : null}

Expand Down
33 changes: 5 additions & 28 deletions frontend/app/[locale]/shop/cart/capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,14 @@
import { isMonobankEnabled } from '@/lib/env/monobank';
import { readServerEnv } from '@/lib/env/server-env';
import { isPaymentsEnabled as isStripePaymentsEnabled } from '@/lib/env/stripe';

function isFlagEnabled(value: string | undefined): boolean {
return (value ?? '').trim() === 'true';
}
import { resolveStandardStorefrontProviderCapabilities } from '@/lib/shop/commercial-policy.server';

export function resolveStripeCheckoutEnabled(): boolean {
try {
return isStripePaymentsEnabled({
requirePublishableKey: true,
});
} catch {
return false;
}
return resolveStandardStorefrontProviderCapabilities().stripeCheckoutEnabled;
}

export function resolveMonobankCheckoutEnabled(): boolean {
const paymentsEnabled = isFlagEnabled(readServerEnv('PAYMENTS_ENABLED'));
if (!paymentsEnabled) return false;

try {
return isMonobankEnabled();
} catch {
return false;
}
return resolveStandardStorefrontProviderCapabilities().monobankCheckoutEnabled;
}

export function resolveMonobankGooglePayEnabled(): boolean {
if (!resolveMonobankCheckoutEnabled()) return false;

const raw = (readServerEnv('SHOP_MONOBANK_GPAY_ENABLED') ?? '')
.trim()
.toLowerCase();
return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on';
return resolveStandardStorefrontProviderCapabilities()
.monobankGooglePayEnabled;
}
33 changes: 33 additions & 0 deletions frontend/app/[locale]/shop/cart/provider-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
type CheckoutProvider = 'stripe' | 'monobank';
type CheckoutPaymentMethod =
| 'stripe_card'
| 'monobank_invoice'
| 'monobank_google_pay';

export function resolveInitialProvider(args: {
stripeEnabled: boolean;
monobankEnabled: boolean;
currency: string | null | undefined;
}): CheckoutProvider {
void args.currency;

const canUseStripe = args.stripeEnabled;
const canUseMonobank = args.monobankEnabled;

if (canUseMonobank) return 'monobank';
if (canUseStripe) return 'stripe';
return 'stripe';
}

export function resolveDefaultMethodForProvider(args: {
provider: CheckoutProvider;
currency: string | null | undefined;
}): CheckoutPaymentMethod | null {
void args.currency;

if (args.provider === 'stripe') return 'stripe_card';
if (args.provider === 'monobank') {
return 'monobank_invoice';
}
return null;
}
52 changes: 34 additions & 18 deletions frontend/app/[locale]/shop/checkout/error/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/routing';
import { OrderNotFoundError } from '@/lib/services/errors';
import { getOrderSummary } from '@/lib/services/orders';
import { formatMoney, resolveCurrencyFromLocale } from '@/lib/shop/currency';
import { resolveCheckoutDisplayCurrency } from '@/lib/shop/checkout-display-currency';
import { formatMoney } from '@/lib/shop/currency';
import {
SHOP_CTA_BASE,
SHOP_CTA_INSET,
Expand All @@ -19,17 +20,32 @@ import {
import { cn } from '@/lib/utils';
import { orderIdParamSchema } from '@/lib/validation/shop';

export const metadata: Metadata = {
title: 'Checkout Error | DevLovers',
description:
'We couldn’t complete the checkout. Try again or contact support.',
};
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({
locale,
namespace: 'shop.checkout.errorPage',
});

return {
title: t('metaTitle'),
description: t('metaDescription'),
};
}

export const dynamic = 'force-dynamic';
export const revalidate = 0;

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

function isPromise<T>(value: unknown): value is Promise<T> {
return !!value && typeof (value as any).then === 'function';
}

function getStringParam(params: SearchParams | undefined, key: string): string {
if (!params) return '';
const raw = params[key];
Expand Down Expand Up @@ -75,10 +91,9 @@ export default async function CheckoutErrorPage({
const { locale } = await params;
const t = await getTranslations('shop.checkout');

const resolvedSearchParams: SearchParams | undefined =
searchParams && typeof (searchParams as any).then === 'function'
? await (searchParams as Promise<SearchParams>)
: (searchParams as SearchParams | undefined);
const resolvedSearchParams = isPromise<SearchParams>(searchParams)
? await searchParams
: searchParams;

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

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

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

const totalMinor =
typeof (order as any).totalAmountMinor === 'number'
? (order as any).totalAmountMinor
: null;
typeof order.totalAmountMinor === 'number' ? order.totalAmountMinor : null;

const currency = (order as any).currency ?? resolveCurrencyFromLocale(locale);
const currency = resolveCheckoutDisplayCurrency(order.currency);

return (
<main
Expand All @@ -247,7 +260,7 @@ export default async function CheckoutErrorPage({

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

<nav className="mt-6 flex flex-wrap gap-3" aria-label="Next steps">
<nav
className="mt-6 flex flex-wrap gap-3"
aria-label={t('errorPage.nextSteps')}
>
<Link href="/shop/cart" className={SHOP_OUTLINE_BTN}>
{t('actions.backToCart')}
</Link>
Expand Down
Loading
Loading