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..bf12e43f 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;
@@ -46,6 +59,7 @@ export default function Footer({
return (
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..a50fcffa
--- /dev/null
+++ b/frontend/components/tests/footer-shop-legal-links.test.tsx
@@ -0,0 +1,127 @@
+// @vitest-environment jsdom
+import { render, screen } from '@testing-library/react';
+import type { AnchorHTMLAttributes, 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,
+ ...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();
+
+ expect(
+ screen.getByTestId('footer-legal-link-seller-information')
+ ).toHaveAttribute('href', '/seller-information');
+ expect(
+ screen.getByTestId('footer-legal-link-payment-policy')
+ ).toHaveTextContent('Payment');
+ expect(
+ screen.getByTestId('footer-legal-link-payment-policy')
+ ).toHaveAttribute('href', '/payment-policy');
+ expect(
+ screen.getByTestId('footer-legal-link-delivery-policy')
+ ).toHaveAttribute('href', '/delivery-policy');
+ expect(
+ screen.getByTestId('footer-legal-link-returns-policy')
+ ).toHaveAttribute('href', '/returns-policy');
+ expect(
+ screen.getByTestId('footer-legal-link-privacy-policy')
+ ).toHaveAttribute('href', '/privacy-policy');
+ expect(
+ screen.getByTestId('footer-legal-link-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.queryByTestId('footer-legal-link-seller-information')
+ ).toBeNull();
+ expect(screen.queryByTestId('footer-legal-link-payment-policy')).toBeNull();
+ expect(
+ screen.queryByTestId('footer-legal-link-delivery-policy')
+ ).toBeNull();
+ expect(screen.queryByTestId('footer-legal-link-returns-policy')).toBeNull();
+ expect(
+ screen.getByTestId('footer-legal-link-privacy-policy')
+ ).toHaveAttribute('href', '/privacy-policy');
+ expect(
+ screen.getByTestId('footer-legal-link-terms-of-service')
+ ).toHaveAttribute('href', '/terms-of-service');
+ });
+
+ 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/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts
index 75269428..b9eae622 100644
--- a/frontend/lib/services/orders/checkout.ts
+++ b/frontend/lib/services/orders/checkout.ts
@@ -17,7 +17,6 @@ import {
getShopShippingFlags,
NovaPoshtaConfigError,
} from '@/lib/env/nova-poshta';
-import { getShopLegalVersions } from '@/lib/env/shop-legal';
import { logError, logWarn } from '@/lib/logging';
import { resolveShippingAvailability } from '@/lib/services/shop/shipping/availability';
import {
@@ -202,12 +201,32 @@ type PreparedLegalConsent = {
};
};
-function normalizeLegalVersion(
+const CHECKOUT_LEGAL_CONSENT_REPLAY_GRACE_MS = 30_000;
+
+function requireLegalConsentVersion(
raw: string | undefined,
- fallback: string
+ field: 'termsVersion' | 'privacyVersion'
): string {
const normalized = (raw ?? '').trim();
- return normalized.length > 0 ? normalized : fallback;
+ if (normalized.length > 0) {
+ return normalized;
+ }
+
+ throw new InvalidPayloadError(
+ `${field === 'termsVersion' ? 'Terms' : 'Privacy'} version is required before checkout.`,
+ {
+ code:
+ field === 'termsVersion'
+ ? 'TERMS_VERSION_REQUIRED'
+ : 'PRIVACY_VERSION_REQUIRED',
+ }
+ );
+}
+
+function isWithinLegalConsentReplayGraceWindow(createdAt: Date): boolean {
+ return (
+ Date.now() - createdAt.getTime() <= CHECKOUT_LEGAL_CONSENT_REPLAY_GRACE_MS
+ );
}
function normalizeCountryCode(raw: string | null | undefined): string | null {
@@ -505,14 +524,21 @@ async function prepareCheckoutShipping(args: {
}
function resolveCheckoutLegalConsent(args: {
- legalConsent?: CheckoutLegalConsentInput | null;
+ legalConsent: CheckoutLegalConsentInput | null | undefined;
locale: string | null | undefined;
country: string | null | undefined;
}): PreparedLegalConsent {
- const versions = getShopLegalVersions();
+ if (args.legalConsent == null) {
+ throw new InvalidPayloadError(
+ 'Explicit legal consent is required before checkout.',
+ {
+ code: 'LEGAL_CONSENT_REQUIRED',
+ }
+ );
+ }
- const termsAccepted = args.legalConsent?.termsAccepted ?? true;
- const privacyAccepted = args.legalConsent?.privacyAccepted ?? true;
+ const termsAccepted = args.legalConsent.termsAccepted;
+ const privacyAccepted = args.legalConsent.privacyAccepted;
if (!termsAccepted) {
throw new InvalidPayloadError('Terms must be accepted before checkout.', {
@@ -526,18 +552,17 @@ function resolveCheckoutLegalConsent(args: {
});
}
- const termsVersion = normalizeLegalVersion(
- args.legalConsent?.termsVersion,
- versions.termsVersion
+ const termsVersion = requireLegalConsentVersion(
+ args.legalConsent.termsVersion,
+ 'termsVersion'
);
- const privacyVersion = normalizeLegalVersion(
- args.legalConsent?.privacyVersion,
- versions.privacyVersion
+ const privacyVersion = requireLegalConsentVersion(
+ args.legalConsent.privacyVersion,
+ 'privacyVersion'
);
const consentedAt = new Date();
- const source =
- args.legalConsent == null ? 'checkout_implicit' : 'checkout_explicit';
+ const source = 'checkout_explicit';
const normalizedLocale = normVariant(args.locale).toLowerCase() || null;
const normalizedCountry = normalizeCountryCode(
args.country ?? localeToCountry(args.locale)
@@ -787,7 +812,7 @@ export async function createOrderWithItems({
locale: string | null | undefined;
country?: string | null;
shipping?: CheckoutShippingInput | null;
- legalConsent?: CheckoutLegalConsentInput | null;
+ legalConsent: CheckoutLegalConsentInput;
pricingFingerprint?: string | null;
requirePricingFingerprint?: boolean;
shippingQuoteFingerprint?: string | null;
@@ -835,7 +860,7 @@ export async function createOrderWithItems({
requireShippingQuoteFingerprint,
});
const preparedLegalConsent = resolveCheckoutLegalConsent({
- legalConsent: legalConsent ?? null,
+ legalConsent,
locale,
country: country ?? null,
});
@@ -884,7 +909,7 @@ export async function createOrderWithItems({
.from(orderShipping)
.where(eq(orderShipping.orderId, row.id))
.limit(1);
- const [existingLegalConsentRow] = await db
+ let [existingLegalConsentRow] = await db
.select({
termsAccepted: orderLegalConsents.termsAccepted,
privacyAccepted: orderLegalConsents.privacyAccepted,
@@ -896,13 +921,37 @@ export async function createOrderWithItems({
.limit(1);
if (!existingLegalConsentRow) {
- throw new IdempotencyConflictError(
- 'Idempotency key cannot be replayed because persisted legal consent evidence is missing.',
- {
+ const canRepairMissingLegalConsent =
+ row.idempotencyRequestHash === requestHash &&
+ isWithinLegalConsentReplayGraceWindow(existing.createdAt);
+
+ if (canRepairMissingLegalConsent) {
+ await ensureOrderLegalConsentSnapshot({
orderId: row.id,
- reason: 'LEGAL_CONSENT_MISSING',
- }
- );
+ snapshot: preparedLegalConsent.snapshot,
+ });
+
+ [existingLegalConsentRow] = await db
+ .select({
+ termsAccepted: orderLegalConsents.termsAccepted,
+ privacyAccepted: orderLegalConsents.privacyAccepted,
+ termsVersion: orderLegalConsents.termsVersion,
+ privacyVersion: orderLegalConsents.privacyVersion,
+ })
+ .from(orderLegalConsents)
+ .where(eq(orderLegalConsents.orderId, row.id))
+ .limit(1);
+ }
+
+ if (!existingLegalConsentRow) {
+ throw new IdempotencyConflictError(
+ 'Idempotency key cannot be replayed because persisted legal consent evidence is missing.',
+ {
+ orderId: row.id,
+ reason: 'LEGAL_CONSENT_MISSING',
+ }
+ );
+ }
}
const existingCityRef = readShippingRefFromSnapshot(
@@ -1195,6 +1244,7 @@ export async function createOrderWithItems({
shippingAmountCents,
]);
+ const orderCreatedAt = new Date();
let orderId: string;
try {
const [created] = await db
@@ -1230,6 +1280,8 @@ export async function createOrderWithItems({
restockedAt: null,
idempotencyKey,
userId: userId ?? null,
+ createdAt: orderCreatedAt,
+ updatedAt: orderCreatedAt,
})
.returning({ id: orders.id });
diff --git a/frontend/lib/tests/shop/checkout-legal-consent-contract.test.ts b/frontend/lib/tests/shop/checkout-legal-consent-contract.test.ts
new file mode 100644
index 00000000..bc744dde
--- /dev/null
+++ b/frontend/lib/tests/shop/checkout-legal-consent-contract.test.ts
@@ -0,0 +1,29 @@
+import { checkoutPayloadSchema } from '@/lib/validation/shop';
+
+describe('checkout legal consent contract', () => {
+ const basePayload = {
+ items: [
+ {
+ productId: '11111111-1111-1111-1111-111111111111',
+ quantity: 1,
+ },
+ ],
+ };
+
+ it('requires explicit legal consent in the checkout payload', () => {
+ const missingConsent = checkoutPayloadSchema.safeParse(basePayload);
+ expect(missingConsent.success).toBe(false);
+
+ const explicitConsent = checkoutPayloadSchema.safeParse({
+ ...basePayload,
+ legalConsent: {
+ termsAccepted: true,
+ privacyAccepted: true,
+ termsVersion: 'terms-v1',
+ privacyVersion: 'privacy-v1',
+ },
+ });
+
+ expect(explicitConsent.success).toBe(true);
+ });
+});
diff --git a/frontend/lib/tests/shop/checkout-legal-consent-phase4.test.ts b/frontend/lib/tests/shop/checkout-legal-consent-phase4.test.ts
index 9106776f..9b0b4fb9 100644
--- a/frontend/lib/tests/shop/checkout-legal-consent-phase4.test.ts
+++ b/frontend/lib/tests/shop/checkout-legal-consent-phase4.test.ts
@@ -13,6 +13,8 @@ import { IdempotencyConflictError } from '@/lib/services/errors';
import { createOrderWithItems } from '@/lib/services/orders';
import { toDbMoney } from '@/lib/shop/money';
+import { TEST_LEGAL_CONSENT } from './test-legal-consent';
+
type SeedProduct = {
productId: string;
};
@@ -94,12 +96,7 @@ describe('checkout legal consent phase 4', () => {
locale: 'en-US',
country: 'US',
items: [{ productId, quantity: 1 }],
- legalConsent: {
- termsAccepted: true,
- privacyAccepted: true,
- termsVersion: 'terms-2026-02-27',
- privacyVersion: 'privacy-2026-02-27',
- },
+ legalConsent: TEST_LEGAL_CONSENT,
});
orderId = result.order.id;
@@ -152,12 +149,7 @@ describe('checkout legal consent phase 4', () => {
locale: 'en-US',
country: 'US',
items: [{ productId, quantity: 1 }],
- legalConsent: {
- termsAccepted: true,
- privacyAccepted: true,
- termsVersion: 'terms-2026-02-27',
- privacyVersion: 'privacy-2026-02-27',
- },
+ legalConsent: TEST_LEGAL_CONSENT,
});
orderId = first.order.id;
@@ -211,7 +203,7 @@ describe('checkout legal consent phase 4', () => {
}
}, 30_000);
- it('fails closed when idempotent replay finds missing legal consent row', async () => {
+ it('repairs a transiently missing legal consent row for a recent matching replay', async () => {
const { productId } = await seedProduct();
let orderId: string | null = null;
const idempotencyKey = crypto.randomUUID();
@@ -223,16 +215,74 @@ describe('checkout legal consent phase 4', () => {
locale: 'en-US',
country: 'US',
items: [{ productId, quantity: 1 }],
- legalConsent: {
- termsAccepted: true,
- privacyAccepted: true,
- termsVersion: 'terms-2026-02-27',
- privacyVersion: 'privacy-2026-02-27',
- },
+ legalConsent: TEST_LEGAL_CONSENT,
});
orderId = first.order.id;
+ await db
+ .delete(orderLegalConsents)
+ .where(eq(orderLegalConsents.orderId, orderId));
+
+ const replay = await createOrderWithItems({
+ idempotencyKey,
+ userId: null,
+ locale: 'en-US',
+ country: 'US',
+ items: [{ productId, quantity: 1 }],
+ legalConsent: TEST_LEGAL_CONSENT,
+ });
+
+ expect(replay.isNew).toBe(false);
+ expect(replay.order.id).toBe(orderId);
+
+ const [restored] = await db
+ .select({
+ orderId: orderLegalConsents.orderId,
+ termsVersion: orderLegalConsents.termsVersion,
+ privacyVersion: orderLegalConsents.privacyVersion,
+ source: orderLegalConsents.source,
+ })
+ .from(orderLegalConsents)
+ .where(eq(orderLegalConsents.orderId, orderId))
+ .limit(1);
+
+ expect(restored).toBeTruthy();
+ expect(restored?.termsVersion).toBe('terms-2026-02-27');
+ expect(restored?.privacyVersion).toBe('privacy-2026-02-27');
+ expect(restored?.source).toBe('checkout_explicit');
+ } finally {
+ if (orderId) await cleanupOrder(orderId);
+ await cleanupProduct(productId);
+ }
+ }, 30_000);
+
+ it('fails closed when idempotent replay finds missing legal consent row outside the replay grace window', async () => {
+ const { productId } = await seedProduct();
+ let orderId: string | null = null;
+ const idempotencyKey = crypto.randomUUID();
+
+ try {
+ const first = await createOrderWithItems({
+ idempotencyKey,
+ userId: null,
+ locale: 'en-US',
+ country: 'US',
+ items: [{ productId, quantity: 1 }],
+ legalConsent: TEST_LEGAL_CONSENT,
+ });
+
+ orderId = first.order.id;
+
+ const staleTimestamp = new Date(Date.now() - 5 * 60_000);
+ await db
+ .update(orders)
+ .set({
+ createdAt: staleTimestamp,
+ updatedAt: staleTimestamp,
+ })
+ .where(eq(orders.id, orderId));
+
await db
.delete(orderLegalConsents)
.where(eq(orderLegalConsents.orderId, orderId));
@@ -244,12 +294,7 @@ describe('checkout legal consent phase 4', () => {
locale: 'en-US',
country: 'US',
items: [{ productId, quantity: 1 }],
- legalConsent: {
- termsAccepted: true,
- privacyAccepted: true,
- termsVersion: 'terms-2026-02-27',
- privacyVersion: 'privacy-2026-02-27',
- },
+ legalConsent: TEST_LEGAL_CONSENT,
})
).rejects.toMatchObject({
code: 'IDEMPOTENCY_CONFLICT',
@@ -272,6 +317,50 @@ describe('checkout legal consent phase 4', () => {
}
}, 30_000);
+ it('rejects blank legal consent versions', async () => {
+ const { productId } = await seedProduct();
+
+ try {
+ await expect(
+ createOrderWithItems({
+ idempotencyKey: crypto.randomUUID(),
+ userId: null,
+ locale: 'en-US',
+ country: 'US',
+ items: [{ productId, quantity: 1 }],
+ legalConsent: {
+ termsAccepted: true,
+ privacyAccepted: true,
+ termsVersion: ' ',
+ privacyVersion: 'privacy-2026-02-27',
+ },
+ })
+ ).rejects.toMatchObject({
+ code: 'TERMS_VERSION_REQUIRED',
+ });
+
+ await expect(
+ createOrderWithItems({
+ idempotencyKey: crypto.randomUUID(),
+ userId: null,
+ locale: 'en-US',
+ country: 'US',
+ items: [{ productId, quantity: 1 }],
+ legalConsent: {
+ termsAccepted: true,
+ privacyAccepted: true,
+ termsVersion: 'terms-2026-02-27',
+ privacyVersion: ' ',
+ },
+ })
+ ).rejects.toMatchObject({
+ code: 'PRIVACY_VERSION_REQUIRED',
+ });
+ } finally {
+ await cleanupProduct(productId);
+ }
+ }, 30_000);
+
it('rejects checkout when terms or privacy are explicitly not accepted', async () => {
const { productId } = await seedProduct();
@@ -315,4 +404,36 @@ describe('checkout legal consent phase 4', () => {
await cleanupProduct(productId);
}
}, 30_000);
+
+ it('rejects checkout when explicit legal consent is missing and does not write implicit consent', async () => {
+ const { productId } = await seedProduct();
+ const idempotencyKey = crypto.randomUUID();
+
+ try {
+ await expect(
+ createOrderWithItems({
+ idempotencyKey,
+ userId: null,
+ locale: 'en-US',
+ country: 'US',
+ items: [{ productId, quantity: 1 }],
+ } as any)
+ ).rejects.toMatchObject({
+ code: 'LEGAL_CONSENT_REQUIRED',
+ });
+
+ const [persistedOrder] = await db
+ .select({
+ id: orders.id,
+ idempotencyKey: orders.idempotencyKey,
+ })
+ .from(orders)
+ .where(eq(orders.idempotencyKey, idempotencyKey))
+ .limit(1);
+
+ expect(persistedOrder).toBeUndefined();
+ } finally {
+ await cleanupProduct(productId);
+ }
+ }, 30_000);
});
diff --git a/frontend/lib/tests/shop/checkout-route-stripe-disabled-recovery.test.ts b/frontend/lib/tests/shop/checkout-route-stripe-disabled-recovery.test.ts
index b2c5579e..ab0bd8d0 100644
--- a/frontend/lib/tests/shop/checkout-route-stripe-disabled-recovery.test.ts
+++ b/frontend/lib/tests/shop/checkout-route-stripe-disabled-recovery.test.ts
@@ -14,6 +14,50 @@ const mockCreateStatusToken = vi.fn();
const mockIsStripePaymentsEnabled = vi.fn();
const mockIsMethodAllowed = vi.fn();
const mockReadPositiveIntEnv = vi.fn();
+type MockCheckoutPayloadSafeParseResult =
+ | {
+ success: true;
+ data: {
+ items: Array<{ productId: string; quantity: number }>;
+ userId: null;
+ shipping: null;
+ country: null;
+ legalConsent: {
+ termsAccepted: boolean;
+ privacyAccepted: boolean;
+ termsVersion: string;
+ privacyVersion: string;
+ };
+ };
+ }
+ | {
+ success: false;
+ error: {
+ issues: Array<{ path: Array; message: string }>;
+ format: () => unknown;
+ };
+ };
+
+function makeValidCheckoutPayloadParseResult(): MockCheckoutPayloadSafeParseResult {
+ return {
+ success: true,
+ data: {
+ items: [{ productId: 'prod_1', quantity: 1 }],
+ userId: null,
+ shipping: null,
+ country: null,
+ legalConsent: {
+ termsAccepted: true,
+ privacyAccepted: true,
+ termsVersion: 'terms-v1',
+ privacyVersion: 'privacy-v1',
+ },
+ },
+ };
+}
+const mockCheckoutPayloadSafeParse = vi.fn<
+ (input: unknown) => MockCheckoutPayloadSafeParseResult
+>(() => makeValidCheckoutPayloadParseResult());
vi.mock('@/lib/auth', () => ({
getCurrentUser: mockGetCurrentUser,
@@ -95,16 +139,7 @@ vi.mock('@/lib/shop/status-token', () => ({
vi.mock('@/lib/validation/shop', () => ({
checkoutPayloadSchema: {
- safeParse: vi.fn(() => ({
- success: true,
- data: {
- items: [{ productId: 'prod_1', quantity: 1 }],
- userId: null,
- shipping: null,
- country: null,
- legalConsent: null,
- },
- })),
+ safeParse: mockCheckoutPayloadSafeParse,
},
idempotencyKeySchema: {
safeParse: vi.fn((value: string) => ({
@@ -118,6 +153,10 @@ describe('checkout route - stripe disabled recovery', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
+ mockCheckoutPayloadSafeParse.mockReset();
+ mockCheckoutPayloadSafeParse.mockReturnValue(
+ makeValidCheckoutPayloadParseResult()
+ );
mockGetCurrentUser.mockResolvedValue(null);
mockGuardBrowserSameOrigin.mockReturnValue(null);
@@ -173,17 +212,24 @@ describe('checkout route - stripe disabled recovery', () => {
);
expect(mockCreateOrderWithItems).toHaveBeenCalledTimes(1);
- expect(mockCreateOrderWithItems).toHaveBeenCalledWith({
- items: [{ productId: 'prod_1', quantity: 1 }],
- idempotencyKey: 'idem_key_1234567890',
- userId: null,
- locale: 'en',
- country: null,
- shipping: null,
- legalConsent: null,
- paymentProvider: 'stripe',
- paymentMethod: 'stripe_card',
- });
+ expect(mockCreateOrderWithItems).toHaveBeenCalledWith(
+ expect.objectContaining({
+ items: [{ productId: 'prod_1', quantity: 1 }],
+ idempotencyKey: 'idem_key_1234567890',
+ userId: null,
+ locale: 'en',
+ country: null,
+ shipping: null,
+ legalConsent: {
+ termsAccepted: true,
+ privacyAccepted: true,
+ termsVersion: 'terms-v1',
+ privacyVersion: 'privacy-v1',
+ },
+ paymentProvider: 'stripe',
+ paymentMethod: 'stripe_card',
+ })
+ );
expect(mockEnsureStripePaymentIntentForOrder).not.toHaveBeenCalled();
});
@@ -224,17 +270,48 @@ describe('checkout route - stripe disabled recovery', () => {
expect(json.orderId).toBe('order_existing_default');
expect(json.paymentProvider).toBe('stripe');
expect(mockCreateOrderWithItems).toHaveBeenCalledTimes(1);
- expect(mockCreateOrderWithItems).toHaveBeenCalledWith({
- items: [{ productId: 'prod_1', quantity: 1 }],
- idempotencyKey: 'idem_key_1234567890',
- userId: null,
- locale: 'en',
- country: null,
- shipping: null,
- legalConsent: null,
- paymentProvider: 'stripe',
- paymentMethod: 'stripe_card',
+ expect(mockCreateOrderWithItems).toHaveBeenCalledWith(
+ expect.objectContaining({
+ items: [{ productId: 'prod_1', quantity: 1 }],
+ idempotencyKey: 'idem_key_1234567890',
+ userId: null,
+ locale: 'en',
+ country: null,
+ shipping: null,
+ legalConsent: {
+ termsAccepted: true,
+ privacyAccepted: true,
+ termsVersion: 'terms-v1',
+ privacyVersion: 'privacy-v1',
+ },
+ paymentProvider: 'stripe',
+ paymentMethod: 'stripe_card',
+ })
+ );
+ expect(mockEnsureStripePaymentIntentForOrder).not.toHaveBeenCalled();
+ });
+
+ it('missing legal consent returns a controlled validation error before checkout starts', async () => {
+ mockCheckoutPayloadSafeParse.mockReturnValue({
+ success: false,
+ error: {
+ issues: [{ path: ['legalConsent'], message: 'Required' }],
+ format: () => ({
+ legalConsent: {
+ _errors: ['Required'],
+ },
+ }),
+ },
});
+
+ const { POST } = await import('@/app/api/shop/checkout/route');
+
+ const response = await POST(makeRequest({ paymentProvider: 'stripe' }));
+ const json = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(json.code).toBe('LEGAL_CONSENT_REQUIRED');
+ expect(mockCreateOrderWithItems).not.toHaveBeenCalled();
expect(mockEnsureStripePaymentIntentForOrder).not.toHaveBeenCalled();
});
@@ -292,6 +369,7 @@ describe('checkout route - stripe disabled recovery', () => {
expect(mockCreateOrderWithItems).not.toHaveBeenCalled();
expect(mockEnsureStripePaymentIntentForOrder).not.toHaveBeenCalled();
});
+
it('stripe disabled + existing order with unknown payment status => returns 503', async () => {
mockFindExistingCheckoutOrderByIdempotencyKey.mockResolvedValue({
id: 'order_existing_bad_status',
diff --git a/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts b/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts
index 5184082e..77cea751 100644
--- a/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts
+++ b/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts
@@ -18,6 +18,8 @@ import {
} from '@/lib/services/errors';
import { createOrderWithItems } from '@/lib/services/orders';
+import { TEST_LEGAL_CONSENT } from './test-legal-consent';
+
type SeedData = {
productId: string;
cityRef: string;
@@ -148,6 +150,7 @@ describe('checkout shipping phase 3', () => {
locale: 'en-US',
country: 'UA',
items: [{ productId: seed.productId, quantity: 1 }],
+ legalConsent: TEST_LEGAL_CONSENT,
shipping: {
provider: 'nova_poshta',
methodCode: 'NP_WAREHOUSE',
@@ -214,6 +217,7 @@ describe('checkout shipping phase 3', () => {
locale: 'uk-UA',
country: 'UA',
items: [{ productId: seed.productId, quantity: 1 }],
+ legalConsent: TEST_LEGAL_CONSENT,
shipping: {
provider: 'nova_poshta',
methodCode: 'NP_WAREHOUSE',
@@ -260,6 +264,7 @@ describe('checkout shipping phase 3', () => {
locale: 'uk-UA',
country: 'UA',
items: [{ productId: seed.productId, quantity: 1 }],
+ legalConsent: TEST_LEGAL_CONSENT,
shipping: {
provider: 'nova_poshta',
methodCode: 'NP_LOCKER',
@@ -302,6 +307,7 @@ describe('checkout shipping phase 3', () => {
locale: 'uk-UA',
country: 'UA',
items: [{ productId: seed.productId, quantity: 1 }],
+ legalConsent: TEST_LEGAL_CONSENT,
shipping: {
provider: 'nova_poshta',
methodCode: 'NP_COURIER',
@@ -343,6 +349,7 @@ describe('checkout shipping phase 3', () => {
locale: 'uk-UA',
country: 'UA',
items: [{ productId: seed.productId, quantity: 1 }],
+ legalConsent: TEST_LEGAL_CONSENT,
shipping: {
provider: 'nova_poshta',
methodCode: 'NP_WAREHOUSE',
@@ -423,6 +430,7 @@ describe('checkout shipping phase 3', () => {
locale: 'uk-UA',
country: 'UA',
items: [{ productId: seed.productId, quantity: 1 }],
+ legalConsent: TEST_LEGAL_CONSENT,
shipping: {
provider: 'nova_poshta',
methodCode: 'NP_WAREHOUSE',
@@ -444,6 +452,7 @@ describe('checkout shipping phase 3', () => {
locale: 'uk-UA',
country: 'UA',
items: [{ productId: seed.productId, quantity: 1 }],
+ legalConsent: TEST_LEGAL_CONSENT,
shipping: {
provider: 'nova_poshta',
methodCode: 'NP_WAREHOUSE',
@@ -478,6 +487,7 @@ describe('checkout shipping phase 3', () => {
locale: 'uk-UA',
country: 'UA',
items: [{ productId: seed.productId, quantity: 1 }],
+ legalConsent: TEST_LEGAL_CONSENT,
shipping: {
provider: 'nova_poshta',
methodCode: 'NP_WAREHOUSE',
diff --git a/frontend/lib/tests/shop/legal-cookie-privacy-alignment-phase4.test.ts b/frontend/lib/tests/shop/legal-cookie-privacy-alignment-phase4.test.ts
new file mode 100644
index 00000000..f248313f
--- /dev/null
+++ b/frontend/lib/tests/shop/legal-cookie-privacy-alignment-phase4.test.ts
@@ -0,0 +1,136 @@
+import en from '@/messages/en.json';
+import pl from '@/messages/pl.json';
+import uk from '@/messages/uk.json';
+
+function getAtPath(
+ root: Record,
+ path: readonly string[]
+): unknown {
+ let current: unknown = root;
+
+ for (const segment of path) {
+ if (!current || typeof current !== 'object' || !(segment in current)) {
+ return undefined;
+ }
+
+ current = (current as Record)[segment];
+ }
+
+ return current;
+}
+
+const localeCases = [
+ ['en', en],
+ ['uk', uk],
+ ['pl', pl],
+] as const;
+
+const cookieAlignmentCases = [
+ {
+ locale: 'en',
+ messages: en,
+ bannerRequired: ['cookies and local storage', 'signed in', 'preferences'],
+ bannerForbidden: ['personalized content', 'analyze our traffic'],
+ privacyRequired: ['cookies/local storage', 'signed in', 'preferences'],
+ },
+ {
+ locale: 'uk',
+ messages: uk,
+ bannerRequired: [
+ 'cookie та локальне сховище',
+ 'авторизації',
+ 'налаштувань',
+ ],
+ bannerForbidden: ['персоналізації контенту', 'аналізу трафіку'],
+ privacyRequired: ['cookie/локальне сховище', 'авторизації', 'налаштувань'],
+ },
+ {
+ locale: 'pl',
+ messages: pl,
+ bannerRequired: [
+ 'plików cookie i lokalnego magazynu',
+ 'zalogowanego',
+ 'preferencje',
+ ],
+ bannerForbidden: ['spersonalizowane treści', 'analizować nasz ruch'],
+ privacyRequired: [
+ 'plików cookie/lokalnego magazynu',
+ 'zalogowanego',
+ 'preferencje',
+ ],
+ },
+] as const;
+
+describe('legal cookie/privacy alignment phase 4', () => {
+ it.each(localeCases)(
+ 'keeps checkout consent keys present for locale %s',
+ (_locale, messages) => {
+ expect(
+ getAtPath(messages as Record, [
+ 'shop',
+ 'cart',
+ 'checkout',
+ 'consent',
+ 'prefix',
+ ])
+ ).toBeTruthy();
+
+ expect(
+ getAtPath(messages as Record, [
+ 'shop',
+ 'cart',
+ 'checkout',
+ 'consent',
+ 'required',
+ ])
+ ).toBeTruthy();
+ }
+ );
+
+ it.each(cookieAlignmentCases)(
+ 'aligns cookie banner wording with privacy cookie wording for locale $locale',
+ ({
+ locale,
+ messages,
+ bannerRequired,
+ bannerForbidden,
+ privacyRequired,
+ }) => {
+ const banner = String(
+ getAtPath(messages as Record, [
+ 'CookieBanner',
+ 'description',
+ ]) ?? ''
+ );
+ const privacy = String(
+ getAtPath(messages as Record, [
+ 'legal',
+ 'privacy',
+ 'cookies',
+ 'content',
+ ]) ?? ''
+ );
+
+ for (const snippet of bannerRequired) {
+ expect(
+ banner,
+ `locale=${locale} banner should contain "${snippet}"`
+ ).toContain(snippet);
+ }
+
+ for (const snippet of bannerForbidden) {
+ expect(
+ banner,
+ `locale=${locale} banner should not contain "${snippet}"`
+ ).not.toContain(snippet);
+ }
+
+ for (const snippet of privacyRequired) {
+ expect(
+ privacy,
+ `locale=${locale} privacy content should contain "${snippet}"`
+ ).toContain(snippet);
+ }
+ }
+ );
+});
diff --git a/frontend/lib/tests/shop/order-items-variants.test.ts b/frontend/lib/tests/shop/order-items-variants.test.ts
index 9dc09c54..f3c2a90e 100644
--- a/frontend/lib/tests/shop/order-items-variants.test.ts
+++ b/frontend/lib/tests/shop/order-items-variants.test.ts
@@ -6,6 +6,8 @@ import { db } from '@/db';
import { orderItems, orders, productPrices, products } from '@/db/schema/shop';
import { createOrderWithItems } from '@/lib/services/orders';
+import { TEST_LEGAL_CONSENT } from './test-legal-consent';
+
describe('order_items variants (selected_size/selected_color)', () => {
it('creates two distinct order_items rows for same product with different variants', async () => {
const productId = crypto.randomUUID();
@@ -48,6 +50,7 @@ describe('order_items variants (selected_size/selected_color)', () => {
idempotencyKey: idem,
userId: null,
locale: 'en-US',
+ legalConsent: TEST_LEGAL_CONSENT,
items: [
{
productId,
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/lib/tests/shop/test-legal-consent.ts b/frontend/lib/tests/shop/test-legal-consent.ts
new file mode 100644
index 00000000..e1c026f5
--- /dev/null
+++ b/frontend/lib/tests/shop/test-legal-consent.ts
@@ -0,0 +1,6 @@
+export const TEST_LEGAL_CONSENT = {
+ termsAccepted: true,
+ privacyAccepted: true,
+ termsVersion: 'terms-2026-02-27',
+ privacyVersion: 'privacy-2026-02-27',
+} as const;
diff --git a/frontend/lib/validation/shop.ts b/frontend/lib/validation/shop.ts
index 40b66586..9a87186c 100644
--- a/frontend/lib/validation/shop.ts
+++ b/frontend/lib/validation/shop.ts
@@ -540,7 +540,7 @@ export const checkoutPayloadSchema = z
.transform(value => value.toUpperCase())
.optional(),
shipping: checkoutShippingSchema.optional(),
- legalConsent: checkoutLegalConsentSchema.optional(),
+ legalConsent: checkoutLegalConsentSchema,
pricingFingerprint: pricingFingerprintSchema.optional(),
shippingQuoteFingerprint: pricingFingerprintSchema.optional(),
paymentProvider: checkoutRequestedProviderSchema.optional(),
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index acf6bf60..a075c799 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"
},
@@ -396,8 +400,8 @@
"shipping": "Shipping",
"shippingCalc": "Calculated at checkout",
"total": "Total",
- "shippingInformationalOnly": "Informational only",
- "shippingPayOnDeliveryNote": "Shipping is paid to the carrier on delivery; we currently charge only for goods."
+ "shippingInformationalOnly": "Shown before confirmation",
+ "shippingPayOnDeliveryNote": "When applicable, the shipping cost is included in the total shown before order confirmation. Additional carrier charges for extra services may still apply separately under the carrier's rules."
},
"delivery": {
"legend": "Delivery",
@@ -478,6 +482,14 @@
"message": "You'll either be redirected to secure payment or see confirmation if payment is not required in this environment",
"notRedirected": "If you are not redirected automatically, open your order",
"goToOrder": "Go to order",
+ "consent": {
+ "prefix": "I have read and agree to the",
+ "termsLink": "Terms of Service",
+ "and": "and",
+ "privacyLink": "Privacy Policy",
+ "suffix": " for this order.",
+ "required": "Please confirm that you agree to the Terms of Service and Privacy Policy before placing your order."
+ },
"errors": {
"unexpectedResponse": "Unexpected checkout response.",
"startFailed": "Unable to start checkout right now."
@@ -1256,10 +1268,10 @@
},
"CookieBanner": {
"title": "🍪 We value your privacy",
- "description": "We use cookies to enhance your browsing experience, serve personalized content, and analyze our traffic. By clicking \"Accept\", you consent to our use of cookies. Read our",
+ "description": "We use cookies and local storage to keep you signed in, remember preferences such as theme, and protect the service. Read our",
"policyLink": "Privacy Policy",
- "accept": "Accept Cookies",
- "decline": "Decline"
+ "accept": "Got it",
+ "decline": "Close"
},
"leaderboard": {
"title": "Leaderboard",
@@ -1695,7 +1707,101 @@
"legal": {
"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 before order confirmation. 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 before order confirmation 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 before order confirmation 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.",
@@ -1721,7 +1827,7 @@
},
"cookies": {
"title": "4. Cookies and similar technologies",
- "content": "We may use cookies/local storage to keep you signed in, store preferences (e.g. theme), and protect the service. If we add analytics cookies, this section will be updated."
+ "content": "We use cookies/local storage to keep you signed in, store preferences (e.g. theme), and protect the service. If we add analytics cookies, this section will be updated."
},
"sharing": {
"title": "5. Sharing of data",
diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json
index 87c60c51..9a25352d 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": "Polityka płatności",
+ "delivery": "Polityka dostawy",
+ "returns": "Polityka zwrotów",
"privacyPolicy": "Polityka Prywatności",
"termsOfService": "Warunki Świadczenia Usług"
},
@@ -396,8 +400,8 @@
"shipping": "Wysyłka",
"shippingCalc": "Obliczone przy kasie",
"total": "Razem",
- "shippingInformationalOnly": "Tylko informacyjnie",
- "shippingPayOnDeliveryNote": "Dostawa jest opłacana przewoźnikowi przy odbiorze; obecnie pobieramy opłatę tylko za produkty."
+ "shippingInformationalOnly": "Pokazywane przed potwierdzeniem",
+ "shippingPayOnDeliveryNote": "Koszt dostawy, jeśli ma zastosowanie, jest doliczany podczas składania zamówienia i pokazywany w sumie przed potwierdzeniem zamówienia."
},
"delivery": {
"legend": "Dostawa",
@@ -478,6 +482,14 @@
"message": "Zostaniesz przekierowany do bezpiecznej płatności lub zobaczysz potwierdzenie, jeśli płatność nie jest wymagana w tym środowisku",
"notRedirected": "Jeśli nie zostałeś automatycznie przekierowany, otwórz swoje zamówienie",
"goToOrder": "Przejdź do zamówienia",
+ "consent": {
+ "prefix": "Potwierdzam akceptację",
+ "termsLink": "Warunki świadczenia usług",
+ "and": "oraz",
+ "privacyLink": "Politykę prywatności",
+ "suffix": " dla tego zamówienia.",
+ "required": "Potwierdź akceptację Warunków świadczenia usług i Polityki prywatności przed złożeniem zamówienia."
+ },
"errors": {
"unexpectedResponse": "Nieoczekiwana odpowiedź checkout.",
"startFailed": "Nie można teraz rozpocząć checkoutu."
@@ -1256,10 +1268,10 @@
},
"CookieBanner": {
"title": "🍪 Dbamy o Twoją prywatność",
- "description": "Używamy plików cookie, aby poprawić jakość przeglądania, wyświetlać spersonalizowane treści i analizować nasz ruch. Klikając „Zaakceptuj\", wyrażasz zgodę na używanie plików cookie. Przeczytaj naszą",
+ "description": "Używamy plików cookie i lokalnego magazynu, aby utrzymać Cię zalogowanego, zapamiętywać preferencje, takie jak motyw, i chronić usługę. Przeczytaj naszą",
"policyLink": "Politykę Prywatności",
- "accept": "Zaakceptuj",
- "decline": "Odrzuć"
+ "accept": "Rozumiem",
+ "decline": "Zamknij"
},
"leaderboard": {
"title": "Ranking",
@@ -1697,7 +1709,101 @@
"legal": {
"back": "Wróć",
"lastUpdated": "Ostatnia aktualizacja",
- "contactEmail": "devlovers.net@gmail.com",
+ "seller": {
+ "metaTitle": "Informacje o sprzedawcy | Sklep DevLovers",
+ "metaDescription": "Publiczne dane sprzedawcy i dane kontaktowe 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.",
@@ -1723,7 +1829,7 @@
},
"cookies": {
"title": "4. Pliki cookie i podobne technologie",
- "content": "Możemy używać plików cookie/lokalnego magazynu, aby utrzymać Cię zalogowanego, przechowywać preferencje (np. motyw) i chronić usługę. Jeśli dodamy analityczne pliki cookie, zaktualizujemy tę sekcję."
+ "content": "Używamy plików cookie/lokalnego magazynu, aby utrzymać Cię zalogowanego, przechowywać preferencje (np. motyw) i chronić usługę. Jeśli dodamy analityczne pliki cookie, zaktualizujemy tę sekcję."
},
"sharing": {
"title": "5. Udostępnianie danych",
diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json
index eed30dba..48d4f159 100644
--- a/frontend/messages/uk.json
+++ b/frontend/messages/uk.json
@@ -322,6 +322,10 @@
"footer": {
"builtWith": "Створено",
"byCommunity": "спільнотою.",
+ "sellerInformation": "Інформація про продавця",
+ "payment": "Оплата",
+ "delivery": "Доставка",
+ "returns": "Повернення",
"privacyPolicy": "Політика Конфіденційності",
"termsOfService": "Умови Використання"
},
@@ -396,8 +400,8 @@
"shipping": "Доставка",
"shippingCalc": "Розраховується при оформленні",
"total": "Всього",
- "shippingInformationalOnly": "Лише інформаційно",
- "shippingPayOnDeliveryNote": "Доставка оплачується перевізнику при отриманні; зараз списуємо лише товари."
+ "shippingInformationalOnly": "Показується до підтвердження",
+ "shippingPayOnDeliveryNote": "Якщо доставка застосовується, її вартість додається під час оформлення замовлення і входить до суми, яку ви бачите до підтвердження замовлення. Додаткові платежі перевізнику за окремі послуги можуть стягуватися окремо за його правилами."
},
"delivery": {
"legend": "Доставка",
@@ -426,7 +430,7 @@
"placeholder": "Почніть вводити назву міста (мін. 2 символи)",
"selected": "Обране місто: {city}",
"searching": "Пошук міст...",
- "noResults": "Міста не знайдено. Перевірте назву або локальні дані Nova Poshta."
+ "noResults": "Міста не знайдено. Перевірте назву або локальні дані Нової пошти."
},
"warehouse": {
"label": "Відділення / поштомат",
@@ -460,15 +464,15 @@
"methodCards": {
"warehouse": {
"title": "Відділення",
- "description": "Самовивіз із відділення Nova Poshta"
+ "description": "Самовивіз із відділення Нової пошти"
},
"locker": {
"title": "Поштомат",
- "description": "Самовивіз із поштомату Nova Poshta"
+ "description": "Самовивіз із поштомата Нової пошти"
},
"courier": {
"title": "Кур’єр",
- "description": "Доставка Nova Poshta до дверей"
+ "description": "Кур’єрська доставка Новою поштою"
}
}
},
@@ -478,6 +482,14 @@
"message": "Ви будете перенаправлені на безпечну оплату або побачите підтвердження, якщо оплата не потрібна в цьому середовищі",
"notRedirected": "Якщо ви не були автоматично перенаправлені, відкрийте ваше замовлення",
"goToOrder": "Перейти до замовлення",
+ "consent": {
+ "prefix": "Я ознайомився(-лася) та погоджуюся з",
+ "termsLink": "Умовами використання",
+ "and": "та",
+ "privacyLink": "Політикою конфіденційності",
+ "suffix": " для цього замовлення.",
+ "required": "Підтвердьте, будь ласка, згоду з Умовами використання та Політикою конфіденційності перед оформленням замовлення."
+ },
"errors": {
"unexpectedResponse": "Неочікувана відповідь оформлення замовлення.",
"startFailed": "Наразі неможливо розпочати оформлення замовлення."
@@ -1256,10 +1268,10 @@
},
"CookieBanner": {
"title": "🍪 Ми цінуємо вашу приватність",
- "description": "Ми використовуємо файли cookie для покращення вашого досвіду, персоналізації контенту та аналізу трафіку. Натискаючи \"Прийняти\", ви погоджуєтесь на використання cookie. Читайте нашу",
+ "description": "Ми використовуємо cookie та локальне сховище для підтримки авторизації, збереження налаштувань, зокрема теми, і захисту сервісу. Читайте нашу",
"policyLink": "Політику конфіденційності",
- "accept": "Прийняти",
- "decline": "Відхилити"
+ "accept": "Зрозуміло",
+ "decline": "Закрити"
},
"leaderboard": {
"title": "Рейтинг",
@@ -1697,7 +1709,101 @@
"legal": {
"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": "Самовивіз із відділення Нової пошти",
+ "locker": "Самовивіз із поштомата Нової пошти",
+ "courier": "Кур’єрська доставка Новою поштою"
+ }
+ },
+ "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 збирає, використовує та захищає ваші персональні дані.",
@@ -1723,7 +1829,7 @@
},
"cookies": {
"title": "4. Файли cookie та подібні технології",
- "content": "Ми можемо використовувати cookie/локальне сховище для підтримки авторизації, збереження налаштувань (наприклад, теми) та захисту сервісу. Якщо ми додамо аналітичні cookie, ми оновимо цей розділ."
+ "content": "Ми використовуємо cookie/локальне сховище для підтримки авторизації, збереження налаштувань (наприклад, теми) та захисту сервісу. Якщо ми додамо аналітичні cookie, ми оновимо цей розділ."
},
"sharing": {
"title": "5. Передача даних",