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
4 changes: 3 additions & 1 deletion frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ SHOP_SHIPPING_NP_WAREHOUSE_AMOUNT_MINOR=
SHOP_SHIPPING_NP_LOCKER_AMOUNT_MINOR=
SHOP_SHIPPING_NP_COURIER_AMOUNT_MINOR=

# Required Nova Poshta provider config when shipping is enabled
# Required Nova Poshta sender/provider config when shipping is enabled.
# Some sender fields are also reused by public seller-information surfaces.
# (SHOP_SHIPPING_ENABLED=1 and SHOP_SHIPPING_NP_ENABLED=1).
# In production-like runtime, invalid or placeholder config must fail closed.
NP_API_BASE=
Expand All @@ -119,6 +120,7 @@ NP_SENDER_CONTACT_REF=
NP_SENDER_NAME=
NP_SENDER_PHONE=
NP_SENDER_REF=
NP_SENDER_EDRPOU=

# Optional Nova Poshta runtime tuning.
NP_MAX_RETRIES=
Expand Down
33 changes: 33 additions & 0 deletions frontend/app/[locale]/delivery-policy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getTranslations } from 'next-intl/server';

import DeliveryPolicyContent, {
DELIVERY_POLICY_LAST_UPDATED,
} from '@/components/legal/DeliveryPolicyContent';
import LegalPageShell from '@/components/legal/LegalPageShell';

export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'legal.delivery' });

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

export default async function DeliveryPolicyPage() {
const t = await getTranslations('legal.delivery');

return (
<LegalPageShell
title={t('title')}
lastUpdated={DELIVERY_POLICY_LAST_UPDATED}
>
<DeliveryPolicyContent />
</LegalPageShell>
);
}
33 changes: 33 additions & 0 deletions frontend/app/[locale]/payment-policy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getTranslations } from 'next-intl/server';

import LegalPageShell from '@/components/legal/LegalPageShell';
import PaymentPolicyContent, {
PAYMENT_POLICY_LAST_UPDATED,
} from '@/components/legal/PaymentPolicyContent';

export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'legal.payment' });

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

export default async function PaymentPolicyPage() {
const t = await getTranslations('legal.payment');

return (
<LegalPageShell
title={t('title')}
lastUpdated={PAYMENT_POLICY_LAST_UPDATED}
>
<PaymentPolicyContent />
</LegalPageShell>
);
}
33 changes: 33 additions & 0 deletions frontend/app/[locale]/returns-policy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getTranslations } from 'next-intl/server';

import LegalPageShell from '@/components/legal/LegalPageShell';
import ReturnsPolicyContent, {
RETURNS_POLICY_LAST_UPDATED,
} from '@/components/legal/ReturnsPolicyContent';

export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'legal.returns' });

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

export default async function ReturnsPolicyPage() {
const t = await getTranslations('legal.returns');

return (
<LegalPageShell
title={t('title')}
lastUpdated={RETURNS_POLICY_LAST_UPDATED}
>
<ReturnsPolicyContent />
</LegalPageShell>
);
}
33 changes: 33 additions & 0 deletions frontend/app/[locale]/seller-information/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getTranslations } from 'next-intl/server';

import LegalPageShell from '@/components/legal/LegalPageShell';
import SellerInformationContent, {
SELLER_INFORMATION_LAST_UPDATED,
} from '@/components/legal/SellerInformationContent';

export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'legal.seller' });

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

export default async function SellerInformationPage() {
const t = await getTranslations('legal.seller');

return (
<LegalPageShell
title={t('title')}
lastUpdated={SELLER_INFORMATION_LAST_UPDATED}
>
<SellerInformationContent />
</LegalPageShell>
);
}
74 changes: 74 additions & 0 deletions frontend/app/[locale]/shop/cart/CartPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ type Props = {
stripeEnabled: boolean;
monobankEnabled: boolean;
monobankGooglePayEnabled: boolean;
termsVersion: string;
privacyVersion: string;
};

