@rovela-ai/sdk 0.19.4 → 0.19.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/changelog/README.md +53 -0
- package/docs/changelog/epoch-01-chrome-consent.md +63 -0
- package/docs/changelog/epoch-02-platform-commerce.md +108 -0
- package/docs/changelog/epoch-03-i18n-files.md +72 -0
- package/docs/changelog/epoch-04-emails.md +65 -0
- package/docs/changelog/epoch-05-admin-experience.md +98 -0
- package/docs/changelog/epoch-06-mobile-integrity.md +72 -0
- package/docs/design/admin-ledger-visual-universe.html +1961 -0
- package/docs/design/admin-sidebar-and-band.html +542 -0
- package/package.json +3 -1
- package/templates/store-template/.claude/skills/add-section/SKILL.md +438 -0
- package/templates/store-template/.claude/skills/build-feature/SKILL.md +201 -0
- package/templates/store-template/.claude/skills/customize-theme/SKILL.md +167 -0
- package/templates/store-template/.claude/skills/debug-errors/SKILL.md +68 -0
- package/templates/store-template/.claude/skills/frontend-design/SKILL.md +176 -0
- package/templates/store-template/.claude/skills/generate-images/SKILL.md +115 -0
- package/templates/store-template/.claude/skills/manage-products/SKILL.md +187 -0
- package/templates/store-template/.claude/skills/override-sdk-component/SKILL.md +154 -0
- package/templates/store-template/.env.example +50 -0
- package/templates/store-template/.mcp.json +8 -0
- package/templates/store-template/.rovela/blueprint.example.json +190 -0
- package/templates/store-template/CLAUDE.md +14 -0
- package/templates/store-template/app/LayoutContent.tsx +106 -0
- package/templates/store-template/app/[...path]/page.tsx +79 -0
- package/templates/store-template/app/account/addresses/page.tsx +45 -0
- package/templates/store-template/app/account/layout.tsx +17 -0
- package/templates/store-template/app/account/orders/[id]/invoice/page.tsx +277 -0
- package/templates/store-template/app/account/orders/[id]/page.tsx +725 -0
- package/templates/store-template/app/account/orders/page.tsx +153 -0
- package/templates/store-template/app/account/page.tsx +184 -0
- package/templates/store-template/app/account/profile/page.tsx +434 -0
- package/templates/store-template/app/admin/(dashboard)/account/page.tsx +16 -0
- package/templates/store-template/app/admin/(dashboard)/analytics/events/page.tsx +14 -0
- package/templates/store-template/app/admin/(dashboard)/analytics/layout.tsx +48 -0
- package/templates/store-template/app/admin/(dashboard)/analytics/page.tsx +15 -0
- package/templates/store-template/app/admin/(dashboard)/analytics/visitors/page.tsx +14 -0
- package/templates/store-template/app/admin/(dashboard)/categories/[id]/page.tsx +38 -0
- package/templates/store-template/app/admin/(dashboard)/categories/new/page.tsx +35 -0
- package/templates/store-template/app/admin/(dashboard)/categories/page.tsx +223 -0
- package/templates/store-template/app/admin/(dashboard)/customers/[id]/page.tsx +26 -0
- package/templates/store-template/app/admin/(dashboard)/customers/page.tsx +33 -0
- package/templates/store-template/app/admin/(dashboard)/discounts/page.tsx +16 -0
- package/templates/store-template/app/admin/(dashboard)/emails/page.tsx +16 -0
- package/templates/store-template/app/admin/(dashboard)/files/page.tsx +16 -0
- package/templates/store-template/app/admin/(dashboard)/layout.tsx +44 -0
- package/templates/store-template/app/admin/(dashboard)/loading.tsx +88 -0
- package/templates/store-template/app/admin/(dashboard)/locations/page.tsx +16 -0
- package/templates/store-template/app/admin/(dashboard)/orders/[id]/page.tsx +66 -0
- package/templates/store-template/app/admin/(dashboard)/orders/page.tsx +26 -0
- package/templates/store-template/app/admin/(dashboard)/page.tsx +146 -0
- package/templates/store-template/app/admin/(dashboard)/payments/page.tsx +15 -0
- package/templates/store-template/app/admin/(dashboard)/products/[id]/page.tsx +38 -0
- package/templates/store-template/app/admin/(dashboard)/products/new/page.tsx +35 -0
- package/templates/store-template/app/admin/(dashboard)/products/page.tsx +31 -0
- package/templates/store-template/app/admin/(dashboard)/settings/page.tsx +46 -0
- package/templates/store-template/app/admin/(dashboard)/shipping/page.tsx +15 -0
- package/templates/store-template/app/admin/(dashboard)/tax/page.tsx +15 -0
- package/templates/store-template/app/admin/(dashboard)/users/[id]/page.tsx +114 -0
- package/templates/store-template/app/admin/(dashboard)/users/page.tsx +16 -0
- package/templates/store-template/app/admin/accept-invite/page.tsx +36 -0
- package/templates/store-template/app/admin/forgot-password/page.tsx +13 -0
- package/templates/store-template/app/admin/layout.tsx +47 -0
- package/templates/store-template/app/admin/login/page.tsx +528 -0
- package/templates/store-template/app/admin/reset-password/page.tsx +36 -0
- package/templates/store-template/app/api/account/addresses/[id]/route.ts +10 -0
- package/templates/store-template/app/api/account/addresses/route.ts +10 -0
- package/templates/store-template/app/api/account/change-password/route.ts +107 -0
- package/templates/store-template/app/api/account/orders/[id]/refund/route.ts +11 -0
- package/templates/store-template/app/api/account/orders/[id]/return/route.ts +14 -0
- package/templates/store-template/app/api/account/orders/[id]/route.ts +42 -0
- package/templates/store-template/app/api/account/orders/route.ts +29 -0
- package/templates/store-template/app/api/account/profile/route.ts +165 -0
- package/templates/store-template/app/api/admin/accept-invite/route.ts +11 -0
- package/templates/store-template/app/api/admin/analytics/events/route.ts +9 -0
- package/templates/store-template/app/api/admin/analytics/route.ts +10 -0
- package/templates/store-template/app/api/admin/analytics/visitors/route.ts +9 -0
- package/templates/store-template/app/api/admin/branding/favicon/route.ts +9 -0
- package/templates/store-template/app/api/admin/branding/logo/route.ts +9 -0
- package/templates/store-template/app/api/admin/categories/[id]/route.ts +9 -0
- package/templates/store-template/app/api/admin/categories/route.ts +9 -0
- package/templates/store-template/app/api/admin/check/route.ts +10 -0
- package/templates/store-template/app/api/admin/customer-groups/[id]/route.ts +10 -0
- package/templates/store-template/app/api/admin/customer-groups/route.ts +10 -0
- package/templates/store-template/app/api/admin/customers/[id]/addresses/[addressId]/route.ts +15 -0
- package/templates/store-template/app/api/admin/customers/[id]/addresses/route.ts +15 -0
- package/templates/store-template/app/api/admin/customers/[id]/route.ts +11 -0
- package/templates/store-template/app/api/admin/customers/bulk/route.ts +8 -0
- package/templates/store-template/app/api/admin/customers/route.ts +9 -0
- package/templates/store-template/app/api/admin/discounts/[id]/route.ts +11 -0
- package/templates/store-template/app/api/admin/discounts/route.ts +10 -0
- package/templates/store-template/app/api/admin/emails/preview/route.ts +4 -0
- package/templates/store-template/app/api/admin/emails/route.ts +5 -0
- package/templates/store-template/app/api/admin/emails/test/route.ts +4 -0
- package/templates/store-template/app/api/admin/files/route.ts +8 -0
- package/templates/store-template/app/api/admin/forgot-password/route.ts +7 -0
- package/templates/store-template/app/api/admin/linked-customer/route.ts +11 -0
- package/templates/store-template/app/api/admin/locations/[id]/route.ts +10 -0
- package/templates/store-template/app/api/admin/locations/route.ts +10 -0
- package/templates/store-template/app/api/admin/me/password/route.ts +13 -0
- package/templates/store-template/app/api/admin/me/route.ts +11 -0
- package/templates/store-template/app/api/admin/orders/[id]/refund/route.ts +7 -0
- package/templates/store-template/app/api/admin/orders/[id]/return/approve/route.ts +7 -0
- package/templates/store-template/app/api/admin/orders/[id]/return/reject/route.ts +7 -0
- package/templates/store-template/app/api/admin/orders/[id]/route.ts +9 -0
- package/templates/store-template/app/api/admin/orders/[id]/ship-with-shippo/route.ts +6 -0
- package/templates/store-template/app/api/admin/orders/[id]/shippo-rates/route.ts +6 -0
- package/templates/store-template/app/api/admin/orders/bulk/route.ts +8 -0
- package/templates/store-template/app/api/admin/orders/route.ts +9 -0
- package/templates/store-template/app/api/admin/payment-methods/[id]/route.ts +10 -0
- package/templates/store-template/app/api/admin/payment-methods/route.ts +10 -0
- package/templates/store-template/app/api/admin/products/[id]/route.ts +9 -0
- package/templates/store-template/app/api/admin/products/bulk/route.ts +9 -0
- package/templates/store-template/app/api/admin/products/route.ts +9 -0
- package/templates/store-template/app/api/admin/products/stats/route.ts +10 -0
- package/templates/store-template/app/api/admin/reset-password/route.ts +10 -0
- package/templates/store-template/app/api/admin/settings/route.ts +11 -0
- package/templates/store-template/app/api/admin/setup/route.ts +10 -0
- package/templates/store-template/app/api/admin/setup-guide/manual-mark/route.ts +13 -0
- package/templates/store-template/app/api/admin/setup-guide/route.ts +10 -0
- package/templates/store-template/app/api/admin/shipping/carriers/[id]/route.ts +9 -0
- package/templates/store-template/app/api/admin/shipping/carriers/route.ts +9 -0
- package/templates/store-template/app/api/admin/shipping/rates/[id]/route.ts +9 -0
- package/templates/store-template/app/api/admin/shipping/rates/route.ts +9 -0
- package/templates/store-template/app/api/admin/shipping/shippo/api-key/route.ts +1 -0
- package/templates/store-template/app/api/admin/shipping/shippo/validate/route.ts +1 -0
- package/templates/store-template/app/api/admin/shipping/zones/[id]/route.ts +9 -0
- package/templates/store-template/app/api/admin/shipping/zones/route.ts +9 -0
- package/templates/store-template/app/api/admin/stats/route.ts +9 -0
- package/templates/store-template/app/api/admin/stripe-status/route.ts +1 -0
- package/templates/store-template/app/api/admin/tax-zones/[id]/route.ts +7 -0
- package/templates/store-template/app/api/admin/tax-zones/route.ts +7 -0
- package/templates/store-template/app/api/admin/users/[id]/route.ts +14 -0
- package/templates/store-template/app/api/admin/users/route.ts +12 -0
- package/templates/store-template/app/api/admin-auth/[...nextauth]/route.ts +17 -0
- package/templates/store-template/app/api/analytics/track/route.ts +12 -0
- package/templates/store-template/app/api/auth/[...nextauth]/route.ts +19 -0
- package/templates/store-template/app/api/auth/forgot-password/route.ts +9 -0
- package/templates/store-template/app/api/auth/register/route.ts +9 -0
- package/templates/store-template/app/api/auth/resend-verification/route.ts +9 -0
- package/templates/store-template/app/api/auth/reset-password/route.ts +10 -0
- package/templates/store-template/app/api/auth/verify-email/route.ts +10 -0
- package/templates/store-template/app/api/categories/route.ts +10 -0
- package/templates/store-template/app/api/checkout/countries/route.ts +7 -0
- package/templates/store-template/app/api/checkout/discount/route.ts +9 -0
- package/templates/store-template/app/api/checkout/manual/route.ts +10 -0
- package/templates/store-template/app/api/checkout/payment-methods/route.ts +8 -0
- package/templates/store-template/app/api/checkout/pickup-locations/route.ts +8 -0
- package/templates/store-template/app/api/checkout/route.ts +10 -0
- package/templates/store-template/app/api/checkout/shipping/route.ts +21 -0
- package/templates/store-template/app/api/checkout/status/route.ts +10 -0
- package/templates/store-template/app/api/contact/route.ts +164 -0
- package/templates/store-template/app/api/media/presign/route.ts +7 -0
- package/templates/store-template/app/api/media/route.ts +7 -0
- package/templates/store-template/app/api/products/[slug]/route.ts +10 -0
- package/templates/store-template/app/api/products/route.ts +10 -0
- package/templates/store-template/app/api/store/settings/route.ts +12 -0
- package/templates/store-template/app/api/webhooks/stripe/route.ts +63 -0
- package/templates/store-template/app/auth/forgot-password/page.tsx +38 -0
- package/templates/store-template/app/auth/layout.tsx +17 -0
- package/templates/store-template/app/auth/reset-password/page.tsx +81 -0
- package/templates/store-template/app/auth/signin/page.tsx +62 -0
- package/templates/store-template/app/auth/signup/page.tsx +38 -0
- package/templates/store-template/app/auth/verify-email/page.tsx +185 -0
- package/templates/store-template/app/cart/page.tsx +137 -0
- package/templates/store-template/app/checkout/page.tsx +88 -0
- package/templates/store-template/app/checkout/success/page.tsx +50 -0
- package/templates/store-template/app/contact/layout.tsx +24 -0
- package/templates/store-template/app/contact/page.tsx +321 -0
- package/templates/store-template/app/faq/page.tsx +153 -0
- package/templates/store-template/app/globals.css +99 -0
- package/templates/store-template/app/layout.tsx +269 -0
- package/templates/store-template/app/not-found.tsx +35 -0
- package/templates/store-template/app/page.tsx +23 -0
- package/templates/store-template/app/privacy/page.tsx +281 -0
- package/templates/store-template/app/products/[slug]/ProductDetailClient.tsx +179 -0
- package/templates/store-template/app/products/[slug]/page.tsx +149 -0
- package/templates/store-template/app/products/layout.tsx +26 -0
- package/templates/store-template/app/products/page.tsx +102 -0
- package/templates/store-template/app/robots.ts +25 -0
- package/templates/store-template/app/sitemap.ts +76 -0
- package/templates/store-template/app/terms/page.tsx +313 -0
- package/templates/store-template/components/PathSyncBridge.tsx +61 -0
- package/templates/store-template/components/Providers.tsx +37 -0
- package/templates/store-template/components/layout/Footer.tsx +150 -0
- package/templates/store-template/components/layout/Header.tsx +162 -0
- package/templates/store-template/components/sections/.gitkeep +7 -0
- package/templates/store-template/components/sections/CTA.tsx +51 -0
- package/templates/store-template/components/sections/Features.tsx +91 -0
- package/templates/store-template/components/sections/Hero.tsx +85 -0
- package/templates/store-template/eslint.config.mjs +18 -0
- package/templates/store-template/lib/store-config.ts +77 -0
- package/templates/store-template/lib/store-policy.ts +68 -0
- package/templates/store-template/lib/utils.ts +21 -0
- package/templates/store-template/next-env.d.ts +6 -0
- package/templates/store-template/next.config.js +105 -0
- package/templates/store-template/package.json +32 -0
- package/templates/store-template/postcss.config.js +6 -0
- package/templates/store-template/proxy.ts +150 -0
- package/templates/store-template/public/rovela-logo.png +0 -0
- package/templates/store-template/tailwind.config.ts +74 -0
- package/templates/store-template/tsconfig.json +41 -0
- package/templates/store-template/types/next-auth.d.ts +32 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { LocaleLink as Link } from '@rovela-ai/sdk/core'
|
|
3
|
+
import { getBlueprint, localizedAlternates, findSettings } from '@rovela-ai/sdk/core/server'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Privacy Policy Page
|
|
7
|
+
*
|
|
8
|
+
* Static privacy policy page with standard e-commerce sections.
|
|
9
|
+
* Store name is pulled from blueprint for personalization.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Metadata
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
17
|
+
let storeName = 'Our Store'
|
|
18
|
+
try {
|
|
19
|
+
const blueprint = getBlueprint()
|
|
20
|
+
storeName = blueprint?.store?.name || process.env.STORE_NAME || storeName
|
|
21
|
+
} catch {
|
|
22
|
+
// Blueprint might not exist
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
title: 'Privacy Policy',
|
|
27
|
+
description: `Privacy Policy for ${storeName}. Learn how we collect, use, and protect your personal information.`,
|
|
28
|
+
alternates: await localizedAlternates('/privacy'),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// Page Component
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
export default async function PrivacyPage() {
|
|
37
|
+
// Get store name for display — LIVE settings first (store_settings is what
|
|
38
|
+
// the merchant edits in /admin; blueprint + env are build-time fallbacks
|
|
39
|
+
// that go stale after a rename). Fail-open on DB errors.
|
|
40
|
+
let storeName = 'Our Store'
|
|
41
|
+
let supportEmail = ''
|
|
42
|
+
const liveSettings = await findSettings().catch(() => null)
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const blueprint = getBlueprint()
|
|
46
|
+
storeName =
|
|
47
|
+
liveSettings?.storeName || blueprint?.store?.name || process.env.STORE_NAME || storeName
|
|
48
|
+
} catch {
|
|
49
|
+
storeName = liveSettings?.storeName || process.env.STORE_NAME || storeName
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Public contact email = the merchant's own address only. NEVER the platform
|
|
53
|
+
// transactional sender (RESEND_FROM_EMAIL): surfacing that put
|
|
54
|
+
// "contact@rovela.ai" on storefront legal pages and read as a template
|
|
55
|
+
// artifact. Falls back to support@<your-domain> derived from the live host;
|
|
56
|
+
// if only a *.rovela.app / *.vercel.app / localhost host is available it stays
|
|
57
|
+
// empty and the email line is hidden (the contact-page link still renders).
|
|
58
|
+
const storeHost = (process.env.NEXT_PUBLIC_APP_URL || '')
|
|
59
|
+
.replace(/^https?:\/\//, '')
|
|
60
|
+
.replace(/\/.*$/, '')
|
|
61
|
+
.trim()
|
|
62
|
+
const hasOwnDomain =
|
|
63
|
+
!!storeHost &&
|
|
64
|
+
!storeHost.endsWith('rovela.app') &&
|
|
65
|
+
!storeHost.endsWith('vercel.app') &&
|
|
66
|
+
!storeHost.startsWith('localhost')
|
|
67
|
+
// Live store email first — this is the address the merchant actually set
|
|
68
|
+
// (emails, contact page and JSON-LD already read it); SUPPORT_EMAIL env is
|
|
69
|
+
// the build-time fallback, support@<domain> the last resort.
|
|
70
|
+
supportEmail =
|
|
71
|
+
liveSettings?.storeEmail?.trim() ||
|
|
72
|
+
process.env.SUPPORT_EMAIL?.trim() ||
|
|
73
|
+
(hasOwnDomain ? `support@${storeHost}` : '')
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
|
77
|
+
<div className="max-w-3xl mx-auto">
|
|
78
|
+
{/* Header */}
|
|
79
|
+
<div className="mb-12">
|
|
80
|
+
<h1 className="text-3xl font-bold font-heading">Privacy Policy</h1>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Content */}
|
|
84
|
+
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
|
85
|
+
{/* Introduction */}
|
|
86
|
+
<section className="mb-10">
|
|
87
|
+
<p className="text-muted-foreground leading-relaxed">
|
|
88
|
+
At {storeName}, we are committed to protecting your privacy and ensuring the
|
|
89
|
+
security of your personal information. This Privacy Policy explains how we collect,
|
|
90
|
+
use, disclose, and safeguard your information when you visit our website or make
|
|
91
|
+
a purchase.
|
|
92
|
+
</p>
|
|
93
|
+
</section>
|
|
94
|
+
|
|
95
|
+
{/* Information We Collect */}
|
|
96
|
+
<section className="mb-10">
|
|
97
|
+
<h2 className="text-xl font-semibold font-heading mb-4">
|
|
98
|
+
Information We Collect
|
|
99
|
+
</h2>
|
|
100
|
+
<p className="text-muted-foreground mb-4">
|
|
101
|
+
We collect information that you provide directly to us, including:
|
|
102
|
+
</p>
|
|
103
|
+
<ul className="list-disc pl-6 space-y-2 text-muted-foreground">
|
|
104
|
+
<li>Name and contact information (email address, phone number, shipping address)</li>
|
|
105
|
+
<li>Payment information (processed securely through our payment provider)</li>
|
|
106
|
+
<li>Order history and preferences</li>
|
|
107
|
+
<li>Communications you send to us</li>
|
|
108
|
+
<li>Account information if you create an account</li>
|
|
109
|
+
</ul>
|
|
110
|
+
<p className="text-muted-foreground mt-4">
|
|
111
|
+
We also automatically collect certain information when you visit our website,
|
|
112
|
+
including your IP address, browser type, device information, and browsing behavior.
|
|
113
|
+
</p>
|
|
114
|
+
</section>
|
|
115
|
+
|
|
116
|
+
{/* How We Use Your Information */}
|
|
117
|
+
<section className="mb-10">
|
|
118
|
+
<h2 className="text-xl font-semibold font-heading mb-4">
|
|
119
|
+
How We Use Your Information
|
|
120
|
+
</h2>
|
|
121
|
+
<p className="text-muted-foreground mb-4">
|
|
122
|
+
We use the information we collect to:
|
|
123
|
+
</p>
|
|
124
|
+
<ul className="list-disc pl-6 space-y-2 text-muted-foreground">
|
|
125
|
+
<li>Process and fulfill your orders</li>
|
|
126
|
+
<li>Send you order confirmations and shipping updates</li>
|
|
127
|
+
<li>Respond to your questions and provide customer support</li>
|
|
128
|
+
<li>Send promotional communications (with your consent)</li>
|
|
129
|
+
<li>Improve our website and services</li>
|
|
130
|
+
<li>Detect and prevent fraud</li>
|
|
131
|
+
<li>Comply with legal obligations</li>
|
|
132
|
+
</ul>
|
|
133
|
+
</section>
|
|
134
|
+
|
|
135
|
+
{/* Information Sharing */}
|
|
136
|
+
<section className="mb-10">
|
|
137
|
+
<h2 className="text-xl font-semibold font-heading mb-4">
|
|
138
|
+
Information Sharing
|
|
139
|
+
</h2>
|
|
140
|
+
<p className="text-muted-foreground mb-4">
|
|
141
|
+
We do not sell your personal information. We may share your information with:
|
|
142
|
+
</p>
|
|
143
|
+
<ul className="list-disc pl-6 space-y-2 text-muted-foreground">
|
|
144
|
+
<li>Service providers who assist in our operations (payment processors, shipping carriers)</li>
|
|
145
|
+
<li>Analytics providers to help us understand website usage</li>
|
|
146
|
+
<li>Legal authorities when required by law</li>
|
|
147
|
+
</ul>
|
|
148
|
+
</section>
|
|
149
|
+
|
|
150
|
+
{/* Data Security */}
|
|
151
|
+
<section className="mb-10">
|
|
152
|
+
<h2 className="text-xl font-semibold font-heading mb-4">
|
|
153
|
+
Data Security
|
|
154
|
+
</h2>
|
|
155
|
+
<p className="text-muted-foreground">
|
|
156
|
+
We implement appropriate technical and organizational measures to protect your
|
|
157
|
+
personal information against unauthorized access, alteration, disclosure, or
|
|
158
|
+
destruction. All payment transactions are encrypted using SSL technology and
|
|
159
|
+
processed through secure payment providers.
|
|
160
|
+
</p>
|
|
161
|
+
</section>
|
|
162
|
+
|
|
163
|
+
{/* Cookies */}
|
|
164
|
+
<section id="cookies" className="mb-10 scroll-mt-24">
|
|
165
|
+
<h2 className="text-xl font-semibold font-heading mb-4">
|
|
166
|
+
Cookies and Tracking
|
|
167
|
+
</h2>
|
|
168
|
+
<p className="text-muted-foreground mb-4">
|
|
169
|
+
We use cookies and similar technologies to make this store work
|
|
170
|
+
and, with your consent, to understand how it is used and show
|
|
171
|
+
you relevant content. Cookies are grouped into three categories:
|
|
172
|
+
</p>
|
|
173
|
+
<ul className="list-disc pl-6 space-y-2 text-muted-foreground">
|
|
174
|
+
<li>
|
|
175
|
+
<strong className="text-foreground">Essential.</strong> Required
|
|
176
|
+
for the store to work — including your cart, session, and
|
|
177
|
+
sign-in state. Always on; cannot be disabled.
|
|
178
|
+
</li>
|
|
179
|
+
<li>
|
|
180
|
+
<strong className="text-foreground">Analytics.</strong> Help us
|
|
181
|
+
understand how the store is used so we can improve it.
|
|
182
|
+
Optional; off unless you choose to allow them.
|
|
183
|
+
</li>
|
|
184
|
+
<li>
|
|
185
|
+
<strong className="text-foreground">Marketing.</strong> Used to
|
|
186
|
+
measure ads and show you relevant content across sites.
|
|
187
|
+
Optional; off unless you choose to allow them.
|
|
188
|
+
</li>
|
|
189
|
+
</ul>
|
|
190
|
+
<p className="text-muted-foreground mt-4">
|
|
191
|
+
You can change your preferences at any time by clicking
|
|
192
|
+
“Cookie preferences” in the footer of any page. If
|
|
193
|
+
you reject non-essential cookies, some features that rely on
|
|
194
|
+
analytics or personalization may be limited.
|
|
195
|
+
</p>
|
|
196
|
+
</section>
|
|
197
|
+
|
|
198
|
+
{/* Your Rights */}
|
|
199
|
+
<section className="mb-10">
|
|
200
|
+
<h2 className="text-xl font-semibold font-heading mb-4">
|
|
201
|
+
Your Rights
|
|
202
|
+
</h2>
|
|
203
|
+
<p className="text-muted-foreground mb-4">
|
|
204
|
+
Depending on your location, you may have the right to:
|
|
205
|
+
</p>
|
|
206
|
+
<ul className="list-disc pl-6 space-y-2 text-muted-foreground">
|
|
207
|
+
<li>Access the personal information we hold about you</li>
|
|
208
|
+
<li>Request correction of inaccurate information</li>
|
|
209
|
+
<li>Request deletion of your personal information</li>
|
|
210
|
+
<li>Opt out of marketing communications</li>
|
|
211
|
+
<li>Data portability</li>
|
|
212
|
+
</ul>
|
|
213
|
+
</section>
|
|
214
|
+
|
|
215
|
+
{/* Children's Privacy */}
|
|
216
|
+
<section className="mb-10">
|
|
217
|
+
<h2 className="text-xl font-semibold font-heading mb-4">
|
|
218
|
+
Children's Privacy
|
|
219
|
+
</h2>
|
|
220
|
+
<p className="text-muted-foreground">
|
|
221
|
+
Our website is not intended for children under 13 years of age. We do not
|
|
222
|
+
knowingly collect personal information from children. If you believe we have
|
|
223
|
+
collected information from a child, please contact us immediately.
|
|
224
|
+
</p>
|
|
225
|
+
</section>
|
|
226
|
+
|
|
227
|
+
{/* Changes to Policy */}
|
|
228
|
+
<section className="mb-10">
|
|
229
|
+
<h2 className="text-xl font-semibold font-heading mb-4">
|
|
230
|
+
Changes to This Policy
|
|
231
|
+
</h2>
|
|
232
|
+
<p className="text-muted-foreground">
|
|
233
|
+
We may update this Privacy Policy from time to time. We will notify you of any
|
|
234
|
+
changes by posting the new policy on this page and updating the "Last updated"
|
|
235
|
+
date. We encourage you to review this policy periodically.
|
|
236
|
+
</p>
|
|
237
|
+
</section>
|
|
238
|
+
|
|
239
|
+
{/* Contact Us */}
|
|
240
|
+
<section className="mb-10">
|
|
241
|
+
<h2 className="text-xl font-semibold font-heading mb-4">
|
|
242
|
+
Contact Us
|
|
243
|
+
</h2>
|
|
244
|
+
<p className="text-muted-foreground">
|
|
245
|
+
If you have any questions about this Privacy Policy or our privacy
|
|
246
|
+
practices, please
|
|
247
|
+
{supportEmail ? (
|
|
248
|
+
<>
|
|
249
|
+
{' '}contact us at{' '}
|
|
250
|
+
<a
|
|
251
|
+
href={`mailto:${supportEmail}`}
|
|
252
|
+
className="text-primary hover:underline"
|
|
253
|
+
>
|
|
254
|
+
{supportEmail}
|
|
255
|
+
</a>{' '}
|
|
256
|
+
or visit our{' '}
|
|
257
|
+
</>
|
|
258
|
+
) : (
|
|
259
|
+
<>{' '}reach us through our{' '}</>
|
|
260
|
+
)}
|
|
261
|
+
<Link href="/contact" className="text-primary hover:underline">
|
|
262
|
+
contact page
|
|
263
|
+
</Link>
|
|
264
|
+
.
|
|
265
|
+
</p>
|
|
266
|
+
</section>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{/* Back to Home */}
|
|
270
|
+
<div className="mt-12 pt-8 border-t">
|
|
271
|
+
<Link
|
|
272
|
+
href="/"
|
|
273
|
+
className="text-sm text-primary hover:underline"
|
|
274
|
+
>
|
|
275
|
+
← Back to Home
|
|
276
|
+
</Link>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
)
|
|
281
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Product Detail — interactive client island.
|
|
5
|
+
*
|
|
6
|
+
* Rendered by the server `page.tsx`, which owns metadata + the server-rendered
|
|
7
|
+
* Product JSON-LD (AI shopping crawlers don't run JS, so schema must be server
|
|
8
|
+
* side). This component keeps the original interactive UX unchanged:
|
|
9
|
+
* - ProductGallery: Image gallery
|
|
10
|
+
* - VariantSelector: Variant selection
|
|
11
|
+
* - AddToCartButton: Add to cart with feedback
|
|
12
|
+
* - useProduct: Single product data fetching
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useState, useMemo } from 'react'
|
|
16
|
+
import { notFound } from 'next/navigation'
|
|
17
|
+
import { LocaleLink as Link } from '@rovela-ai/sdk/core'
|
|
18
|
+
import { ChevronLeft } from 'lucide-react'
|
|
19
|
+
import {
|
|
20
|
+
ProductGallery,
|
|
21
|
+
ProductSpecifications,
|
|
22
|
+
VariantSelector,
|
|
23
|
+
useProduct,
|
|
24
|
+
} from '@rovela-ai/sdk/products'
|
|
25
|
+
import { AddToCartButton } from '@rovela-ai/sdk/cart'
|
|
26
|
+
import { useT, useStoreFormatting } from '@rovela-ai/sdk/core'
|
|
27
|
+
import type { ProductVariant } from '@rovela-ai/sdk/core'
|
|
28
|
+
|
|
29
|
+
export function ProductDetailClient({ slug }: { slug: string }) {
|
|
30
|
+
const { product, variants, isLoading, error } = useProduct(slug)
|
|
31
|
+
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null)
|
|
32
|
+
const t = useT()
|
|
33
|
+
// Locale-aware price shapes (active i18n locale + store currency).
|
|
34
|
+
const { formatPrice } = useStoreFormatting()
|
|
35
|
+
|
|
36
|
+
// ALL HOOKS AND COMPUTED VALUES MUST BE ABOVE EARLY RETURNS (Rules of Hooks)
|
|
37
|
+
|
|
38
|
+
// Get current variant or default to first (safe even if variants is empty)
|
|
39
|
+
const currentVariant = selectedVariant || (variants.length > 0 ? variants[0] : null)
|
|
40
|
+
|
|
41
|
+
// Compute gallery: variant image + variant video first (when set), then
|
|
42
|
+
// product images, then product videos. Filter duplicates so a variant
|
|
43
|
+
// URL that also appears in product arrays only renders once.
|
|
44
|
+
const galleryMedia = useMemo<string[]>(() => {
|
|
45
|
+
if (!product) return []
|
|
46
|
+
const variantUrls = [currentVariant?.image, currentVariant?.video].filter(
|
|
47
|
+
(u): u is string => !!u
|
|
48
|
+
)
|
|
49
|
+
const productImages = product.images || []
|
|
50
|
+
const productVideos = product.videos || []
|
|
51
|
+
if (variantUrls.length === 0) {
|
|
52
|
+
return [...productImages, ...productVideos]
|
|
53
|
+
}
|
|
54
|
+
const variantSet = new Set(variantUrls)
|
|
55
|
+
return [
|
|
56
|
+
...variantUrls,
|
|
57
|
+
...productImages.filter((u) => !variantSet.has(u)),
|
|
58
|
+
...productVideos.filter((u) => !variantSet.has(u)),
|
|
59
|
+
]
|
|
60
|
+
}, [product, currentVariant])
|
|
61
|
+
|
|
62
|
+
// Generate a key to reset gallery when variant changes
|
|
63
|
+
const galleryKey = currentVariant?.id || 'no-variant'
|
|
64
|
+
|
|
65
|
+
// Determine price and stock (safe if product is null)
|
|
66
|
+
const price = currentVariant?.price ?? product?.price ?? 0
|
|
67
|
+
const comparePrice = product?.comparePrice
|
|
68
|
+
const inStock = currentVariant
|
|
69
|
+
? (currentVariant.inventory ?? 0) > 0
|
|
70
|
+
: true
|
|
71
|
+
|
|
72
|
+
// EARLY RETURNS AFTER ALL HOOKS
|
|
73
|
+
|
|
74
|
+
// Loading state
|
|
75
|
+
if (isLoading) {
|
|
76
|
+
return (
|
|
77
|
+
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
78
|
+
<div className="animate-pulse">
|
|
79
|
+
<div className="h-8 w-48 bg-muted rounded mb-8" />
|
|
80
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
|
81
|
+
<div className="aspect-square bg-muted rounded-lg" />
|
|
82
|
+
<div className="space-y-4">
|
|
83
|
+
<div className="h-10 w-3/4 bg-muted rounded" />
|
|
84
|
+
<div className="h-6 w-1/4 bg-muted rounded" />
|
|
85
|
+
<div className="h-24 bg-muted rounded" />
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Error or not found
|
|
94
|
+
if (error || !product) {
|
|
95
|
+
notFound()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
100
|
+
{/* Breadcrumb */}
|
|
101
|
+
<nav className="mb-8">
|
|
102
|
+
<Link
|
|
103
|
+
href="/products"
|
|
104
|
+
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
105
|
+
>
|
|
106
|
+
<ChevronLeft className="h-4 w-4 mr-1" />
|
|
107
|
+
{t('listing.backToProducts')}
|
|
108
|
+
</Link>
|
|
109
|
+
</nav>
|
|
110
|
+
|
|
111
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
|
112
|
+
{/* Product Gallery */}
|
|
113
|
+
<ProductGallery
|
|
114
|
+
key={galleryKey}
|
|
115
|
+
media={galleryMedia}
|
|
116
|
+
alt={product.name}
|
|
117
|
+
/>
|
|
118
|
+
|
|
119
|
+
{/* Product Info */}
|
|
120
|
+
<div className="flex flex-col">
|
|
121
|
+
<h1 className="text-3xl font-bold font-heading">{product.name}</h1>
|
|
122
|
+
|
|
123
|
+
{/* Price */}
|
|
124
|
+
<div className="mt-4 flex items-baseline gap-3">
|
|
125
|
+
<span className="text-2xl font-semibold">
|
|
126
|
+
{formatPrice(price)}
|
|
127
|
+
</span>
|
|
128
|
+
{comparePrice && comparePrice > price && (
|
|
129
|
+
<span className="text-lg text-muted-foreground line-through">
|
|
130
|
+
{formatPrice(comparePrice)}
|
|
131
|
+
</span>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Description */}
|
|
136
|
+
{product.description && (
|
|
137
|
+
<p className="mt-6 whitespace-pre-line text-muted-foreground">
|
|
138
|
+
{product.description}
|
|
139
|
+
</p>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{/* Variant Selector */}
|
|
143
|
+
{variants && variants.length > 1 && (
|
|
144
|
+
<div className="mt-8">
|
|
145
|
+
<VariantSelector
|
|
146
|
+
variants={variants}
|
|
147
|
+
selected={currentVariant}
|
|
148
|
+
onSelect={setSelectedVariant}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{/* Add to Cart */}
|
|
154
|
+
<div className="mt-8">
|
|
155
|
+
<AddToCartButton
|
|
156
|
+
product={product}
|
|
157
|
+
selectedVariant={currentVariant}
|
|
158
|
+
disabled={!inStock}
|
|
159
|
+
showFeedback
|
|
160
|
+
className="w-full sm:w-auto"
|
|
161
|
+
>
|
|
162
|
+
{inStock ? t('cart.addToCart') : t('product.outOfStock')}
|
|
163
|
+
</AddToCartButton>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{/* Stock Status */}
|
|
167
|
+
{!inStock && (
|
|
168
|
+
<p className="mt-4 text-sm text-destructive">
|
|
169
|
+
{t('product.currentlyOutOfStock')}
|
|
170
|
+
</p>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{/* Specifications (custom fields) — renders nothing when empty */}
|
|
176
|
+
<ProductSpecifications metafields={product.metafields} className="mt-12 max-w-3xl" />
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product Detail Page — server shell.
|
|
3
|
+
*
|
|
4
|
+
* This is a Server Component so it can emit per-product metadata AND a
|
|
5
|
+
* server-rendered `Product` + `Offer` JSON-LD block. AI shopping crawlers
|
|
6
|
+
* (GPTBot, OAI-SearchBot, PerplexityBot) read structured data BEFORE HTML and
|
|
7
|
+
* do not execute JavaScript, so the schema must be server-rendered here — not
|
|
8
|
+
* injected by the client island. The interactive UI lives in
|
|
9
|
+
* `ProductDetailClient` and is unchanged.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Metadata } from 'next'
|
|
13
|
+
import { cache } from 'react'
|
|
14
|
+
import { notFound } from 'next/navigation'
|
|
15
|
+
import {
|
|
16
|
+
findProductBySlug,
|
|
17
|
+
findProductVariants,
|
|
18
|
+
findSettings,
|
|
19
|
+
localizedAlternates,
|
|
20
|
+
getRequestLocale,
|
|
21
|
+
} from '@rovela-ai/sdk/core/server'
|
|
22
|
+
import { localizeCatalogRow } from '@rovela-ai/sdk/core'
|
|
23
|
+
import { buildProductJsonLd, buildBreadcrumbJsonLd, serializeJsonLd } from '@rovela-ai/sdk/core'
|
|
24
|
+
import { ProductDetailClient } from './ProductDetailClient'
|
|
25
|
+
|
|
26
|
+
const STORE_URL = process.env.NEXT_PUBLIC_APP_URL || ''
|
|
27
|
+
|
|
28
|
+
// Dedupe the product lookup across generateMetadata + the page render within a request.
|
|
29
|
+
const getProduct = cache((slug: string) =>
|
|
30
|
+
findProductBySlug(slug).catch(() => null)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
function toPlainText(html: string): string {
|
|
34
|
+
return html
|
|
35
|
+
.replace(/<[^>]*>/g, ' ')
|
|
36
|
+
.replace(/\s+/g, ' ')
|
|
37
|
+
.trim()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Props = { params: Promise<{ slug: string }> }
|
|
41
|
+
|
|
42
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
43
|
+
const { slug } = await params
|
|
44
|
+
const fetchedProduct = await getProduct(slug)
|
|
45
|
+
const { locale: activeLocale, config: localeConfig } = await getRequestLocale()
|
|
46
|
+
// Shadow with the localized copy so every downstream usage (title, JSON-LD,
|
|
47
|
+
// breadcrumb, client island props) inherits the translation.
|
|
48
|
+
const product = fetchedProduct
|
|
49
|
+
? localizeCatalogRow(fetchedProduct, activeLocale, localeConfig.defaultLocale)
|
|
50
|
+
: fetchedProduct
|
|
51
|
+
if (!product) return { title: 'Product not found' }
|
|
52
|
+
|
|
53
|
+
const title = product.metaTitle || product.name
|
|
54
|
+
const description =
|
|
55
|
+
product.metaDescription ||
|
|
56
|
+
(product.description ? toPlainText(product.description).slice(0, 160) : undefined)
|
|
57
|
+
const image = product.images?.[0]
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
title,
|
|
61
|
+
...(description && { description }),
|
|
62
|
+
alternates: await localizedAlternates(`/products/${slug}`),
|
|
63
|
+
...(product.metaKeywords && product.metaKeywords.length > 0 && {
|
|
64
|
+
keywords: product.metaKeywords,
|
|
65
|
+
}),
|
|
66
|
+
openGraph: {
|
|
67
|
+
title,
|
|
68
|
+
...(description && { description }),
|
|
69
|
+
type: 'website',
|
|
70
|
+
url: `/products/${slug}`,
|
|
71
|
+
...(image && { images: [{ url: image }] }),
|
|
72
|
+
},
|
|
73
|
+
twitter: {
|
|
74
|
+
card: 'summary_large_image',
|
|
75
|
+
title,
|
|
76
|
+
...(description && { description }),
|
|
77
|
+
...(image && { images: [image] }),
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default async function ProductPage({ params }: Props) {
|
|
83
|
+
const { slug } = await params
|
|
84
|
+
const fetchedProduct = await getProduct(slug)
|
|
85
|
+
const { locale: activeLocale, config: localeConfig } = await getRequestLocale()
|
|
86
|
+
// Shadow with the localized copy so every downstream usage (title, JSON-LD,
|
|
87
|
+
// breadcrumb, client island props) inherits the translation.
|
|
88
|
+
const product = fetchedProduct
|
|
89
|
+
? localizeCatalogRow(fetchedProduct, activeLocale, localeConfig.defaultLocale)
|
|
90
|
+
: fetchedProduct
|
|
91
|
+
if (!product) notFound()
|
|
92
|
+
|
|
93
|
+
const [variants, settings] = await Promise.all([
|
|
94
|
+
findProductVariants(product.id).catch(() => []),
|
|
95
|
+
findSettings().catch(() => null),
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
// Mirror the canonical out-of-stock logic (see buildOutOfStockCondition in
|
|
99
|
+
// the SDK): variant products are in stock if any variant has inventory;
|
|
100
|
+
// non-variant products honor trackInventory + continue-selling.
|
|
101
|
+
const inStock = product.hasVariants
|
|
102
|
+
? variants.some((v) => (v.inventory ?? 0) > 0)
|
|
103
|
+
: !product.trackInventory ||
|
|
104
|
+
(product.inventory ?? 0) > 0 ||
|
|
105
|
+
!!product.continueSellingWhenOutOfStock
|
|
106
|
+
|
|
107
|
+
const jsonLd = buildProductJsonLd(
|
|
108
|
+
{
|
|
109
|
+
name: product.name,
|
|
110
|
+
slug: product.slug,
|
|
111
|
+
description: product.description,
|
|
112
|
+
price: product.price,
|
|
113
|
+
images: product.images,
|
|
114
|
+
sku: product.sku,
|
|
115
|
+
inStock,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
storeUrl: STORE_URL,
|
|
119
|
+
storeName: settings?.storeName || process.env.STORE_NAME || 'Store',
|
|
120
|
+
currency: (settings?.storeCurrency || 'USD').toUpperCase(),
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
// Breadcrumb trail (Home › Products › product) — server-rendered. Only when
|
|
125
|
+
// we have an absolute origin to build valid `item` URLs.
|
|
126
|
+
const breadcrumbJsonLd = STORE_URL
|
|
127
|
+
? buildBreadcrumbJsonLd([
|
|
128
|
+
{ name: 'Home', url: `${STORE_URL}/` },
|
|
129
|
+
{ name: 'Products', url: `${STORE_URL}/products` },
|
|
130
|
+
{ name: product.name, url: `${STORE_URL}/products/${product.slug}` },
|
|
131
|
+
])
|
|
132
|
+
: null
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<>
|
|
136
|
+
<script
|
|
137
|
+
type="application/ld+json"
|
|
138
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
|
|
139
|
+
/>
|
|
140
|
+
{breadcrumbJsonLd && (
|
|
141
|
+
<script
|
|
142
|
+
type="application/ld+json"
|
|
143
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(breadcrumbJsonLd) }}
|
|
144
|
+
/>
|
|
145
|
+
)}
|
|
146
|
+
<ProductDetailClient slug={slug} />
|
|
147
|
+
</>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { localizedAlternates, getRequestLocale } from '@rovela-ai/sdk/core/server'
|
|
2
|
+
import { getT } from '@rovela-ai/sdk/core'
|
|
3
|
+
import type { Metadata } from 'next'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Server layout for the /products listing. The listing page is a client
|
|
7
|
+
* component and can't export metadata, so this wrapper supplies the canonical
|
|
8
|
+
* for /products. The product detail page (/products/[slug]) sets its OWN
|
|
9
|
+
* per-product canonical in generateMetadata, which overrides this for those
|
|
10
|
+
* routes — so it never mis-canonicalizes a product page to the listing.
|
|
11
|
+
*/
|
|
12
|
+
export async function generateMetadata() {
|
|
13
|
+
const { locale } = await getRequestLocale()
|
|
14
|
+
return {
|
|
15
|
+
title: getT(locale)('listing.title'),
|
|
16
|
+
alternates: await localizedAlternates('/products'),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function ProductsLayout({
|
|
21
|
+
children,
|
|
22
|
+
}: {
|
|
23
|
+
children: React.ReactNode
|
|
24
|
+
}) {
|
|
25
|
+
return children
|
|
26
|
+
}
|