diff --git a/frontend/.env.example b/frontend/.env.example index 24d111e8..44ee9b03 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -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= @@ -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= diff --git a/frontend/app/[locale]/delivery-policy/page.tsx b/frontend/app/[locale]/delivery-policy/page.tsx new file mode 100644 index 00000000..ee61f524 --- /dev/null +++ b/frontend/app/[locale]/delivery-policy/page.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/app/[locale]/payment-policy/page.tsx b/frontend/app/[locale]/payment-policy/page.tsx new file mode 100644 index 00000000..03cd65fb --- /dev/null +++ b/frontend/app/[locale]/payment-policy/page.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/app/[locale]/returns-policy/page.tsx b/frontend/app/[locale]/returns-policy/page.tsx new file mode 100644 index 00000000..c060c864 --- /dev/null +++ b/frontend/app/[locale]/returns-policy/page.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/app/[locale]/seller-information/page.tsx b/frontend/app/[locale]/seller-information/page.tsx new file mode 100644 index 00000000..37e32529 --- /dev/null +++ b/frontend/app/[locale]/seller-information/page.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx index d9a0e139..2d6d03f9 100644 --- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx +++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx @@ -46,6 +46,8 @@ type Props = { stripeEnabled: boolean; monobankEnabled: boolean; monobankGooglePayEnabled: boolean; + termsVersion: string; + privacyVersion: string; }; type CheckoutProvider = 'stripe' | 'monobank'; @@ -411,6 +413,8 @@ export default function CartPage({ stripeEnabled, monobankEnabled, monobankGooglePayEnabled, + termsVersion, + privacyVersion, }: Props) { const { cart, updateQuantity, removeFromCart } = useCart(); const router = useRouter(); @@ -486,6 +490,10 @@ export default function CartPage({ const [recipientComment, setRecipientComment] = useState(''); const [deliveryUiError, setDeliveryUiError] = useState(null); + const [legalConsentAccepted, setLegalConsentAccepted] = useState(false); + const [legalConsentUiError, setLegalConsentUiError] = useState( + null + ); useEffect(() => { setIsClientReady(true); @@ -569,6 +577,7 @@ export default function CartPage({ const clearCheckoutUiErrors = () => { setDeliveryUiError(null); + setLegalConsentUiError(null); setCheckoutError(null); }; @@ -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); @@ -1203,6 +1220,12 @@ export default function CartPage({ selectedShippingQuote.quoteFingerprint, } : {}), + legalConsent: { + termsAccepted: true, + privacyAccepted: true, + termsVersion, + privacyVersion, + }, ...(shippingPayloadResult?.ok ? { shipping: shippingPayloadResult.shipping, @@ -1359,6 +1382,7 @@ export default function CartPage({ const canPlaceOrder = hasSelectableProvider && hasValidPaymentSelection && + legalConsentAccepted && !shippingMethodsLoading && !shippingUnavailableHardBlock && (!shippingAvailable || !!selectedShippingMethod); @@ -2325,6 +2349,56 @@ export default function CartPage({
+
+ + + {legalConsentUiError ? ( +

+ {legalConsentUiError} +

+ ) : null} +
+ , +})); + +vi.mock('@/i18n/routing', () => ({ + Link: ({ + href, + children, + ...props + }: { + href: string; + children: ReactNode; + } & AnchorHTMLAttributes) => ( + + {children} + + ), +})); + +describe('shared footer shop legal links', () => { + beforeEach(() => { + navigationState.pathname = '/en/shop/products'; + navigationState.segments = ['shop']; + }); + + it('shows all required legal links in shop scope', () => { + render(