type CheckoutProvider = 'stripe' | 'monobank';
Expand Down Expand Up @@ -411,6 +413,8 @@ export default function CartPage({
stripeEnabled,
monobankEnabled,
monobankGooglePayEnabled,
termsVersion,
privacyVersion,
}: Props) {
const { cart, updateQuantity, removeFromCart } = useCart();
const router = useRouter();
Expand Down Expand Up @@ -486,6 +490,10 @@ export default function CartPage({
const [recipientComment, setRecipientComment] = useState('');

const [deliveryUiError, setDeliveryUiError] = useState<string | null>(null);
const [legalConsentAccepted, setLegalConsentAccepted] = useState(false);
const [legalConsentUiError, setLegalConsentUiError] = useState<string | null>(
null
);

useEffect(() => {
setIsClientReady(true);
Expand Down Expand Up @@ -569,6 +577,7 @@ export default function CartPage({

const clearCheckoutUiErrors = () => {
setDeliveryUiError(null);
setLegalConsentUiError(null);
setCheckoutError(null);
};

Expand Down Expand Up @@ -1144,8 +1153,16 @@ export default function CartPage({
return;
}

if (!legalConsentAccepted) {
const message = t('checkout.consent.required');
setLegalConsentUiError(message);
setCheckoutError(message);
return;
}

setCheckoutError(null);
setDeliveryUiError(null);
setLegalConsentUiError(null);
setCreatedOrderId(null);
setCreatedOrderStatusToken(null);
setPaymentRecoveryUrl(null);
Expand Down Expand Up @@ -1203,6 +1220,12 @@ export default function CartPage({
selectedShippingQuote.quoteFingerprint,
}
: {}),
legalConsent: {
termsAccepted: true,
privacyAccepted: true,
termsVersion,
privacyVersion,
},
...(shippingPayloadResult?.ok
? {
shipping: shippingPayloadResult.shipping,
Expand Down Expand Up @@ -1359,6 +1382,7 @@ export default function CartPage({
const canPlaceOrder =
hasSelectableProvider &&
hasValidPaymentSelection &&
legalConsentAccepted &&
!shippingMethodsLoading &&
!shippingUnavailableHardBlock &&
(!shippingAvailable || !!selectedShippingMethod);
Expand Down Expand Up @@ -2325,6 +2349,56 @@ export default function CartPage({
</div>

<div className="border-border border-t p-5">
<div className="mb-4 rounded-2xl border border-gray-200/70 bg-gray-50/80 p-4 dark:border-neutral-800/70 dark:bg-neutral-900/70">
<label
htmlFor="checkout-legal-consent"
className="flex cursor-pointer items-start gap-3"
>
<input
id="checkout-legal-consent"
type="checkbox"
checked={legalConsentAccepted}
onChange={event => {
clearCheckoutUiErrors();
setLegalConsentAccepted(event.target.checked);
}}
aria-invalid={legalConsentUiError ? true : undefined}
className="mt-1 h-4 w-4 shrink-0 rounded border-gray-300"
/>
<span className="text-sm leading-6 text-slate-700 dark:text-slate-200">
{t('checkout.consent.prefix')}{' '}
<Link
href="/terms-of-service"
className={cn(
SHOP_LINK_BASE,
SHOP_LINK_XS,
SHOP_FOCUS
)}
>
{t('checkout.consent.termsLink')}
</Link>{' '}
{t('checkout.consent.and')}{' '}
<Link
href="/privacy-policy"
className={cn(
SHOP_LINK_BASE,
SHOP_LINK_XS,
SHOP_FOCUS
)}
>
{t('checkout.consent.privacyLink')}
</Link>
{t('checkout.consent.suffix')}
</span>
</label>

{legalConsentUiError ? (
<p className="text-destructive mt-2 text-xs" role="alert">
{legalConsentUiError}
</p>
) : null}
</div>

<button
type="button"
onClick={handleCheckout}
Expand Down
6 changes: 6 additions & 0 deletions frontend/app/[locale]/shop/cart/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Metadata } from 'next';

import { getShopLegalVersions } from '@/lib/env/shop-legal';

import {
resolveMonobankCheckoutEnabled,
resolveMonobankGooglePayEnabled,
Expand All @@ -13,11 +15,15 @@ export const metadata: Metadata = {
};

export default function CartPage() {
const legalVersions = getShopLegalVersions();

return (
<CartPageClient
stripeEnabled={resolveStripeCheckoutEnabled()}
monobankEnabled={resolveMonobankCheckoutEnabled()}
monobankGooglePayEnabled={resolveMonobankGooglePayEnabled()}
termsVersion={legalVersions.termsVersion}
privacyVersion={legalVersions.privacyVersion}
/>
);
}
30 changes: 25 additions & 5 deletions frontend/app/api/shop/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const EXPECTED_BUSINESS_ERROR_CODES = new Set([
'SHIPPING_METHOD_UNAVAILABLE',
'SHIPPING_CURRENCY_UNSUPPORTED',
'SHIPPING_AMOUNT_UNAVAILABLE',
'LEGAL_CONSENT_REQUIRED',
'TERMS_NOT_ACCEPTED',
'PRIVACY_NOT_ACCEPTED',
]);
Expand Down Expand Up @@ -218,6 +219,12 @@ function getErrorMessage(error: unknown, fallback: string): string {
return fallback;
}

function hasLegalConsentValidationIssue(issues: Array<{ path?: unknown[] }>) {
return issues.some(
issue => Array.isArray(issue.path) && issue.path[0] === 'legalConsent'
);
}

function isMonobankInvalidRequestError(error: unknown): boolean {
const code = getErrorCode(error);

Expand Down Expand Up @@ -352,9 +359,7 @@ function errorResponse(
return res;
}

function collectUnsupportedDiscountFields(
value: unknown
): string[] {
function collectUnsupportedDiscountFields(value: unknown): string[] {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return [];
}
Expand Down Expand Up @@ -1041,6 +1046,21 @@ export async function POST(request: NextRequest) {
const parsedPayload = checkoutPayloadSchema.safeParse(payloadForValidation);

if (!parsedPayload.success) {
if (hasLegalConsentValidationIssue(parsedPayload.error.issues ?? [])) {
logWarn('checkout_legal_consent_required', {
...meta,
code: 'LEGAL_CONSENT_REQUIRED',
issuesCount: parsedPayload.error.issues?.length ?? 0,
});

return errorResponse(
'LEGAL_CONSENT_REQUIRED',
'Explicit legal consent is required before checkout.',
400,
parsedPayload.error.format()
);
}

if (selectedProvider === 'monobank') {
logWarn('checkout_invalid_request', {
...meta,
Expand Down Expand Up @@ -1202,7 +1222,7 @@ export async function POST(request: NextRequest) {
locale,
country: country ?? null,
shipping: shipping ?? null,
legalConsent: legalConsent ?? null,
legalConsent,
pricingFingerprint,
shippingQuoteFingerprint,
requirePricingFingerprint: true,
Expand Down Expand Up @@ -1230,7 +1250,7 @@ export async function POST(request: NextRequest) {
locale,
country: country ?? null,
shipping: shipping ?? null,
legalConsent: legalConsent ?? null,
legalConsent,
pricingFingerprint,
shippingQuoteFingerprint,
requirePricingFingerprint: true,
Expand Down
Loading
Loading