@tokenoftrust/storefront-runner 1.3.2
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/LICENSE +58 -0
- package/README.md +42 -0
- package/apps/storefront/astro.config.mjs +66 -0
- package/apps/storefront/dev-plugins/dev-loop-state.mjs +147 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +238 -0
- package/apps/storefront/env.d.ts +162 -0
- package/apps/storefront/migrations/0000_baseline.sql +54 -0
- package/apps/storefront/migrations/0001_products_fts.sql +9 -0
- package/apps/storefront/migrations/README.md +54 -0
- package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/_journal.json +20 -0
- package/apps/storefront/package.json +42 -0
- package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
- package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
- package/apps/storefront/public/brand/northwind-og.svg +13 -0
- package/apps/storefront/public/favicon.svg +5 -0
- package/apps/storefront/public/js/dashboard-team.js +147 -0
- package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
- package/apps/storefront/public/shared/commerce-chrome.css +369 -0
- package/apps/storefront/src/components/Badge.astro +34 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
- package/apps/storefront/src/components/CollectionCard.astro +56 -0
- package/apps/storefront/src/components/EmptyState.astro +55 -0
- package/apps/storefront/src/components/Img.astro +68 -0
- package/apps/storefront/src/components/ProductCard.astro +228 -0
- package/apps/storefront/src/components/Section.astro +39 -0
- package/apps/storefront/src/components/Seo.astro +70 -0
- package/apps/storefront/src/components/WidgetFrame.astro +34 -0
- package/apps/storefront/src/components/admin/AdminAIWorkflowsTab.astro +531 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +597 -0
- package/apps/storefront/src/components/admin/AdminFoxyCartSettingsPanel.astro +227 -0
- package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +465 -0
- package/apps/storefront/src/components/admin/AdminProductsTab.astro +901 -0
- package/apps/storefront/src/components/admin/AdminReportingTab.astro +435 -0
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +782 -0
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +1223 -0
- package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
- package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
- package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +201 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +201 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +234 -0
- package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
- package/apps/storefront/src/components/commerce/ArticleCard.astro +242 -0
- package/apps/storefront/src/components/commerce/BrandGrid.astro +175 -0
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
- package/apps/storefront/src/components/commerce/FoxySidecartStyles.astro +61 -0
- package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
- package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
- package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
- package/apps/storefront/src/components/commerce/ReviewList.astro +54 -0
- package/apps/storefront/src/components/commerce/ReviewSummary.astro +44 -0
- package/apps/storefront/src/components/commerce/TagFilterCloud.astro +149 -0
- package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +37 -0
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +99 -0
- package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +45 -0
- package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
- package/apps/storefront/src/components/compliance/PactActNotice.astro +35 -0
- package/apps/storefront/src/components/compliance/Prop65Warning.astro +80 -0
- package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +38 -0
- package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +35 -0
- package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +53 -0
- package/apps/storefront/src/components/home/Hero.astro +164 -0
- package/apps/storefront/src/components/islands/AgeGate.tsx +109 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
- package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
- package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
- package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
- package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
- package/apps/storefront/src/components/islands/VariantSelector.tsx +375 -0
- package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
- package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
- package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
- package/apps/storefront/src/components/marketing/Faq.astro +37 -0
- package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
- package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
- package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
- package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
- package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
- package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
- package/apps/storefront/src/components/marketing/Steps.astro +36 -0
- package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
- package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
- package/apps/storefront/src/components/membership/IllustrativeLabel.astro +56 -0
- package/apps/storefront/src/components/membership/TierCards.astro +164 -0
- package/apps/storefront/src/components/membership/TierComparisonTable.astro +148 -0
- package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
- package/apps/storefront/src/components/plp/FacetSidebar.astro +199 -0
- package/apps/storefront/src/components/plp/Pagination.astro +99 -0
- package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
- package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +95 -0
- package/apps/storefront/src/components/subscription/HowItWorksSteps.astro +67 -0
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +67 -0
- package/apps/storefront/src/components/subscription/SavingsExplainer.astro +101 -0
- package/apps/storefront/src/config/adminTargetTenant.ts +28 -0
- package/apps/storefront/src/config/devTenantSeed.ts +78 -0
- package/apps/storefront/src/config/devTenants.ts +16 -0
- package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
- package/apps/storefront/src/config/resolver.ts +102 -0
- package/apps/storefront/src/config/tenants.ts +87 -0
- package/apps/storefront/src/layouts/Layout.astro +368 -0
- package/apps/storefront/src/lib/admin/README.md +122 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +21 -0
- package/apps/storefront/src/lib/admin/ai/askEnvelope.ts +48 -0
- package/apps/storefront/src/lib/admin/ai/audit.ts +14 -0
- package/apps/storefront/src/lib/admin/ai/index.ts +6 -0
- package/apps/storefront/src/lib/admin/ai/operationalRuns.ts +155 -0
- package/apps/storefront/src/lib/admin/ai/recommendation.ts +20 -0
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +172 -0
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +65 -0
- package/apps/storefront/src/lib/admin/envelope.ts +10 -0
- package/apps/storefront/src/lib/admin/evidence/evidenceReference.ts +32 -0
- package/apps/storefront/src/lib/admin/memoizeByEnvelope.ts +27 -0
- package/apps/storefront/src/lib/admin/orderClient.ts +146 -0
- package/apps/storefront/src/lib/admin/seed.ts +54 -0
- package/apps/storefront/src/lib/admin/services/mock/aiWorkflows.ts +59 -0
- package/apps/storefront/src/lib/admin/services/mock/customers.ts +86 -0
- package/apps/storefront/src/lib/admin/services/mock/evidence.ts +99 -0
- package/apps/storefront/src/lib/admin/services/mock/foxyCartSettings.ts +78 -0
- package/apps/storefront/src/lib/admin/services/mock/fulfillment.ts +62 -0
- package/apps/storefront/src/lib/admin/services/mock/index.ts +12 -0
- package/apps/storefront/src/lib/admin/services/mock/orders.ts +184 -0
- package/apps/storefront/src/lib/admin/services/mock/products.ts +105 -0
- package/apps/storefront/src/lib/admin/services/mock/reporting.ts +51 -0
- package/apps/storefront/src/lib/admin/services/mock/settings.ts +70 -0
- package/apps/storefront/src/lib/admin/services/mock/tenants.ts +29 -0
- package/apps/storefront/src/lib/admin/services/orders.ts +300 -0
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +67 -0
- package/apps/storefront/src/lib/admin/viewmodels/customers.ts +56 -0
- package/apps/storefront/src/lib/admin/viewmodels/fulfillment.ts +49 -0
- package/apps/storefront/src/lib/admin/viewmodels/orders.ts +92 -0
- package/apps/storefront/src/lib/admin/viewmodels/products.ts +74 -0
- package/apps/storefront/src/lib/admin/viewmodels/reporting.ts +34 -0
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +64 -0
- package/apps/storefront/src/lib/adminTenantEnvelope.ts +90 -0
- package/apps/storefront/src/lib/analytics/index.ts +47 -0
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
- package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
- package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +119 -0
- package/apps/storefront/src/lib/auth/route.ts +146 -0
- package/apps/storefront/src/lib/auth/session.ts +203 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
- package/apps/storefront/src/lib/basePath.ts +41 -0
- package/apps/storefront/src/lib/cfImage.ts +55 -0
- package/apps/storefront/src/lib/chrome/model.ts +244 -0
- package/apps/storefront/src/lib/chrome/parseConfig.ts +34 -0
- package/apps/storefront/src/lib/colors.ts +97 -0
- package/apps/storefront/src/lib/commerce/foxyCartSettings.ts +253 -0
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +91 -0
- package/apps/storefront/src/lib/content-edit/client.ts +230 -0
- package/apps/storefront/src/lib/content-edit/index.ts +14 -0
- package/apps/storefront/src/lib/content-edit/route.ts +25 -0
- package/apps/storefront/src/lib/content-edit/types.ts +19 -0
- package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
- package/apps/storefront/src/lib/d1/catalog.ts +366 -0
- package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
- package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
- package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
- package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
- package/apps/storefront/src/lib/demoGate.ts +113 -0
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +78 -0
- package/apps/storefront/src/lib/dev/activityStore.ts +72 -0
- package/apps/storefront/src/lib/dev/ai-widget-client.js +308 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +14 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +155 -0
- package/apps/storefront/src/lib/dev/devDraft.ts +133 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +169 -0
- package/apps/storefront/src/lib/dev/livePreview.ts +77 -0
- package/apps/storefront/src/lib/dev/runtimeStore.ts +120 -0
- package/apps/storefront/src/lib/dev/storeStatus.ts +142 -0
- package/apps/storefront/src/lib/diag/redact.ts +20 -0
- package/apps/storefront/src/lib/edgeCache.ts +128 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
- package/apps/storefront/src/lib/env.ts +60 -0
- package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
- package/apps/storefront/src/lib/format.ts +38 -0
- package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
- package/apps/storefront/src/lib/imageAttrs.ts +55 -0
- package/apps/storefront/src/lib/jsonld.ts +166 -0
- package/apps/storefront/src/lib/maintenance.ts +71 -0
- package/apps/storefront/src/lib/membership/model.ts +97 -0
- package/apps/storefront/src/lib/memoryKv.ts +32 -0
- package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
- package/apps/storefront/src/lib/placeholder.ts +99 -0
- package/apps/storefront/src/lib/previewReload.ts +125 -0
- package/apps/storefront/src/lib/priceBuckets.ts +33 -0
- package/apps/storefront/src/lib/rawChrome.ts +505 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +136 -0
- package/apps/storefront/src/lib/search/index.ts +74 -0
- package/apps/storefront/src/lib/sections.ts +129 -0
- package/apps/storefront/src/lib/securityHeaders.ts +31 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +607 -0
- package/apps/storefront/src/lib/subscription/model.ts +68 -0
- package/apps/storefront/src/lib/tenantRouting.ts +91 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
- package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
- package/apps/storefront/src/lib/tot/query.ts +249 -0
- package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
- package/apps/storefront/src/lib/url.ts +142 -0
- package/apps/storefront/src/lib/wishlist.ts +63 -0
- package/apps/storefront/src/middleware/index.ts +455 -0
- package/apps/storefront/src/pages/404.astro +61 -0
- package/apps/storefront/src/pages/[...slug].astro +90 -0
- package/apps/storefront/src/pages/admin.astro +1675 -0
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +53 -0
- package/apps/storefront/src/pages/api/admin/orders.ts +71 -0
- package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
- package/apps/storefront/src/pages/api/auth/send.ts +85 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
- package/apps/storefront/src/pages/api/checkout-style.css.ts +43 -0
- package/apps/storefront/src/pages/api/cli/policy.ts +70 -0
- package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +164 -0
- package/apps/storefront/src/pages/api/csp-report.ts +25 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
- package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
- package/apps/storefront/src/pages/api/dev/activity.ts +124 -0
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +73 -0
- package/apps/storefront/src/pages/api/dev/diff.ts +68 -0
- package/apps/storefront/src/pages/api/dev/file.ts +57 -0
- package/apps/storefront/src/pages/api/dev/locate.ts +125 -0
- package/apps/storefront/src/pages/api/dev/publish.ts +128 -0
- package/apps/storefront/src/pages/api/dev/tree.ts +70 -0
- package/apps/storefront/src/pages/api/dev/write.ts +116 -0
- package/apps/storefront/src/pages/api/newsletter.ts +104 -0
- package/apps/storefront/src/pages/api/request-access.ts +172 -0
- package/apps/storefront/src/pages/auth/login.astro +149 -0
- package/apps/storefront/src/pages/auth/magic.astro +105 -0
- package/apps/storefront/src/pages/capabilities.astro +292 -0
- package/apps/storefront/src/pages/collections/[handle].astro +177 -0
- package/apps/storefront/src/pages/collections/index.astro +59 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
- package/apps/storefront/src/pages/dashboard/index.astro +160 -0
- package/apps/storefront/src/pages/index.astro +290 -0
- package/apps/storefront/src/pages/llms.txt.ts +92 -0
- package/apps/storefront/src/pages/products/[handle].astro +402 -0
- package/apps/storefront/src/pages/robots.txt.ts +24 -0
- package/apps/storefront/src/pages/saved.astro +35 -0
- package/apps/storefront/src/pages/search.astro +150 -0
- package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +1280 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +229 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +144 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +116 -0
- package/apps/storefront/src/pages/style-guide/index.astro +98 -0
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
- package/apps/storefront/src/styles/global.css +292 -0
- package/apps/storefront/src/themes/reference.ts +68 -0
- package/apps/storefront/src/themes/schema.ts +111 -0
- package/apps/storefront/src/themes/tenants/index.ts +51 -0
- package/apps/storefront/tsconfig.json +16 -0
- package/apps/storefront/vitest.config.ts +22 -0
- package/package.json +26 -0
- package/packages/public-runtime/LICENSE +58 -0
- package/packages/public-runtime/package.json +22 -0
- package/packages/public-runtime/src/binary-path.ts +68 -0
- package/packages/public-runtime/src/capabilities.ts +89 -0
- package/packages/public-runtime/src/catalog-api.ts +81 -0
- package/packages/public-runtime/src/catalog-d1.ts +192 -0
- package/packages/public-runtime/src/checkout-style-publish.ts +200 -0
- package/packages/public-runtime/src/checkout-style.ts +375 -0
- package/packages/public-runtime/src/csp.ts +109 -0
- package/packages/public-runtime/src/customization-preview.ts +197 -0
- package/packages/public-runtime/src/customization-reconcile.ts +63 -0
- package/packages/public-runtime/src/customization-runtime.ts +157 -0
- package/packages/public-runtime/src/customization-scripts.ts +64 -0
- package/packages/public-runtime/src/customization-versioning.ts +95 -0
- package/packages/public-runtime/src/foxy-orders.ts +136 -0
- package/packages/public-runtime/src/foxy.ts +196 -0
- package/packages/public-runtime/src/hot-standby-readiness.ts +140 -0
- package/packages/public-runtime/src/index.ts +34 -0
- package/packages/public-runtime/src/product.ts +159 -0
- package/packages/public-runtime/src/search.ts +45 -0
- package/packages/public-runtime/src/tenant-assets.ts +444 -0
- package/packages/public-runtime/src/tenant.ts +384 -0
- package/packages/public-runtime/tsconfig.json +8 -0
- package/packages/public-runtime/vitest.config.ts +8 -0
- package/pnpm-workspace.yaml +7 -0
- package/scripts/build/copy-tenant-assets.mjs +69 -0
- package/scripts/dev/ai-edit.mjs +183 -0
- package/scripts/dev/checkout-watch.mjs +116 -0
- package/scripts/dev/file-browser.mjs +159 -0
- package/scripts/dev/file-writer.mjs +141 -0
- package/scripts/dev/git-status.mjs +171 -0
- package/scripts/dev/locate-handler.mjs +163 -0
- package/scripts/dev/locate.mjs +112 -0
- package/scripts/dev/port-check.mjs +48 -0
- package/scripts/dev/safe-path.mjs +109 -0
- package/scripts/dev/shot.mjs +46 -0
- package/scripts/dev/tenant-source-root.mjs +76 -0
- package/scripts/dev/unified-diff.mjs +81 -0
- package/scripts/tot-dev.mjs +376 -0
- package/tenants/README.md +14 -0
- package/tenants/home/content/chrome.json +7 -0
- package/tenants/home/content/home.html +58 -0
- package/tenants/home/content/home.json +11 -0
- package/tenants/home/public/js/storefront.js +210 -0
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +212 -0
- package/tenants/home/theme.json +6 -0
- package/tsconfig.base.json +20 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tenant llms.txt — the emerging /llms.txt convention: a concise,
|
|
3
|
+
* machine-readable guide that helps LLM answer engines (ChatGPT, Perplexity,
|
|
4
|
+
* Google AI Overviews, etc.) understand what this store is and where its key
|
|
5
|
+
* content lives. Mirrors robots.txt / sitemap.xml: built per request from the
|
|
6
|
+
* resolved tenant's catalog + content, canonical base from the Host.
|
|
7
|
+
*
|
|
8
|
+
* Every line traces to REAL migrated data or tenant config — no fabricated copy
|
|
9
|
+
* (playbook/codebase-health.md T7). Summary/descriptions are the merchant's own
|
|
10
|
+
* SEO text; compliance facts come from tenant.compliance.
|
|
11
|
+
*/
|
|
12
|
+
import type { APIContext } from "astro";
|
|
13
|
+
import { createContentProvider } from "@/lib/storyblok/provider";
|
|
14
|
+
|
|
15
|
+
/** Collapse HTML/whitespace to a single trimmed line for a link description. */
|
|
16
|
+
function oneLine(s: string | undefined, max = 140): string | undefined {
|
|
17
|
+
if (!s) return undefined;
|
|
18
|
+
const t = s.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
19
|
+
if (!t) return undefined;
|
|
20
|
+
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function GET(context: APIContext): Promise<Response> {
|
|
24
|
+
const { tot, tenant, canonicalBase } = context.locals;
|
|
25
|
+
const base = canonicalBase.replace(/\/$/, "");
|
|
26
|
+
const provider = createContentProvider(tenant);
|
|
27
|
+
|
|
28
|
+
const [collections, pages, featured, home] = await Promise.all([
|
|
29
|
+
tot.listCollections(),
|
|
30
|
+
provider.listPages(),
|
|
31
|
+
tot.getFeatured(12),
|
|
32
|
+
provider.getHome().catch(() => null),
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const lines: string[] = [`# ${tenant.displayName}`, ""];
|
|
36
|
+
|
|
37
|
+
const summary = oneLine(home?.seoDescription, 280);
|
|
38
|
+
if (summary) lines.push(`> ${summary}`, "");
|
|
39
|
+
|
|
40
|
+
// Compliance facts — sourced from tenant config, never invented.
|
|
41
|
+
const c = tenant.compliance;
|
|
42
|
+
if (c && (c.minAge || c.nicotineWarning || c.shippingRestriction)) {
|
|
43
|
+
const facts: string[] = [];
|
|
44
|
+
if (c.minAge) facts.push(`age-restricted — customers must be ${c.minAge}+`);
|
|
45
|
+
if (c.nicotineWarning) facts.push("products contain nicotine (FDA warning applies)");
|
|
46
|
+
if (facts.length) lines.push(`**Compliance:** ${facts.join("; ")}.`);
|
|
47
|
+
const ship = oneLine(c.shippingRestriction, 240);
|
|
48
|
+
if (ship) lines.push(`**Shipping:** ${ship}`);
|
|
49
|
+
lines.push("");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (collections.length) {
|
|
53
|
+
lines.push("## Collections");
|
|
54
|
+
for (const col of collections) {
|
|
55
|
+
const desc = oneLine(col.description_text ?? col.seo.description);
|
|
56
|
+
lines.push(`- [${col.title}](${base}/collections/${col.handle})${desc ? `: ${desc}` : ""}`);
|
|
57
|
+
}
|
|
58
|
+
lines.push("");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (featured.length) {
|
|
62
|
+
lines.push("## Featured products");
|
|
63
|
+
for (const p of featured) {
|
|
64
|
+
const desc = oneLine(p.seo.description ?? p.description_text);
|
|
65
|
+
lines.push(`- [${p.title}](${base}/products/${p.handle})${desc ? `: ${desc}` : ""}`);
|
|
66
|
+
}
|
|
67
|
+
lines.push("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (pages.length) {
|
|
71
|
+
lines.push("## Pages");
|
|
72
|
+
for (const pg of pages) {
|
|
73
|
+
lines.push(`- [${pg.title}](${base}/${pg.slug})`);
|
|
74
|
+
}
|
|
75
|
+
lines.push("");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
lines.push("## Optional");
|
|
79
|
+
lines.push(`- [Full product catalog (XML sitemap)](${base}/sitemap.xml)`);
|
|
80
|
+
lines.push(`- [Search](${base}/search)`);
|
|
81
|
+
lines.push(
|
|
82
|
+
"- Every page ships Schema.org JSON-LD (Product, Offer, AggregateRating, Breadcrumb, Organization) for structured extraction.",
|
|
83
|
+
);
|
|
84
|
+
lines.push("");
|
|
85
|
+
|
|
86
|
+
return new Response(lines.join("\n"), {
|
|
87
|
+
headers: {
|
|
88
|
+
"content-type": "text/plain; charset=utf-8",
|
|
89
|
+
"cache-control": "public, max-age=300, s-maxage=3600",
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* PDP — the conversion centerpiece (brief §5). Gallery + variant selector
|
|
4
|
+
* islands, breadcrumbs, structured specs from metafields, related products,
|
|
5
|
+
* badges, review-stars placeholder, disabled buy CTA (no cart in Phase 1),
|
|
6
|
+
* Product + BreadcrumbList JSON-LD.
|
|
7
|
+
*/
|
|
8
|
+
import Layout from "@/layouts/Layout.astro";
|
|
9
|
+
import Breadcrumbs from "@/components/Breadcrumbs.astro";
|
|
10
|
+
import Badge from "@/components/Badge.astro";
|
|
11
|
+
import ProductCard from "@/components/ProductCard.astro";
|
|
12
|
+
import Section from "@/components/Section.astro";
|
|
13
|
+
import ImageGallery from "@/components/islands/ImageGallery.tsx";
|
|
14
|
+
import VariantSelector from "@/components/islands/VariantSelector.tsx";
|
|
15
|
+
import RecentlyViewed from "@/components/islands/RecentlyViewed.tsx";
|
|
16
|
+
import NicotineWarning from "@/components/compliance/NicotineWarning.astro";
|
|
17
|
+
import ReviewSummary from "@/components/commerce/ReviewSummary.astro";
|
|
18
|
+
import ReviewList from "@/components/commerce/ReviewList.astro";
|
|
19
|
+
import { isOnSale, isNew, isAvailable, fromPrice } from "@/lib/tot/query";
|
|
20
|
+
import { buildVariantCartPayload } from "@tot/public-runtime";
|
|
21
|
+
import type { VariantCartPayload } from "@tot/public-runtime";
|
|
22
|
+
import { readCartSecret } from "@/lib/foxyCommerce";
|
|
23
|
+
import { truncate } from "@/lib/format";
|
|
24
|
+
import { productLd, breadcrumbLd, reviewLd } from "@/lib/jsonld";
|
|
25
|
+
import { isUsableImageUrl } from "@/lib/placeholder";
|
|
26
|
+
import { withBase } from "@/lib/basePath";
|
|
27
|
+
|
|
28
|
+
const { handle } = Astro.params;
|
|
29
|
+
const { tenant, tot, canonicalBase, basePath, commerce } = Astro.locals;
|
|
30
|
+
// Commerce-only route: marketing tenants have no product detail pages.
|
|
31
|
+
if (!Astro.locals.features.catalog) return new Response(null, { status: 404 });
|
|
32
|
+
const compliance = tenant.compliance;
|
|
33
|
+
|
|
34
|
+
if (!handle) return Astro.redirect(withBase(basePath, "/404"));
|
|
35
|
+
const product = await tot.getProductByHandle(handle);
|
|
36
|
+
|
|
37
|
+
// Draft canary: drafts resolve by handle (preview/direct link) but are noindex.
|
|
38
|
+
if (!product || product.status === "archived") {
|
|
39
|
+
return new Response(null, { status: 404 });
|
|
40
|
+
}
|
|
41
|
+
const isDraft = product.status !== "active";
|
|
42
|
+
|
|
43
|
+
// Related: same collection first, then shared-tag fallback, exclude self+drafts.
|
|
44
|
+
const related = await (async () => {
|
|
45
|
+
const out = new Map<string, typeof product>();
|
|
46
|
+
for (const colHandle of product.collections) {
|
|
47
|
+
const { products } = await tot.listProducts({ collection: colHandle, pageSize: 12 });
|
|
48
|
+
for (const p of products) if (p.handle !== product.handle) out.set(p.handle, p);
|
|
49
|
+
}
|
|
50
|
+
if (out.size < 4 && product.tags.length) {
|
|
51
|
+
const { products } = await tot.listProducts({ pageSize: 50 });
|
|
52
|
+
for (const p of products) {
|
|
53
|
+
if (p.handle === product.handle) continue;
|
|
54
|
+
if (p.tags.some((t) => product.tags.includes(t))) out.set(p.handle, p);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return [...out.values()].slice(0, 4);
|
|
58
|
+
})();
|
|
59
|
+
|
|
60
|
+
const crumbs = [
|
|
61
|
+
{ label: tenant.displayName, href: "/" },
|
|
62
|
+
...(product.collections[0]
|
|
63
|
+
? [{ label: titleCase(product.collections[0]), href: `/collections/${product.collections[0]}` }]
|
|
64
|
+
: []),
|
|
65
|
+
{ label: product.title, href: `/products/${product.handle}` },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
function titleCase(s: string) {
|
|
69
|
+
return s.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Metafield reader.
|
|
73
|
+
const mf = (ns: string, key: string) =>
|
|
74
|
+
product.metafields.find((m) => m.namespace === ns && m.key === key)?.value;
|
|
75
|
+
|
|
76
|
+
// Reviews — REAL data from the store's reviews app (Stamped), pulled as metafields.
|
|
77
|
+
const reviewCount = Number(mf("stamped", "reviews_count") ?? mf("reviews", "count") ?? "0");
|
|
78
|
+
const reviewAvg = Number(mf("stamped", "reviews_average") ?? mf("reviews", "rating") ?? "0");
|
|
79
|
+
|
|
80
|
+
// Short description (merchant's concise summary) shown high on the PDP, like the
|
|
81
|
+
// source store. The longer description_html still renders below the buy column.
|
|
82
|
+
const shortDescription = mf("my_fields", "short_description") ?? mf("specs", "short_description");
|
|
83
|
+
|
|
84
|
+
// OG image = the real product image (Shopify CDN supports ?width=), sized for
|
|
85
|
+
// social. Falls back to the tenant brand OG only when there's no usable image.
|
|
86
|
+
const ogPrimary = product.images[0];
|
|
87
|
+
const ogImage =
|
|
88
|
+
ogPrimary && isUsableImageUrl(ogPrimary.url, ogPrimary.rehosted)
|
|
89
|
+
? `${ogPrimary.url}${ogPrimary.url.includes("?") ? "&" : "?"}width=1200`
|
|
90
|
+
: undefined;
|
|
91
|
+
|
|
92
|
+
// Product attributes shown the way the SOURCE STORE presents them: plain
|
|
93
|
+
// "Label: value" lines below the description (Flavors / VG % / Bottle Type),
|
|
94
|
+
// sourced from F:-prefixed tags + merchant my_fields. The compliance.* metafields
|
|
95
|
+
// are ToT-internal sync data and are NOT displayed — the source store doesn't
|
|
96
|
+
// show them either (they gate shipping/eligibility, not merchandising).
|
|
97
|
+
// Each flavor links to that flavor filtered within the product's collection —
|
|
98
|
+
// mirrors the source store's /collections/<handle>/<tag> pattern using our
|
|
99
|
+
// existing ?tag= PLP filter. General: any tag → a filtered listing; only the
|
|
100
|
+
// "F: " flavor-tag convention is merchant-specific (same as the display above).
|
|
101
|
+
const flavorCollection = product.collections[0];
|
|
102
|
+
const flavorTags = (product.tags ?? [])
|
|
103
|
+
.filter((t) => t.startsWith("F: "))
|
|
104
|
+
.map((t) => ({
|
|
105
|
+
label: t.slice(3).trim(),
|
|
106
|
+
href: flavorCollection
|
|
107
|
+
? `/collections/${flavorCollection}?tag=${encodeURIComponent(t)}`
|
|
108
|
+
: undefined,
|
|
109
|
+
}))
|
|
110
|
+
.filter((f) => f.label);
|
|
111
|
+
const vgPercentage = mf("my_fields", "vg_percentage");
|
|
112
|
+
const bottleType = mf("my_fields", "bottle_type");
|
|
113
|
+
|
|
114
|
+
// State sales restrictions (ToT compliance sync). Where this can't ship/sell.
|
|
115
|
+
let restrictedStates: string[] = [];
|
|
116
|
+
const restrictedRaw = mf("compliance", "restricted_states");
|
|
117
|
+
if (restrictedRaw) {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(restrictedRaw);
|
|
120
|
+
restrictedStates = Array.isArray(parsed) ? parsed : Object.keys(parsed);
|
|
121
|
+
} catch {
|
|
122
|
+
/* not JSON — ignore */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Legal/safety disclaimer (scraped from the store's own PDP by the migration),
|
|
127
|
+
// per product category — sub-ohm on salts, battery on devices, etc. Selected by
|
|
128
|
+
// product_type (or the compliance.product_category metafield); default fallback.
|
|
129
|
+
const chrome = await Astro.locals.content.getChrome();
|
|
130
|
+
const productCategory = product.product_type || mf("compliance", "product_category");
|
|
131
|
+
const disclaimer =
|
|
132
|
+
(productCategory && chrome.disclaimers?.[productCategory]) || chrome.disclaimer;
|
|
133
|
+
|
|
134
|
+
// Full review content (migrated from the store's reviews app). The aggregate
|
|
135
|
+
// (reviewAvg/reviewCount above) comes from metafields; this is the per-review text.
|
|
136
|
+
const reviews = await tot.getReviews(product.handle);
|
|
137
|
+
|
|
138
|
+
const onSale = isOnSale(product);
|
|
139
|
+
const available = isAvailable(product);
|
|
140
|
+
|
|
141
|
+
// FoxyCart add-to-cart (Track A). Pre-build each variant's cart payload server-
|
|
142
|
+
// side so the island can render a signed, tamper-proof form. In `signed` mode a
|
|
143
|
+
// missing secret means we render NO cart (never a tamperable form); the
|
|
144
|
+
// `unsigned-demo` mode needs no secret. Drafts still get a cart (preview links).
|
|
145
|
+
let foxyCart:
|
|
146
|
+
| { action: string; byVariantId: Record<string, VariantCartPayload> }
|
|
147
|
+
| undefined;
|
|
148
|
+
if (commerce) {
|
|
149
|
+
const secret = await readCartSecret(commerce);
|
|
150
|
+
if (commerce.mode !== "signed" || secret) {
|
|
151
|
+
const entries = await Promise.all(
|
|
152
|
+
product.variants.map(
|
|
153
|
+
async (v) =>
|
|
154
|
+
[
|
|
155
|
+
v.id,
|
|
156
|
+
await buildVariantCartPayload(product, v, {
|
|
157
|
+
mode: commerce.mode,
|
|
158
|
+
secret,
|
|
159
|
+
}),
|
|
160
|
+
] as const,
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
foxyCart = { action: commerce.cartUrl, byVariantId: Object.fromEntries(entries) };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const snapshot = {
|
|
168
|
+
handle: product.handle,
|
|
169
|
+
title: product.title,
|
|
170
|
+
price: fromPrice(product),
|
|
171
|
+
currency: product.price_range.currency,
|
|
172
|
+
vendor: product.vendor,
|
|
173
|
+
img: product.images[0]?.url,
|
|
174
|
+
rehosted: product.images[0]?.rehosted,
|
|
175
|
+
seed: product.images[0]?.id ?? product.handle,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Enrich with the real aggregate rating + reviews for rich results (SEO), per the
|
|
179
|
+
// review-markup policy (docs/architecture/review-markup-policy.md).
|
|
180
|
+
const productJsonLd: Record<string, unknown> = {
|
|
181
|
+
...productLd(product, tenant, canonicalBase),
|
|
182
|
+
...reviewLd(reviewAvg, reviewCount, reviews),
|
|
183
|
+
};
|
|
184
|
+
const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
<Layout
|
|
188
|
+
title={product.seo.title || product.title}
|
|
189
|
+
description={product.seo.description || truncate(product.description_text, 160)}
|
|
190
|
+
path={`/products/${product.handle}`}
|
|
191
|
+
ogType="product"
|
|
192
|
+
ogImage={ogImage}
|
|
193
|
+
noindex={isDraft}
|
|
194
|
+
jsonLd={jsonLd}
|
|
195
|
+
>
|
|
196
|
+
<div class="container-prose pt-6">
|
|
197
|
+
<Breadcrumbs crumbs={crumbs} />
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{isDraft && (
|
|
201
|
+
<div class="container-prose">
|
|
202
|
+
<p class="mt-4 rounded-[var(--radius-md)] border border-[var(--color-accent)] bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] px-4 py-3 text-sm text-[var(--color-text)]">
|
|
203
|
+
Draft preview — this product isn't published and won't appear in listings or search.
|
|
204
|
+
</p>
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
<div class="container-prose grid gap-10 py-8 md:grid-cols-2 lg:gap-16">
|
|
209
|
+
{/* Gallery (island) */}
|
|
210
|
+
<div>
|
|
211
|
+
<ImageGallery
|
|
212
|
+
client:idle
|
|
213
|
+
images={product.images}
|
|
214
|
+
title={product.title}
|
|
215
|
+
productHandle={product.handle}
|
|
216
|
+
/>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
{/* Buy column */}
|
|
220
|
+
<div class="md:pt-2">
|
|
221
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
222
|
+
{onSale && <Badge variant="sale" label="Sale" />}
|
|
223
|
+
{!onSale && isNew(product) && <Badge variant="new" label="New" />}
|
|
224
|
+
{!available && <Badge variant="soldout" label="Sold out" />}
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{product.vendor && (
|
|
228
|
+
<p class="eyebrow mt-4">{product.vendor}</p>
|
|
229
|
+
)}
|
|
230
|
+
<h1 class="display-md mt-1.5">
|
|
231
|
+
{product.title}
|
|
232
|
+
</h1>
|
|
233
|
+
|
|
234
|
+
{/* Review badge — REAL aggregate (rating + count) from the store's reviews
|
|
235
|
+
app, mirrored to product metafields. */}
|
|
236
|
+
{reviewCount > 0 && reviewAvg > 0 && (
|
|
237
|
+
<div class="mt-3">
|
|
238
|
+
<ReviewSummary average={reviewAvg} count={reviewCount} href={reviews.length > 0 ? "#reviews" : undefined} />
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
{shortDescription && (
|
|
243
|
+
<p class="mt-4 max-w-prose text-[1.02rem] leading-relaxed text-[var(--color-text)]">
|
|
244
|
+
{shortDescription}
|
|
245
|
+
</p>
|
|
246
|
+
)}
|
|
247
|
+
|
|
248
|
+
{compliance?.nicotineWarning && (
|
|
249
|
+
<div class="mt-6">
|
|
250
|
+
<NicotineWarning variant="block" />
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
|
|
254
|
+
{restrictedStates.length > 0 && (
|
|
255
|
+
<div class="mt-4 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm" role="note">
|
|
256
|
+
<p class="font-display font-medium text-[var(--color-text)]">Where we can ship this</p>
|
|
257
|
+
<p class="mt-1 text-[var(--color-muted)]">
|
|
258
|
+
Due to state laws, this product can't ship to: {restrictedStates.join(", ")}.
|
|
259
|
+
</p>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{/* Variant selector island owns live price/SKU/availability + buy CTA. */}
|
|
264
|
+
<div class="mt-7">
|
|
265
|
+
<VariantSelector
|
|
266
|
+
client:load
|
|
267
|
+
variants={product.variants}
|
|
268
|
+
options={product.options}
|
|
269
|
+
currency={product.price_range.currency}
|
|
270
|
+
locale={tenant.locale}
|
|
271
|
+
foxyCart={foxyCart}
|
|
272
|
+
/>
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
class="wishlist-toggle group mt-3 inline-flex w-full items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] py-2.5 font-display text-sm font-medium transition-colors hover:border-[var(--color-sale)] hover:text-[var(--color-sale)] aria-pressed:border-[var(--color-sale)] aria-pressed:text-[var(--color-sale)]"
|
|
276
|
+
aria-pressed="false"
|
|
277
|
+
aria-label={`Save ${product.title}`}
|
|
278
|
+
data-wishlist={JSON.stringify(snapshot)}
|
|
279
|
+
>
|
|
280
|
+
<svg class="wishlist-heart h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
|
|
281
|
+
<path d="M12 21s-7.5-4.6-10-9.2C.4 8.6 2 5 5.5 5 8 5 9.4 6.6 12 9c2.6-2.4 4-4 6.5-4C22 5 23.6 8.6 22 11.8 19.5 16.4 12 21 12 21z" />
|
|
282
|
+
</svg>
|
|
283
|
+
<span class="wl-label">Save for later</span>
|
|
284
|
+
</button>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
{/* Rich description + attribute lines — matches the source store's PDP:
|
|
288
|
+
the longer description, then plain "Label: value" lines (no spec table). */}
|
|
289
|
+
<div class="mt-9 border-t border-[var(--color-border)] pt-7">
|
|
290
|
+
<div class="rte text-[var(--color-text)]" set:html={product.description_html} />
|
|
291
|
+
{(flavorTags.length > 0 || vgPercentage || bottleType) && (
|
|
292
|
+
<div class="mt-5 space-y-1.5 text-sm leading-relaxed">
|
|
293
|
+
{flavorTags.length > 0 && (
|
|
294
|
+
<p>
|
|
295
|
+
<span class="text-[var(--color-muted)]">Flavors:</span>{" "}
|
|
296
|
+
{flavorTags.map((f, i) => (
|
|
297
|
+
<>
|
|
298
|
+
{i > 0 && <span class="text-[var(--color-border)]"> | </span>}
|
|
299
|
+
{f.href ? (
|
|
300
|
+
<a href={withBase(basePath, f.href)} class="link-accent">{f.label}</a>
|
|
301
|
+
) : (
|
|
302
|
+
<span>{f.label}</span>
|
|
303
|
+
)}
|
|
304
|
+
</>
|
|
305
|
+
))}
|
|
306
|
+
</p>
|
|
307
|
+
)}
|
|
308
|
+
{vgPercentage && (
|
|
309
|
+
<p><span class="text-[var(--color-muted)]">VG Percentage:</span> {vgPercentage}</p>
|
|
310
|
+
)}
|
|
311
|
+
{bottleType && (
|
|
312
|
+
<p><span class="text-[var(--color-muted)]">Bottle Type:</span> {bottleType}</p>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
{/* Safety & legal (scraped disclaimer) + Shipping & returns. The source
|
|
319
|
+
store shows no attribute/spec table — attributes appear as lines above. */}
|
|
320
|
+
<div class="mt-8 border-t border-[var(--color-border)]">
|
|
321
|
+
{disclaimer && (
|
|
322
|
+
<details class="accordion group">
|
|
323
|
+
<summary class="accordion__summary">
|
|
324
|
+
<span>Safety & legal</span>
|
|
325
|
+
<span class="accordion__chevron" aria-hidden="true">+</span>
|
|
326
|
+
</summary>
|
|
327
|
+
<div class="accordion__body rte text-sm leading-relaxed text-[var(--color-muted)]" set:html={disclaimer} />
|
|
328
|
+
</details>
|
|
329
|
+
)}
|
|
330
|
+
|
|
331
|
+
<details class="accordion group">
|
|
332
|
+
<summary class="accordion__summary">
|
|
333
|
+
<span>Shipping & returns</span>
|
|
334
|
+
<span class="accordion__chevron" aria-hidden="true">+</span>
|
|
335
|
+
</summary>
|
|
336
|
+
<div class="accordion__body text-sm leading-relaxed text-[var(--color-text)]">
|
|
337
|
+
<p>
|
|
338
|
+
Delivery times, rates, and returns are covered in our policy —
|
|
339
|
+
including the adult-signature (21+) requirement on every order.
|
|
340
|
+
</p>
|
|
341
|
+
<a
|
|
342
|
+
href={withBase(basePath, "/shipping-returns")}
|
|
343
|
+
class="link-accent mt-3 inline-block font-medium"
|
|
344
|
+
>
|
|
345
|
+
Shipping & returns policy
|
|
346
|
+
</a>
|
|
347
|
+
</div>
|
|
348
|
+
</details>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
{/* Customer reviews — real content migrated from the store's reviews app. */}
|
|
354
|
+
{reviews.length > 0 && (
|
|
355
|
+
<Section
|
|
356
|
+
id="reviews"
|
|
357
|
+
title="Customer reviews"
|
|
358
|
+
eyebrow={`${reviewAvg.toFixed(1)} ★ · ${reviewCount || reviews.length} review${(reviewCount || reviews.length) === 1 ? "" : "s"}`}
|
|
359
|
+
>
|
|
360
|
+
<ReviewList reviews={reviews} locale={tenant.locale} />
|
|
361
|
+
</Section>
|
|
362
|
+
)}
|
|
363
|
+
|
|
364
|
+
{/* Related */}
|
|
365
|
+
{related.length > 0 && (
|
|
366
|
+
<Section title="You may also like" eyebrow="More from the line" tone="surface">
|
|
367
|
+
<div class="grid grid-cols-2 gap-5 lg:grid-cols-4">
|
|
368
|
+
{related.map((p, i) => (
|
|
369
|
+
<ProductCard product={p} position={i} list="related" />
|
|
370
|
+
))}
|
|
371
|
+
</div>
|
|
372
|
+
</Section>
|
|
373
|
+
)}
|
|
374
|
+
|
|
375
|
+
{/* Recently viewed (client island; records this product, shows the rest). */}
|
|
376
|
+
<RecentlyViewed
|
|
377
|
+
client:idle
|
|
378
|
+
locale={tenant.locale}
|
|
379
|
+
current={{
|
|
380
|
+
handle: product.handle,
|
|
381
|
+
title: product.title,
|
|
382
|
+
price: fromPrice(product),
|
|
383
|
+
currency: product.price_range.currency,
|
|
384
|
+
vendor: product.vendor,
|
|
385
|
+
img: product.images[0]?.url,
|
|
386
|
+
rehosted: product.images[0]?.rehosted,
|
|
387
|
+
seed: product.images[0]?.id ?? product.handle,
|
|
388
|
+
}}
|
|
389
|
+
/>
|
|
390
|
+
|
|
391
|
+
{/* PDP-view analytics event. Phase 1: dispatched to a window event that the
|
|
392
|
+
no-op sink (or a Phase 2 real sink) can subscribe to. No-op by default. */}
|
|
393
|
+
<script type="application/json" id="pdp-meta" set:html={JSON.stringify({ type: "pdp_view", handle: product.handle })}></script>
|
|
394
|
+
<script is:inline nonce={Astro.locals.cspNonce}>
|
|
395
|
+
try {
|
|
396
|
+
const el = document.getElementById("pdp-meta");
|
|
397
|
+
if (el?.textContent) {
|
|
398
|
+
window.dispatchEvent(new CustomEvent("tot:analytics", { detail: JSON.parse(el.textContent) }));
|
|
399
|
+
}
|
|
400
|
+
} catch {}
|
|
401
|
+
</script>
|
|
402
|
+
</Layout>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tenant robots.txt. Allow all, keep crawlers out of the parameterized
|
|
3
|
+
* /search results, and point at this tenant's sitemap (canonical base is
|
|
4
|
+
* resolved per-request from the Host).
|
|
5
|
+
*/
|
|
6
|
+
import type { APIContext } from "astro";
|
|
7
|
+
|
|
8
|
+
export async function GET(context: APIContext): Promise<Response> {
|
|
9
|
+
const base = context.locals.canonicalBase.replace(/\/$/, "");
|
|
10
|
+
|
|
11
|
+
const body = `User-agent: *
|
|
12
|
+
Allow: /
|
|
13
|
+
Disallow: /search
|
|
14
|
+
|
|
15
|
+
Sitemap: ${base}/sitemap.xml
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
return new Response(body, {
|
|
19
|
+
headers: {
|
|
20
|
+
"content-type": "text/plain; charset=utf-8",
|
|
21
|
+
"cache-control": "public, max-age=3600",
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* /saved — the wishlist page. Content is client-rendered from localStorage
|
|
4
|
+
* (SavedList island); noindex since it's per-device and has no stable content.
|
|
5
|
+
*/
|
|
6
|
+
import Layout from "@/layouts/Layout.astro";
|
|
7
|
+
import Breadcrumbs from "@/components/Breadcrumbs.astro";
|
|
8
|
+
import SavedList from "@/components/islands/SavedList.tsx";
|
|
9
|
+
|
|
10
|
+
const { tenant } = Astro.locals;
|
|
11
|
+
// Commerce-only route: marketing tenants have no saved-items surface.
|
|
12
|
+
if (!Astro.locals.features.savedItems) return new Response(null, { status: 404 });
|
|
13
|
+
const crumbs = [
|
|
14
|
+
{ label: tenant.displayName, href: "/" },
|
|
15
|
+
{ label: "Saved", href: "/saved" },
|
|
16
|
+
];
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<Layout
|
|
20
|
+
title="Saved items"
|
|
21
|
+
description="Products you've saved for later."
|
|
22
|
+
path="/saved"
|
|
23
|
+
noindex
|
|
24
|
+
>
|
|
25
|
+
<div class="container-prose pt-6">
|
|
26
|
+
<Breadcrumbs crumbs={crumbs} />
|
|
27
|
+
</div>
|
|
28
|
+
<div class="container-prose py-8">
|
|
29
|
+
<header class="mb-8" data-reveal>
|
|
30
|
+
<p class="eyebrow eyebrow--tick mb-3">Your list</p>
|
|
31
|
+
<h1 class="display-xl">Saved</h1>
|
|
32
|
+
</header>
|
|
33
|
+
<SavedList client:only="preact" locale={tenant.locale} />
|
|
34
|
+
</div>
|
|
35
|
+
</Layout>
|