Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
143 changes: 137 additions & 6 deletions frontend/app/[locale]/shop/cart/CartPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Loader2, Minus, Plus, ShoppingBag, Trash2 } from 'lucide-react';
import Image from 'next/image';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useEffect, useState } from 'react';

import { useCart } from '@/components/shop/CartProvider';
import { Link, useRouter } from '@/i18n/routing';
Expand Down Expand Up @@ -54,18 +54,61 @@ const SHOP_HERO_CTA = cn(
'shadow-[var(--shop-hero-btn-shadow)] hover:shadow-[var(--shop-hero-btn-shadow-hover)]'
);

export default function CartPage() {
type Props = {
stripeEnabled: boolean;
monobankEnabled: boolean;
};

type CheckoutProvider = 'stripe' | 'monobank';

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 (canUseStripe) return 'stripe';
if (canUseMonobank) return 'monobank';
return 'stripe';
}

export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
const { cart, updateQuantity, removeFromCart } = useCart();
const router = useRouter();
const t = useTranslations('shop.cart');
const tColors = useTranslations('shop.catalog.colors');
const [isCheckingOut, setIsCheckingOut] = useState(false);
const [checkoutError, setCheckoutError] = useState<string | null>(null);
const [createdOrderId, setCreatedOrderId] = useState<string | null>(null);
const [selectedProvider, setSelectedProvider] = useState<CheckoutProvider>(
() =>
resolveInitialProvider({
stripeEnabled,
monobankEnabled,
currency: cart?.summary?.currency,
})
);

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 hasSelectableProvider = canUseStripe || canUseMonobank;

useEffect(() => {
if (selectedProvider === 'stripe' && !canUseStripe && canUseMonobank) {
setSelectedProvider('monobank');
return;
}
if (selectedProvider === 'monobank' && !canUseMonobank && canUseStripe) {
setSelectedProvider('stripe');
}
}, [canUseMonobank, canUseStripe, selectedProvider]);

const translateColor = (color: string | null | undefined): string | null => {
if (!color) return null;
Expand All @@ -78,6 +121,23 @@ export default function CartPage() {
};

async function handleCheckout() {
if (!hasSelectableProvider) {
setCheckoutError(t('checkout.paymentMethod.noAvailable'));
return;
}
if (selectedProvider === 'stripe' && !canUseStripe) {
setCheckoutError(t('checkout.paymentMethod.noAvailable'));
return;
}
if (selectedProvider === 'monobank' && !canUseMonobank) {
setCheckoutError(
monobankEnabled
? t('checkout.paymentMethod.monobankUahOnlyHint')
: t('checkout.paymentMethod.monobankUnavailable')
);
return;
}

setCheckoutError(null);
setCreatedOrderId(null);
setIsCheckingOut(true);
Expand All @@ -92,6 +152,7 @@ export default function CartPage() {
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
paymentProvider: selectedProvider,
items: cart.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
Expand All @@ -109,13 +170,13 @@ export default function CartPage() {
? data.message
: typeof data?.error === 'string'
? data.error
: 'Unable to start checkout right now.';
: t('checkout.errors.startFailed');
setCheckoutError(message);
return;
}

if (!data?.orderId) {
setCheckoutError('Unexpected checkout response.');
setCheckoutError(t('checkout.errors.unexpectedResponse'));
return;
}

Expand All @@ -125,6 +186,10 @@ export default function CartPage() {
data.clientSecret.trim().length > 0
? data.clientSecret
: null;
const monobankPageUrl: string | null =
typeof data.pageUrl === 'string' && data.pageUrl.trim().length > 0
? data.pageUrl
: null;

const orderId = String(data.orderId);
setCreatedOrderId(orderId);
Expand All @@ -137,6 +202,14 @@ export default function CartPage() {
);
return;
}
if (paymentProvider === 'monobank' && monobankPageUrl) {
window.location.assign(monobankPageUrl);
return;
}
if (paymentProvider === 'monobank' && !monobankPageUrl) {
setCheckoutError(t('checkout.errors.unexpectedResponse'));
return;
}

const paymentsDisabledFlag =
paymentProvider !== 'stripe' || !clientSecret
Expand All @@ -149,7 +222,7 @@ export default function CartPage() {
)}&clearCart=1${paymentsDisabledFlag}`
);
} catch {
setCheckoutError('Unable to start checkout right now.');
setCheckoutError(t('checkout.errors.startFailed'));
} finally {
setIsCheckingOut(false);
}
Expand Down Expand Up @@ -385,11 +458,69 @@ export default function CartPage() {
</div>
</div>

<fieldset className="border-border mt-6 rounded-md border p-4">
<legend className="text-foreground px-1 text-sm font-semibold">
{t('checkout.paymentMethod.label')}
</legend>

<div className="mt-3 space-y-2">
{canUseStripe ? (
<label className="border-border flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2">
<input
type="radio"
name="payment-provider"
value="stripe"
checked={selectedProvider === 'stripe'}
onChange={() => setSelectedProvider('stripe')}
className="h-4 w-4"
/>
<span className="text-sm font-medium">
{t('checkout.paymentMethod.stripe')}
</span>
</label>
) : null}

<label
className={cn(
'border-border flex items-center gap-2 rounded-md border px-3 py-2',
canUseMonobank ? 'cursor-pointer' : 'opacity-60'
)}
>
<input
type="radio"
name="payment-provider"
value="monobank"
checked={selectedProvider === 'monobank'}
onChange={() => setSelectedProvider('monobank')}
disabled={!canUseMonobank}
className="h-4 w-4"
/>
<span className="text-sm font-medium">
{t('checkout.paymentMethod.monobank')}
</span>
</label>

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

{!hasSelectableProvider ? (
<p className="text-destructive text-xs" role="status">
{t('checkout.paymentMethod.noAvailable')}
</p>
) : null}
</div>
</fieldset>

<div className="mt-6 space-y-3">
<button
type="button"
onClick={handleCheckout}
disabled={isCheckingOut}
disabled={isCheckingOut || !hasSelectableProvider}
className={SHOP_HERO_CTA}
aria-busy={isCheckingOut}
>
Expand Down
33 changes: 32 additions & 1 deletion frontend/app/[locale]/shop/cart/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
import type { Metadata } from 'next';

import { isMonobankEnabled } from '@/lib/env/monobank';

import CartPageClient from './CartPageClient';

export const metadata: Metadata = {
title: 'Cart | DevLovers',
description: 'Review items in your cart and proceed to checkout.',
};

function isFlagEnabled(value: string | undefined): boolean {
return (value ?? '').trim() === 'true';
}

function resolveStripeCheckoutEnabled(): boolean {
const paymentsEnabled = isFlagEnabled(process.env.PAYMENTS_ENABLED);
const stripeFlag = (process.env.STRIPE_PAYMENTS_ENABLED ?? '').trim();

return (
paymentsEnabled && (stripeFlag.length > 0 ? stripeFlag === 'true' : true)
);
}

function resolveMonobankCheckoutEnabled(): boolean {
const paymentsEnabled = isFlagEnabled(process.env.PAYMENTS_ENABLED);
if (!paymentsEnabled) return false;

try {
return isMonobankEnabled();
} catch {
return false;
}
}

export default function CartPage() {
return <CartPageClient />;
return (
<CartPageClient
stripeEnabled={resolveStripeCheckoutEnabled()}
monobankEnabled={resolveMonobankCheckoutEnabled()}
/>
);
}
Loading