From ba362d99ecc4eaa54c4618687ffebdc40df58bf5 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Fri, 27 Mar 2026 09:13:09 -0700 Subject: [PATCH 1/5] (SP: 3) [SHOP] add public legal surfaces and polish buyer-facing policy copy --- frontend/.env.example | 4 +- .../app/[locale]/delivery-policy/page.tsx | 33 +++++ frontend/app/[locale]/payment-policy/page.tsx | 30 +++++ frontend/app/[locale]/returns-policy/page.tsx | 30 +++++ .../app/[locale]/seller-information/page.tsx | 33 +++++ .../legal/DeliveryPolicyContent.tsx | 50 +++++++ frontend/components/legal/LegalPageShell.tsx | 6 +- .../components/legal/PaymentPolicyContent.tsx | 45 +++++++ .../components/legal/PrivacyPolicyContent.tsx | 5 +- .../components/legal/ReturnsPolicyContent.tsx | 41 ++++++ .../legal/SellerInformationContent.tsx | 113 ++++++++++++++++ .../legal/TermsOfServiceContent.tsx | 5 +- frontend/components/shared/Footer.tsx | 43 +++--- .../tests/footer-shop-legal-links.test.tsx | 122 ++++++++++++++++++ frontend/lib/legal/public-contact.ts | 5 + .../lib/legal/public-seller-information.ts | 38 ++++++ .../shop/public-legal-pages-phase4.test.ts | 68 ++++++++++ .../public-seller-information-phase4.test.ts | 56 ++++++++ frontend/messages/en.json | 99 ++++++++++++++ frontend/messages/pl.json | 99 ++++++++++++++ frontend/messages/uk.json | 99 ++++++++++++++ 21 files changed, 1002 insertions(+), 22 deletions(-) create mode 100644 frontend/app/[locale]/delivery-policy/page.tsx create mode 100644 frontend/app/[locale]/payment-policy/page.tsx create mode 100644 frontend/app/[locale]/returns-policy/page.tsx create mode 100644 frontend/app/[locale]/seller-information/page.tsx create mode 100644 frontend/components/legal/DeliveryPolicyContent.tsx create mode 100644 frontend/components/legal/PaymentPolicyContent.tsx create mode 100644 frontend/components/legal/ReturnsPolicyContent.tsx create mode 100644 frontend/components/legal/SellerInformationContent.tsx create mode 100644 frontend/components/tests/footer-shop-legal-links.test.tsx create mode 100644 frontend/lib/legal/public-contact.ts create mode 100644 frontend/lib/legal/public-seller-information.ts create mode 100644 frontend/lib/tests/shop/public-legal-pages-phase4.test.ts create mode 100644 frontend/lib/tests/shop/public-seller-information-phase4.test.ts 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..d655231e --- /dev/null +++ b/frontend/app/[locale]/payment-policy/page.tsx @@ -0,0 +1,30 @@ +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..b1fca71a --- /dev/null +++ b/frontend/app/[locale]/returns-policy/page.tsx @@ -0,0 +1,30 @@ +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/components/legal/DeliveryPolicyContent.tsx b/frontend/components/legal/DeliveryPolicyContent.tsx new file mode 100644 index 00000000..b05a6c2c --- /dev/null +++ b/frontend/components/legal/DeliveryPolicyContent.tsx @@ -0,0 +1,50 @@ +import { getTranslations } from 'next-intl/server'; + +import { getPublicSupportEmail } from '@/lib/legal/public-contact'; + +import LegalBlock from './LegalBlock'; + +export const DELIVERY_POLICY_LAST_UPDATED = '2026-03-27'; + +const linkClass = + 'underline underline-offset-4 hover:text-blue-600 dark:hover:text-blue-400 transition-colors'; + +export default async function DeliveryPolicyContent() { + const t = await getTranslations('legal.delivery'); + const email = getPublicSupportEmail(); + + return ( +
+ +

{t('methods.body')}

+
    +
  • {t('methods.items.warehouse')}
  • +
  • {t('methods.items.locker')}
  • +
  • {t('methods.items.courier')}
  • +
+
+ + +

{t('availability.body')}

+
+ + +

{t('timing.body')}

+
+ + +

{t('charges.body')}

+
+ + +

+ {t('support.body')}{' '} + + {email} + + . +

+
+
+ ); +} diff --git a/frontend/components/legal/LegalPageShell.tsx b/frontend/components/legal/LegalPageShell.tsx index 3406bfe5..bb373f33 100644 --- a/frontend/components/legal/LegalPageShell.tsx +++ b/frontend/components/legal/LegalPageShell.tsx @@ -1,6 +1,7 @@ import { getTranslations } from 'next-intl/server'; import LegalBackButton from '@/components/legal/LegalBackButton'; +import { getPublicSupportEmail } from '@/lib/legal/public-contact'; type Props = { title: string; @@ -14,6 +15,7 @@ export default async function LegalPageShell({ children, }: Props) { const t = await getTranslations('legal'); + const contactEmail = getPublicSupportEmail(); return (
@@ -39,10 +41,10 @@ export default async function LegalPageShell({
- {t('contactEmail')} + {contactEmail}
diff --git a/frontend/components/legal/PaymentPolicyContent.tsx b/frontend/components/legal/PaymentPolicyContent.tsx new file mode 100644 index 00000000..2d1da516 --- /dev/null +++ b/frontend/components/legal/PaymentPolicyContent.tsx @@ -0,0 +1,45 @@ +import { getTranslations } from 'next-intl/server'; + +import { getPublicSupportEmail } from '@/lib/legal/public-contact'; + +import LegalBlock from './LegalBlock'; + +export const PAYMENT_POLICY_LAST_UPDATED = '2026-03-27'; + +const linkClass = + 'underline underline-offset-4 hover:text-blue-600 dark:hover:text-blue-400 transition-colors'; + +export default async function PaymentPolicyContent() { + const t = await getTranslations('legal.payment'); + const email = getPublicSupportEmail(); + + return ( +
+ +

{t('methods.body')}

+
    +
  • {t('methods.items.stripe')}
  • +
  • {t('methods.items.monobank')}
  • +
+
+ + +

{t('confirmation.body')}

+
+ + +

{t('charges.body')}

+
+ + +

+ {t('support.body')}{' '} + + {email} + + . +

+
+
+ ); +} diff --git a/frontend/components/legal/PrivacyPolicyContent.tsx b/frontend/components/legal/PrivacyPolicyContent.tsx index 3ab756b9..7efada85 100644 --- a/frontend/components/legal/PrivacyPolicyContent.tsx +++ b/frontend/components/legal/PrivacyPolicyContent.tsx @@ -1,5 +1,7 @@ import { getTranslations } from 'next-intl/server'; +import { getPublicSupportEmail } from '@/lib/legal/public-contact'; + import LegalBlock from './LegalBlock'; export const PRIVACY_LAST_UPDATED = '2025-12-14'; @@ -9,8 +11,7 @@ const linkClass = export default async function PrivacyPolicyContent() { const t = await getTranslations('legal.privacy'); - const tLegal = await getTranslations('legal'); - const email = tLegal('contactEmail'); + const email = getPublicSupportEmail(); return (
diff --git a/frontend/components/legal/ReturnsPolicyContent.tsx b/frontend/components/legal/ReturnsPolicyContent.tsx new file mode 100644 index 00000000..d5f4b48e --- /dev/null +++ b/frontend/components/legal/ReturnsPolicyContent.tsx @@ -0,0 +1,41 @@ +import { getTranslations } from 'next-intl/server'; + +import { getPublicSupportEmail } from '@/lib/legal/public-contact'; + +import LegalBlock from './LegalBlock'; + +export const RETURNS_POLICY_LAST_UPDATED = '2026-03-27'; + +const linkClass = + 'underline underline-offset-4 hover:text-blue-600 dark:hover:text-blue-400 transition-colors'; + +export default async function ReturnsPolicyContent() { + const t = await getTranslations('legal.returns'); + const email = getPublicSupportEmail(); + + return ( +
+ +

{t('request.body')}

+
+ + +

{t('review.body')}

+
+ + +

{t('refunds.body')}

+
+ + +

+ {t('contact.body')}{' '} + + {email} + + . +

+
+
+ ); +} diff --git a/frontend/components/legal/SellerInformationContent.tsx b/frontend/components/legal/SellerInformationContent.tsx new file mode 100644 index 00000000..5ed167f4 --- /dev/null +++ b/frontend/components/legal/SellerInformationContent.tsx @@ -0,0 +1,113 @@ +import { getTranslations } from 'next-intl/server'; + +import { getPublicSellerInformation } from '@/lib/legal/public-seller-information'; + +import LegalBlock from './LegalBlock'; + +export const SELLER_INFORMATION_LAST_UPDATED = '2026-03-27'; + +const linkClass = + 'underline underline-offset-4 hover:text-blue-600 dark:hover:text-blue-400 transition-colors'; + +function placeholder(text: string) { + return ( + {text} + ); +} + +export default async function SellerInformationContent() { + const t = await getTranslations('legal.seller'); + const seller = getPublicSellerInformation(); + + const sellerDetails = [ + { + key: 'sellerName', + label: t('fields.sellerName'), + value: seller.sellerName ?? placeholder(t('placeholders.toBeAdded')), + }, + { + key: 'address', + label: t('fields.address'), + value: seller.address ?? placeholder(t('placeholders.toBeAdded')), + }, + { + key: 'businessDetails', + label: t('fields.businessDetails'), + value: + seller.businessDetails.length > 0 ? ( +
    + {seller.businessDetails.map(detail => ( +
  • + {detail.label}: {detail.value} +
  • + ))} +
+ ) : ( + placeholder(t('placeholders.toBeAdded')) + ), + }, + ]; + + const supportContacts = [ + { + key: 'supportEmail', + label: t('fields.supportEmail'), + value: seller.supportEmail ? ( + + {seller.supportEmail} + + ) : ( + placeholder(t('placeholders.toBeAdded')) + ), + }, + { + key: 'supportPhone', + label: t('fields.supportPhone'), + value: seller.supportPhone ? ( + + {seller.supportPhone} + + ) : ( + placeholder(t('placeholders.toBeAdded')) + ), + }, + ]; + + return ( +
+ +

{t('sellerDetailsBody')}

+
+ {sellerDetails.map(field => ( +
+
+ {field.label} +
+
{field.value}
+
+ ))} +
+
+ + +

{t('supportContactsBody')}

+
+ {supportContacts.map(field => ( +
+
+ {field.label} +
+
{field.value}
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/components/legal/TermsOfServiceContent.tsx b/frontend/components/legal/TermsOfServiceContent.tsx index 040d8a01..15a73434 100644 --- a/frontend/components/legal/TermsOfServiceContent.tsx +++ b/frontend/components/legal/TermsOfServiceContent.tsx @@ -1,5 +1,7 @@ import { getTranslations } from 'next-intl/server'; +import { getPublicSupportEmail } from '@/lib/legal/public-contact'; + import LegalBlock from './LegalBlock'; export const TERMS_LAST_UPDATED = '2025-12-14'; @@ -9,8 +11,7 @@ const linkClass = export default async function TermsOfServiceContent() { const t = await getTranslations('legal.terms'); - const tLegal = await getTranslations('legal'); - const email = tLegal('contactEmail'); + const email = getPublicSupportEmail(); return (
diff --git a/frontend/components/shared/Footer.tsx b/frontend/components/shared/Footer.tsx index 0e829dce..d5a3c9df 100644 --- a/frontend/components/shared/Footer.tsx +++ b/frontend/components/shared/Footer.tsx @@ -3,7 +3,7 @@ import { Github, Linkedin, Send } from 'lucide-react'; import { usePathname, useSelectedLayoutSegments } from 'next/navigation'; import { useTranslations } from 'next-intl'; -import type { Ref } from 'react'; +import { Fragment, type Ref } from 'react'; import { ThemeToggle } from '@/components/theme/ThemeToggle'; import { locales } from '@/i18n/config'; @@ -38,6 +38,19 @@ export default function Footer({ pathSegments.length === 0 || (pathSegments.length === 1 && locales.includes(pathSegments[0] as (typeof locales)[number])); + const legalLinks = isShop + ? [ + { href: '/seller-information', label: t('sellerInformation') }, + { href: '/payment-policy', label: t('payment') }, + { href: '/delivery-policy', label: t('delivery') }, + { href: '/returns-policy', label: t('returns') }, + { href: '/privacy-policy', label: t('privacyPolicy') }, + { href: '/terms-of-service', label: t('termsOfService') }, + ] + : [ + { href: '/privacy-policy', label: t('privacyPolicy') }, + { href: '/terms-of-service', label: t('termsOfService') }, + ]; if (isHome && !forceVisible) { return null; @@ -69,20 +82,20 @@ export default function Footer({ {t('byCommunity')}

-

- - {t('privacyPolicy')} - - | - - {t('termsOfService')} - +

+ {legalLinks.map((link, index) => ( + + {index > 0 ? ( + + ) : null} + + {link.label} + + + ))}

diff --git a/frontend/components/tests/footer-shop-legal-links.test.tsx b/frontend/components/tests/footer-shop-legal-links.test.tsx new file mode 100644 index 00000000..6c8e7dd8 --- /dev/null +++ b/frontend/components/tests/footer-shop-legal-links.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import Footer from '@/components/shared/Footer'; + +const navigationState = vi.hoisted(() => ({ + pathname: '/en/shop/products', + segments: ['shop'] as string[], +})); + +const footerTranslations: Record = { + builtWith: 'Built by', + byCommunity: 'community.', + sellerInformation: 'Seller Information', + payment: 'Payment', + delivery: 'Delivery', + returns: 'Returns', + privacyPolicy: 'Privacy Policy', + termsOfService: 'Terms of Service', +}; + +vi.mock('lucide-react', () => ({ + Github: () => null, + Linkedin: () => null, + Send: () => null, +})); + +vi.mock('next/navigation', () => ({ + usePathname: () => navigationState.pathname, + useSelectedLayoutSegments: () => navigationState.segments, +})); + +vi.mock('next-intl', () => ({ + useTranslations: + () => + (key: string): string => + footerTranslations[key] ?? key, +})); + +vi.mock('@/components/theme/ThemeToggle', () => ({ + ThemeToggle: () => , +})); + +vi.mock('@/i18n/routing', () => ({ + Link: ({ + href, + children, + className, + }: { + href: string; + children: ReactNode; + className?: string; + }) => ( + + {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(
); + + expect( + screen.getByRole('link', { name: 'Seller Information' }) + ).toHaveAttribute('href', '/seller-information'); + expect(screen.getByRole('link', { name: 'Payment' })).toHaveAttribute( + 'href', + '/payment-policy' + ); + expect(screen.getByRole('link', { name: 'Delivery' })).toHaveAttribute( + 'href', + '/delivery-policy' + ); + expect(screen.getByRole('link', { name: 'Returns' })).toHaveAttribute( + 'href', + '/returns-policy' + ); + expect( + screen.getByRole('link', { name: 'Privacy Policy' }) + ).toHaveAttribute('href', '/privacy-policy'); + expect( + screen.getByRole('link', { name: 'Terms of Service' }) + ).toHaveAttribute('href', '/terms-of-service'); + }); + + it('does not expose shop-only legal links outside shop scope', () => { + navigationState.pathname = '/en/about'; + navigationState.segments = ['about']; + + render(
); + + expect( + screen.queryByRole('link', { name: 'Seller Information' }) + ).toBeNull(); + expect(screen.queryByRole('link', { name: 'Payment' })).toBeNull(); + expect(screen.queryByRole('link', { name: 'Delivery' })).toBeNull(); + expect(screen.queryByRole('link', { name: 'Returns' })).toBeNull(); + expect(screen.getByRole('link', { name: 'Privacy Policy' })).toBeTruthy(); + expect(screen.getByRole('link', { name: 'Terms of Service' })).toBeTruthy(); + }); + + it('preserves hidden-on-home behavior unless forceVisible is enabled', () => { + navigationState.pathname = '/en'; + navigationState.segments = []; + + const { container, rerender } = render(
); + + expect(container.firstChild).toBeNull(); + + rerender(
); + + expect(screen.getByRole('link', { name: 'Privacy Policy' })).toBeTruthy(); + }); +}); diff --git a/frontend/lib/legal/public-contact.ts b/frontend/lib/legal/public-contact.ts new file mode 100644 index 00000000..4def6cb4 --- /dev/null +++ b/frontend/lib/legal/public-contact.ts @@ -0,0 +1,5 @@ +export const PUBLIC_SUPPORT_EMAIL = 'contact@devlovers.net'; + +export function getPublicSupportEmail(): string { + return PUBLIC_SUPPORT_EMAIL; +} diff --git a/frontend/lib/legal/public-seller-information.ts b/frontend/lib/legal/public-seller-information.ts new file mode 100644 index 00000000..4365b086 --- /dev/null +++ b/frontend/lib/legal/public-seller-information.ts @@ -0,0 +1,38 @@ +import 'server-only'; + +import { getPublicSupportEmail } from '@/lib/legal/public-contact'; + +export type SellerBusinessDetail = { + label: string; + value: string; +}; + +export type PublicSellerInformation = { + sellerName: string | null; + supportEmail: string | null; + supportPhone: string | null; + address: string | null; + businessDetails: SellerBusinessDetail[]; +}; + +function nonEmpty(value: string | undefined): string | null { + const trimmed = (value ?? '').trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function getPublicSellerInformation(): PublicSellerInformation { + const sellerName = nonEmpty(process.env.NP_SENDER_NAME); + const supportPhone = nonEmpty(process.env.NP_SENDER_PHONE); + const supportEmail = getPublicSupportEmail(); + const address = null; + const edrpou = nonEmpty(process.env.NP_SENDER_EDRPOU); + const businessDetails = edrpou ? [{ label: 'EDRPOU', value: edrpou }] : []; + + return { + sellerName, + supportEmail, + supportPhone, + address, + businessDetails, + }; +} diff --git a/frontend/lib/tests/shop/public-legal-pages-phase4.test.ts b/frontend/lib/tests/shop/public-legal-pages-phase4.test.ts new file mode 100644 index 00000000..901a3e83 --- /dev/null +++ b/frontend/lib/tests/shop/public-legal-pages-phase4.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; + +const getTranslationsMock = vi.hoisted(() => + vi.fn( + async ( + input?: + | string + | { + locale?: string; + namespace?: string; + } + ) => { + const namespace = + typeof input === 'string' ? input : (input?.namespace ?? ''); + + return (key: string) => `${namespace}.${key}`; + } + ) +); + +vi.mock('next-intl/server', () => ({ + getTranslations: getTranslationsMock, +})); + +describe('phase 4 public legal pages', () => { + it('keeps public legal pages guest-accessible and localized through route metadata', async () => { + const localeParams = { params: Promise.resolve({ locale: 'en' }) }; + + const sellerPage = await import('@/app/[locale]/seller-information/page'); + const paymentPage = await import('@/app/[locale]/payment-policy/page'); + const deliveryPage = await import('@/app/[locale]/delivery-policy/page'); + const returnsPage = await import('@/app/[locale]/returns-policy/page'); + const privacyPage = await import('@/app/[locale]/privacy-policy/page'); + const termsPage = await import('@/app/[locale]/terms-of-service/page'); + + expect(await sellerPage.default()).toBeTruthy(); + expect(await paymentPage.default()).toBeTruthy(); + expect(await deliveryPage.default()).toBeTruthy(); + expect(await returnsPage.default()).toBeTruthy(); + expect(await privacyPage.default()).toBeTruthy(); + expect(await termsPage.default()).toBeTruthy(); + + await expect(sellerPage.generateMetadata(localeParams)).resolves.toEqual({ + title: 'legal.seller.metaTitle', + description: 'legal.seller.metaDescription', + }); + await expect(paymentPage.generateMetadata(localeParams)).resolves.toEqual({ + title: 'legal.payment.metaTitle', + description: 'legal.payment.metaDescription', + }); + await expect(deliveryPage.generateMetadata(localeParams)).resolves.toEqual({ + title: 'legal.delivery.metaTitle', + description: 'legal.delivery.metaDescription', + }); + await expect(returnsPage.generateMetadata(localeParams)).resolves.toEqual({ + title: 'legal.returns.metaTitle', + description: 'legal.returns.metaDescription', + }); + await expect(privacyPage.generateMetadata(localeParams)).resolves.toEqual({ + title: 'legal.privacy.metaTitle', + description: 'legal.privacy.metaDescription', + }); + await expect(termsPage.generateMetadata(localeParams)).resolves.toEqual({ + title: 'legal.terms.metaTitle', + description: 'legal.terms.metaDescription', + }); + }); +}); diff --git a/frontend/lib/tests/shop/public-seller-information-phase4.test.ts b/frontend/lib/tests/shop/public-seller-information-phase4.test.ts new file mode 100644 index 00000000..d037240c --- /dev/null +++ b/frontend/lib/tests/shop/public-seller-information-phase4.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const ENV_KEYS = [ + 'NP_SENDER_NAME', + 'NP_SENDER_PHONE', + 'NP_SENDER_EDRPOU', +] as const; + +const previousEnv: Partial< + Record<(typeof ENV_KEYS)[number], string | undefined> +> = {}; + +function restoreEnv() { + for (const key of ENV_KEYS) { + const value = previousEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +describe('public seller information contract', () => { + beforeEach(() => { + for (const key of ENV_KEYS) { + previousEnv[key] = process.env[key]; + delete process.env[key]; + } + vi.resetModules(); + }); + + afterEach(() => { + restoreEnv(); + vi.resetModules(); + }); + + it('keeps the public seller source neutral when legal identity fields are missing', async () => { + vi.stubEnv('NP_SENDER_NAME', 'Test Merchant'); + vi.stubEnv('NP_SENDER_PHONE', '+380501112233'); + + const { getPublicSellerInformation } = + await import('@/lib/legal/public-seller-information'); + + const seller = getPublicSellerInformation(); + + expect(seller).toMatchObject({ + sellerName: 'Test Merchant', + supportPhone: '+380501112233', + address: null, + businessDetails: [], + }); + expect(seller).not.toHaveProperty('missingFields'); + expect(seller).not.toHaveProperty('isComplete'); + }); +}); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index acf6bf60..5eb5ec2c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -322,6 +322,10 @@ "footer": { "builtWith": "Built by", "byCommunity": "community.", + "sellerInformation": "Seller Information", + "payment": "Payment", + "delivery": "Delivery", + "returns": "Returns", "privacyPolicy": "Privacy Policy", "termsOfService": "Terms of Service" }, @@ -1696,6 +1700,101 @@ "back": "Back", "lastUpdated": "Last updated", "contactEmail": "devlovers.net@gmail.com", + "seller": { + "metaTitle": "Seller Information | DevLovers Shop", + "metaDescription": "Public seller identity and contact details for the DevLovers shop.", + "title": "Seller Information", + "sellerDetailsTitle": "Seller details", + "sellerDetailsBody": "Below are the seller details currently published for the shop.", + "supportContactsTitle": "Support contacts", + "supportContactsBody": "If you need help with an order or a policy question, use the contacts below.", + "placeholders": { + "toBeAdded": "To be added" + }, + "fields": { + "sellerName": "Seller legal name", + "supportEmail": "Contact email", + "supportPhone": "Contact phone", + "address": "Seller address", + "businessDetails": "Registration details" + } + }, + "payment": { + "metaTitle": "Payment Policy | DevLovers Shop", + "metaDescription": "Public payment policy for the DevLovers shop.", + "title": "Payment Policy", + "methods": { + "title": "Available payment methods", + "body": "Available payment methods are shown during order checkout. Depending on the order, you may see online card payment via Stripe or a Monobank payment option.", + "items": { + "stripe": "Online card payment via Stripe.", + "monobank": "Available Monobank payment option." + } + }, + "confirmation": { + "title": "When payment is confirmed", + "body": "An order is treated as paid only after the selected payment provider confirms the transaction and the shop records that result. A browser return page or redirect by itself is not the authoritative confirmation of payment." + }, + "charges": { + "title": "What is charged online", + "body": "The amount shown before order confirmation includes the goods in your order and, when applicable, the delivery cost shown for the selected delivery method." + }, + "support": { + "title": "Payment questions", + "body": "If you have a payment question about a shop order, contact" + } + }, + "delivery": { + "metaTitle": "Delivery Policy | DevLovers Shop", + "metaDescription": "Public delivery policy for the DevLovers shop.", + "title": "Delivery Policy", + "methods": { + "title": "Available delivery methods", + "body": "Available delivery methods are shown during order checkout for orders that can be delivered.", + "items": { + "warehouse": "Nova Poshta warehouse pickup", + "locker": "Nova Poshta parcel locker pickup", + "courier": "Nova Poshta courier delivery" + } + }, + "availability": { + "title": "Availability", + "body": "At the moment, delivery options are available for eligible orders within Ukraine. The available methods may depend on the destination and carrier availability." + }, + "timing": { + "title": "Delivery timing", + "body": "Delivery time depends on the selected method, destination, and the carrier’s workload. The carrier determines the final delivery timing after dispatch." + }, + "charges": { + "title": "Delivery charges", + "body": "The delivery cost for the selected method is shown during order checkout and is added to the order total before order confirmation. Additional carrier charges for extra services requested after order placement, storage beyond the free period, redirection, or other carrier services may be charged separately under the carrier’s own rules." + }, + "support": { + "title": "Delivery questions", + "body": "If you have a delivery question about a shop order, contact" + } + }, + "returns": { + "metaTitle": "Returns Policy | DevLovers Shop", + "metaDescription": "Public returns policy for the DevLovers shop.", + "title": "Returns Policy", + "request": { + "title": "How to request a return or refund review", + "body": "To request a return or refund review, contact support and include your order number together with a short description of the reason for the request." + }, + "review": { + "title": "How requests are reviewed", + "body": "Each request is reviewed individually. We may ask for additional details, photos, or other information needed to assess the request and explain the next steps." + }, + "refunds": { + "title": "Refund processing", + "body": "Refund availability and timing depend on the review result and the payment method used for the order. Automatic refund processing through the website is not currently available." + }, + "contact": { + "title": "How to contact support", + "body": "To request return guidance or report a delivery issue, contact" + } + }, "privacy": { "metaTitle": "Privacy Policy | DevLovers", "metaDescription": "Learn how DevLovers collects, uses, and protects your personal data.", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 87c60c51..2b1430e4 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -322,6 +322,10 @@ "footer": { "builtWith": "Zbudowane przez", "byCommunity": "przez społeczność.", + "sellerInformation": "Informacje o sprzedawcy", + "payment": "Płatność", + "delivery": "Dostawa", + "returns": "Zwroty", "privacyPolicy": "Polityka Prywatności", "termsOfService": "Warunki Świadczenia Usług" }, @@ -1698,6 +1702,101 @@ "back": "Wróć", "lastUpdated": "Ostatnia aktualizacja", "contactEmail": "devlovers.net@gmail.com", + "seller": { + "metaTitle": "Informacje o sprzedawcy | Sklep DevLovers", + "metaDescription": "Publiczne dane sprzedawcy i kontaktowe dla sklepu DevLovers.", + "title": "Informacje o sprzedawcy", + "sellerDetailsTitle": "Dane sprzedawcy", + "sellerDetailsBody": "Poniżej znajdują się dane sprzedawcy, które są obecnie opublikowane dla sklepu.", + "supportContactsTitle": "Kontakty wsparcia", + "supportContactsBody": "Jeśli potrzebujesz pomocy z zamówieniem lub masz pytanie dotyczące zasad sklepu, skorzystaj z kontaktów poniżej.", + "placeholders": { + "toBeAdded": "Do uzupełnienia" + }, + "fields": { + "sellerName": "Nazwa sprzedawcy", + "supportEmail": "Kontaktowy email", + "supportPhone": "Kontaktowy telefon", + "address": "Adres sprzedawcy", + "businessDetails": "Dane rejestracyjne" + } + }, + "payment": { + "metaTitle": "Polityka płatności | Sklep DevLovers", + "metaDescription": "Publiczna polityka płatności sklepu DevLovers.", + "title": "Polityka płatności", + "methods": { + "title": "Dostępne metody płatności", + "body": "Dostępne metody płatności są pokazywane podczas składania zamówienia. W zależności od zamówienia możesz zobaczyć płatność kartą online przez Stripe lub opcję płatności Monobank.", + "items": { + "stripe": "Płatność kartą online przez Stripe.", + "monobank": "Dostępna opcja płatności Monobank." + } + }, + "confirmation": { + "title": "Kiedy płatność jest potwierdzona", + "body": "Zamówienie jest uznawane za opłacone dopiero wtedy, gdy wybrany operator płatności potwierdzi transakcję, a sklep zapisze ten wynik. Sama strona powrotu lub przekierowanie w przeglądarce nie są autorytatywnym potwierdzeniem płatności." + }, + "charges": { + "title": "Co jest pobierane online", + "body": "Kwota widoczna przed potwierdzeniem zamówienia obejmuje towary w Twoim zamówieniu oraz, jeśli dotyczy, koszt dostawy pokazany dla wybranej metody dostawy." + }, + "support": { + "title": "Pytania o płatność", + "body": "Jeśli masz pytanie dotyczące płatności za zamówienie w sklepie, napisz na" + } + }, + "delivery": { + "metaTitle": "Polityka dostawy | Sklep DevLovers", + "metaDescription": "Publiczna polityka dostawy sklepu DevLovers.", + "title": "Polityka dostawy", + "methods": { + "title": "Dostępne metody dostawy", + "body": "Dostępne metody dostawy są pokazywane podczas składania zamówienia dla zamówień, które mogą zostać dostarczone.", + "items": { + "warehouse": "Odbiór w oddziale Nova Poshta", + "locker": "Odbiór w automacie paczkowym Nova Poshta", + "courier": "Dostawa kurierska Nova Poshta" + } + }, + "availability": { + "title": "Dostępność", + "body": "Obecnie opcje dostawy są dostępne dla kwalifikujących się zamówień na terenie Ukrainy. Dostępne metody mogą zależeć od miejsca docelowego i dostępności przewoźnika." + }, + "timing": { + "title": "Czas dostawy", + "body": "Czas dostawy zależy od wybranej metody, miejsca docelowego i obciążenia przewoźnika. Ostateczny termin dostawy przewoźnik określa po nadaniu przesyłki." + }, + "charges": { + "title": "Koszty dostawy", + "body": "Koszt dostawy dla wybranej metody jest pokazywany podczas składania zamówienia i dodawany do łącznej kwoty przed potwierdzeniem zamówienia. Dodatkowe opłaty przewoźnika za usługi zamawiane po złożeniu zamówienia, przechowywanie po bezpłatnym okresie, przekierowanie lub inne usługi przewoźnika mogą zostać naliczone osobno zgodnie z zasadami przewoźnika." + }, + "support": { + "title": "Pytania o dostawę", + "body": "Jeśli masz pytanie dotyczące dostawy zamówienia w sklepie, napisz na" + } + }, + "returns": { + "metaTitle": "Polityka zwrotów | Sklep DevLovers", + "metaDescription": "Publiczna polityka zwrotów sklepu DevLovers.", + "title": "Polityka zwrotów", + "request": { + "title": "Jak zgłosić zwrot lub prośbę o rozpatrzenie refundacji", + "body": "Aby zgłosić zwrot lub prośbę o rozpatrzenie refundacji, skontaktuj się ze wsparciem i podaj numer zamówienia oraz krótki opis przyczyny zgłoszenia." + }, + "review": { + "title": "Jak rozpatrywane są zgłoszenia", + "body": "Każde zgłoszenie jest rozpatrywane indywidualnie. W razie potrzeby możemy poprosić o dodatkowe szczegóły, zdjęcia lub inne informacje potrzebne do oceny zgłoszenia i przekazania dalszych kroków." + }, + "refunds": { + "title": "Zwrot środków", + "body": "Możliwość oraz czas zwrotu środków zależą od wyniku rozpatrzenia i metody płatności użytej przy zamówieniu. Automatyczne zwroty przez stronę internetową nie są obecnie dostępne." + }, + "contact": { + "title": "Jak skontaktować się ze wsparciem", + "body": "Aby uzyskać wskazówki dotyczące zwrotu lub zgłosić problem z dostawą, napisz na" + } + }, "privacy": { "metaTitle": "Polityka Prywatności | DevLovers", "metaDescription": "Dowiedz się, jak DevLovers gromadzi, wykorzystuje i chroni Twoje dane osobowe.", diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index eed30dba..7c37643c 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -322,6 +322,10 @@ "footer": { "builtWith": "Створено", "byCommunity": "спільнотою.", + "sellerInformation": "Інформація про продавця", + "payment": "Оплата", + "delivery": "Доставка", + "returns": "Повернення", "privacyPolicy": "Політика Конфіденційності", "termsOfService": "Умови Використання" }, @@ -1698,6 +1702,101 @@ "back": "Назад", "lastUpdated": "Останнє оновлення", "contactEmail": "devlovers.net@gmail.com", + "seller": { + "metaTitle": "Інформація про продавця | Магазин DevLovers", + "metaDescription": "Публічна інформація про продавця та контакти магазину DevLovers.", + "title": "Інформація про продавця", + "sellerDetailsTitle": "Дані про продавця", + "sellerDetailsBody": "Нижче наведено дані про продавця, які зараз опубліковані для магазину.", + "supportContactsTitle": "Контакти підтримки", + "supportContactsBody": "Якщо вам потрібна допомога із замовленням або питання щодо політики магазину, скористайтеся контактами нижче.", + "placeholders": { + "toBeAdded": "Буде додано" + }, + "fields": { + "sellerName": "Найменування продавця", + "supportEmail": "Контактний email", + "supportPhone": "Контактний телефон", + "address": "Адреса продавця", + "businessDetails": "Реєстраційні дані" + } + }, + "payment": { + "metaTitle": "Політика оплати | Магазин DevLovers", + "metaDescription": "Публічна політика оплати магазину DevLovers.", + "title": "Політика оплати", + "methods": { + "title": "Доступні способи оплати", + "body": "Доступні способи оплати показуються під час оформлення замовлення. Залежно від замовлення ви можете побачити онлайн-оплату карткою через Stripe або спосіб оплати Monobank.", + "items": { + "stripe": "Онлайн-оплата карткою через Stripe.", + "monobank": "Доступний спосіб оплати Monobank." + } + }, + "confirmation": { + "title": "Коли оплата вважається підтвердженою", + "body": "Замовлення вважається оплаченим лише після того, як обраний платіжний провайдер підтвердить транзакцію, а магазин збереже цей результат. Сторінка повернення або редирект у браузері самі по собі не є авторитетним підтвердженням оплати." + }, + "charges": { + "title": "Що списується онлайн", + "body": "Сума, яку ви бачите перед підтвердженням замовлення, включає товари у вашому замовленні та, за потреби, вартість доставки для обраного способу доставки." + }, + "support": { + "title": "Питання щодо оплати", + "body": "Якщо у вас є питання щодо оплати замовлення в магазині, напишіть на" + } + }, + "delivery": { + "metaTitle": "Політика доставки | Магазин DevLovers", + "metaDescription": "Публічна політика доставки магазину DevLovers.", + "title": "Політика доставки", + "methods": { + "title": "Доступні способи доставки", + "body": "Доступні способи доставки показуються під час оформлення замовлення для замовлень, які можна доставити.", + "items": { + "warehouse": "Самовивіз із відділення Nova Poshta", + "locker": "Самовивіз із поштомата Nova Poshta", + "courier": "Кур’єрська доставка Nova Poshta" + } + }, + "availability": { + "title": "Доступність", + "body": "Наразі доставка доступна для відповідних замовлень в межах України. Набір доступних способів може залежати від місця призначення та доступності перевізника." + }, + "timing": { + "title": "Терміни доставки", + "body": "Термін доставки залежить від обраного способу, населеного пункту призначення та завантаженості перевізника. Остаточний строк доставки визначає перевізник після відправлення." + }, + "charges": { + "title": "Вартість доставки", + "body": "Вартість доставки для обраного способу показується під час оформлення замовлення і додається до загальної суми до підтвердження замовлення. Додаткові платежі перевізнику за окремі послуги після оформлення замовлення, зберігання понад безкоштовний строк, переадресацію чи інші послуги перевізника можуть стягуватися окремо за правилами перевізника." + }, + "support": { + "title": "Питання щодо доставки", + "body": "Якщо у вас є питання щодо доставки замовлення в магазині, напишіть на" + } + }, + "returns": { + "metaTitle": "Політика повернень | Магазин DevLovers", + "metaDescription": "Публічна політика повернень магазину DevLovers.", + "title": "Політика повернень", + "request": { + "title": "Як подати запит на повернення або розгляд відшкодування", + "body": "Щоб подати запит на повернення або розгляд відшкодування, зверніться до підтримки та вкажіть номер замовлення й коротко опишіть причину звернення." + }, + "review": { + "title": "Як розглядаються звернення", + "body": "Кожне звернення розглядається окремо. За потреби ми можемо попросити додаткові деталі, фото або іншу інформацію, щоб оцінити ситуацію та повідомити подальші кроки." + }, + "refunds": { + "title": "Повернення коштів", + "body": "Можливість і строк повернення коштів залежать від результату розгляду та способу оплати замовлення. Автоматичне повернення коштів через сайт наразі недоступне." + }, + "contact": { + "title": "Як зв’язатися з підтримкою", + "body": "Щоб отримати інструкції щодо повернення або повідомити про проблему з доставкою, напишіть на" + } + }, "privacy": { "metaTitle": "Політика Конфіденційності | DevLovers", "metaDescription": "Дізнайтеся, як DevLovers збирає, використовує та захищає ваші персональні дані.", From 6eddc41d0bcaef38ae69bbcaebdeb46a7654521a Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Fri, 27 Mar 2026 10:06:37 -0700 Subject: [PATCH 2/5] (SP: 1) [SHOP] apply formatting to WS8 legal/footer files --- frontend/app/[locale]/payment-policy/page.tsx | 5 ++++- frontend/app/[locale]/returns-policy/page.tsx | 5 ++++- frontend/components/shared/Footer.tsx | 11 +++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/frontend/app/[locale]/payment-policy/page.tsx b/frontend/app/[locale]/payment-policy/page.tsx index d655231e..03cd65fb 100644 --- a/frontend/app/[locale]/payment-policy/page.tsx +++ b/frontend/app/[locale]/payment-policy/page.tsx @@ -23,7 +23,10 @@ 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 index b1fca71a..c060c864 100644 --- a/frontend/app/[locale]/returns-policy/page.tsx +++ b/frontend/app/[locale]/returns-policy/page.tsx @@ -23,7 +23,10 @@ export default async function ReturnsPolicyPage() { const t = await getTranslations('legal.returns'); return ( - + ); diff --git a/frontend/components/shared/Footer.tsx b/frontend/components/shared/Footer.tsx index d5a3c9df..bf12e43f 100644 --- a/frontend/components/shared/Footer.tsx +++ b/frontend/components/shared/Footer.tsx @@ -59,6 +59,7 @@ export default function Footer({ return (
-

+

{legalLinks.map((link, index) => ( {index > 0 ? ( - + ) : null} {link.label} From fc1b33375a9020b46e0d9456e726dd6679e468c1 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Fri, 27 Mar 2026 10:07:53 -0700 Subject: [PATCH 3/5] (SP: 3) [SHOP] require explicit checkout consent and align privacy wording --- .../app/[locale]/shop/cart/CartPageClient.tsx | 74 +++++++++ frontend/app/[locale]/shop/cart/page.tsx | 6 + frontend/app/api/shop/checkout/route.ts | 30 +++- frontend/lib/services/orders/checkout.ts | 20 ++- .../checkout-legal-consent-contract.test.ts | 29 ++++ .../checkout-legal-consent-phase4.test.ts | 32 ++++ ...out-route-stripe-disabled-recovery.test.ts | 140 ++++++++++++++---- ...al-cookie-privacy-alignment-phase4.test.ts | 118 +++++++++++++++ frontend/lib/validation/shop.ts | 2 +- frontend/messages/en.json | 16 +- frontend/messages/pl.json | 16 +- frontend/messages/uk.json | 16 +- 12 files changed, 444 insertions(+), 55 deletions(-) create mode 100644 frontend/lib/tests/shop/checkout-legal-consent-contract.test.ts create mode 100644 frontend/lib/tests/shop/legal-cookie-privacy-alignment-phase4.test.ts 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} +
+