@tokenoftrust/storefront-runner 1.3.4-rc.4 → 1.4.0

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.
Files changed (224) hide show
  1. package/apps/storefront/astro.config.mjs +15 -0
  2. package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
  3. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
  4. package/apps/storefront/drizzle.config.apps.ts +13 -0
  5. package/apps/storefront/env.d.ts +10 -0
  6. package/apps/storefront/migrations/README.md +13 -7
  7. package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
  8. package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
  10. package/apps/storefront/package.json +13 -1
  11. package/apps/storefront/perf/README.md +64 -0
  12. package/apps/storefront/perf/assert-budgets.ts +159 -0
  13. package/apps/storefront/playwright.config.ts +23 -0
  14. package/apps/storefront/public/js/dashboard-apps.js +173 -0
  15. package/apps/storefront/public/shared/commerce-marketing.css +221 -0
  16. package/apps/storefront/src/components/CollectionCard.astro +1 -0
  17. package/apps/storefront/src/components/ProductCard.astro +1 -0
  18. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
  19. package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
  20. package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
  21. package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
  22. package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
  23. package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
  24. package/apps/storefront/src/components/content/Callout.astro +75 -0
  25. package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
  26. package/apps/storefront/src/components/content/ProseSections.astro +121 -0
  27. package/apps/storefront/src/components/content/ProseToc.astro +34 -0
  28. package/apps/storefront/src/components/content/RichText.astro +44 -0
  29. package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
  30. package/apps/storefront/src/components/home/Hero.astro +14 -0
  31. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
  32. package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
  33. package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
  34. package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
  35. package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
  36. package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
  37. package/apps/storefront/src/config/storeName.ts +34 -0
  38. package/apps/storefront/src/layouts/Layout.astro +97 -23
  39. package/apps/storefront/src/lib/analytics/budgets.json +69 -0
  40. package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
  41. package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
  42. package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
  43. package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
  44. package/apps/storefront/src/lib/apps/adminService.ts +106 -0
  45. package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
  46. package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
  47. package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
  48. package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
  49. package/apps/storefront/src/lib/apps/credentials.ts +153 -0
  50. package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
  51. package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
  52. package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
  53. package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
  54. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
  55. package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
  56. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
  57. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
  58. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
  59. package/apps/storefront/src/lib/apps/registryService.ts +579 -0
  60. package/apps/storefront/src/lib/apps/scopes.ts +79 -0
  61. package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
  62. package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
  63. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
  64. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
  65. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
  66. package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
  67. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
  68. package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
  69. package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
  70. package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
  71. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
  72. package/apps/storefront/src/lib/auth/route.ts +16 -1
  73. package/apps/storefront/src/lib/auth/session.ts +8 -0
  74. package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
  75. package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
  76. package/apps/storefront/src/lib/blog/provider.ts +39 -0
  77. package/apps/storefront/src/lib/blog/types.ts +26 -0
  78. package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
  79. package/apps/storefront/src/lib/chrome/model.ts +11 -0
  80. package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
  81. package/apps/storefront/src/lib/content/callout.ts +78 -0
  82. package/apps/storefront/src/lib/content/index.ts +27 -0
  83. package/apps/storefront/src/lib/content/needsReview.ts +58 -0
  84. package/apps/storefront/src/lib/content/prose.ts +186 -0
  85. package/apps/storefront/src/lib/content/richtext.ts +76 -0
  86. package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
  87. package/apps/storefront/src/lib/content-edit/client.ts +70 -14
  88. package/apps/storefront/src/lib/d1/catalog.ts +12 -0
  89. package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
  90. package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
  91. package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
  92. package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
  93. package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
  94. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  95. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  96. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  97. package/apps/storefront/src/lib/env.ts +12 -0
  98. package/apps/storefront/src/lib/jsonld.ts +12 -17
  99. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  100. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  101. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  102. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  103. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  104. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  105. package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
  106. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  107. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  108. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  109. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  110. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  111. package/apps/storefront/src/lib/tot/query.ts +32 -0
  112. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  113. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  114. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  115. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  116. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  117. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  118. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  119. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  120. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  121. package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
  122. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
  123. package/apps/storefront/src/middleware/index.ts +28 -13
  124. package/apps/storefront/src/pages/404.astro +21 -9
  125. package/apps/storefront/src/pages/[...slug].astro +36 -2
  126. package/apps/storefront/src/pages/admin.astro +29 -3
  127. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
  128. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  129. package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
  130. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  131. package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
  132. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
  133. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
  134. package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
  135. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
  136. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  137. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
  138. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  139. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
  140. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  141. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  142. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  143. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  144. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  145. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  146. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  147. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
  148. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  149. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  150. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  151. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  152. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  153. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  154. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  155. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  156. package/apps/storefront/src/pages/auth/login.astro +197 -41
  157. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  158. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  159. package/apps/storefront/src/pages/blog/index.astro +98 -0
  160. package/apps/storefront/src/pages/capabilities.astro +8 -0
  161. package/apps/storefront/src/pages/cockpit.astro +433 -69
  162. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  163. package/apps/storefront/src/pages/collections/index.astro +10 -2
  164. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  165. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  166. package/apps/storefront/src/pages/index.astro +57 -0
  167. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  168. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  169. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  170. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  171. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  172. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  173. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  174. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  175. package/apps/storefront/src/styles/fonts.css +54 -0
  176. package/apps/storefront/src/styles/global.css +22 -2
  177. package/apps/storefront/src/themes/schema.ts +3 -25
  178. package/apps/storefront/tsconfig.json +1 -1
  179. package/apps/storefront/vitest.config.ts +4 -1
  180. package/package.json +1 -1
  181. package/packages/public-runtime/src/candidate-index.ts +311 -0
  182. package/packages/public-runtime/src/checkout.ts +94 -2
  183. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  184. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  185. package/packages/public-runtime/src/compliance/index.ts +22 -0
  186. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  187. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  188. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  189. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  190. package/packages/public-runtime/src/csp.ts +27 -3
  191. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  192. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  193. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  194. package/packages/public-runtime/src/extension-contract.ts +2 -1
  195. package/packages/public-runtime/src/hash.ts +25 -0
  196. package/packages/public-runtime/src/index.ts +6 -0
  197. package/packages/public-runtime/src/membership.ts +353 -0
  198. package/packages/public-runtime/src/product.ts +24 -2
  199. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  200. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  201. package/packages/public-runtime/src/tenant.ts +236 -0
  202. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  203. package/scripts/dev/publish.mjs +158 -0
  204. package/scripts/dev/transient-files.mjs +2 -1
  205. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  206. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  207. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  208. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  209. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  210. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  211. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  212. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  213. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  214. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  215. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  216. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  217. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  218. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  219. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  220. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  221. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  222. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  223. package/tenants/home/public/pages/storefront.css +23 -0
  224. package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
@@ -34,6 +34,13 @@ query.collection = handle;
34
34
  if (!query.pageSize) query.pageSize = 9;
35
35
 
36
36
  const { products, page, pageSize, total, facets } = await tot.listProducts(query);
37
+
38
+ // A synthesized-EMPTY collection (zero active members overall — not merely
39
+ // zero under the current facet/price/q filters) still resolves 200 for a direct
40
+ // link, but must not be indexed: it's a crawlable dead end, kept out of the
41
+ // sitemap + directory too. Detect it via the UNFILTERED membership total.
42
+ const { total: memberTotal } = await tot.listProducts({ collection: handle, pageSize: 1 });
43
+ const isEmptyCollection = memberTotal === 0;
37
44
  const basePath = `/collections/${handle}`;
38
45
  // Tenant-prefixed page path for facet/sort/pagination, which build links by
39
46
  // string-concatenating onto this base (so they stay inside a path-routed
@@ -76,6 +83,7 @@ if (query.priceMin != null || query.priceMax != null) {
76
83
  title={collection.seo.title || collection.title}
77
84
  description={collection.seo.description || (desc ? truncate(desc, 160) : `Shop ${collection.title} at ${tenant.displayName}.`)}
78
85
  path={basePath}
86
+ noindex={isEmptyCollection}
79
87
  jsonLd={[
80
88
  breadcrumbLd(crumbs, canonicalBase),
81
89
  itemListLd(products, canonicalBase, (page - 1) * pageSize),
@@ -8,11 +8,19 @@ import Breadcrumbs from "@/components/Breadcrumbs.astro";
8
8
  import CollectionCard from "@/components/CollectionCard.astro";
9
9
  import EmptyState from "@/components/EmptyState.astro";
10
10
  import { breadcrumbLd } from "@/lib/jsonld";
11
+ import { activeMemberCount, nonEmptyCollections } from "@/lib/tot/query";
11
12
 
12
13
  const { tenant, tot, canonicalBase } = Astro.locals;
13
14
  // Commerce-only route: marketing tenants have no catalog surface.
14
15
  if (!Astro.locals.features.catalog) return new Response(null, { status: 404 });
15
- const collections = await tot.listCollections();
16
+ // Directory lists only populated collections empty synthesized shells are
17
+ // hidden here just as they're kept out of the sitemap/llms.txt. Product counts
18
+ // reflect ACTIVE membership (product_handles can list drafts/dropped items).
19
+ const [allCollections, activeProducts] = await Promise.all([
20
+ tot.listCollections(),
21
+ tot.getAllActiveProducts(),
22
+ ]);
23
+ const collections = nonEmptyCollections(allCollections, activeProducts);
16
24
 
17
25
  const crumbs = [
18
26
  { label: tenant.displayName, href: "/" },
@@ -51,7 +59,7 @@ const crumbs = [
51
59
  ) : (
52
60
  <div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
53
61
  {collections.map((c) => (
54
- <CollectionCard collection={c} productCount={c.product_handles.length} />
62
+ <CollectionCard collection={c} productCount={activeMemberCount(c, activeProducts)} />
55
63
  ))}
56
64
  </div>
57
65
  )}
@@ -0,0 +1,119 @@
1
+ ---
2
+ /**
3
+ * /dashboard/<appDomain>/apps — the console's app-health list (PrivateApps
4
+ * epic, D8 Chunk C). Guarded the same way as `../team.astro`: signed-in
5
+ * session + a membership for this appDomain, else bounce.
6
+ *
7
+ * Thin shell over one session-authed endpoint:
8
+ * GET /api/apps/admin/health?tenant=<appDomain> → AppInstallHealthView[]
9
+ * `canManage` is resolved server-side from the SAME `MANAGE_ROLES` set the
10
+ * mutating admin routes gate on, and passed to the client via `data-can-
11
+ * manage` so a `developer` viewer never even sees the suspend/uninstall/
12
+ * rotate buttons rendered (the routes still enforce it independently —
13
+ * this is UX, not the security boundary).
14
+ */
15
+ export const prerender = false;
16
+
17
+ import { readViewerSession } from "@/lib/auth/route";
18
+ import { membershipFor } from "@/lib/dashboard/tenantSelection";
19
+ import { MANAGE_ROLES } from "@/lib/apps/adminSession.js";
20
+
21
+ const cspNonce = Astro.locals.cspNonce;
22
+ const appDomain = decodeURIComponent(Astro.params.appDomain ?? "");
23
+
24
+ const session = await readViewerSession(Astro);
25
+ if (!session) {
26
+ return Astro.redirect(
27
+ `/auth/login?next=${encodeURIComponent(`/dashboard/${appDomain}/apps`)}`,
28
+ 302,
29
+ );
30
+ }
31
+ const membership = membershipFor(session.tenants, appDomain, {
32
+ selection: session.staffSelection,
33
+ nowSeconds: Math.floor(Date.now() / 1000),
34
+ });
35
+ if (!membership) {
36
+ return Astro.redirect("/dashboard", 302);
37
+ }
38
+ const canManage = MANAGE_ROLES.has(membership.role);
39
+ const backHref = `/dashboard/${encodeURIComponent(appDomain)}`;
40
+ ---
41
+
42
+ <html lang="en">
43
+ <head>
44
+ <meta charset="utf-8" />
45
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
46
+ <meta name="robots" content="noindex, nofollow" />
47
+ <title>Apps — {appDomain}</title>
48
+ <style nonce={cspNonce}>
49
+ :root { color-scheme: light dark; }
50
+ * { box-sizing: border-box; }
51
+ body { font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
52
+ margin: 0; min-height: 100vh; padding: 2.5rem 1.25rem; background: #f5f4f0; color: #1c1c1c; }
53
+ @media (prefers-color-scheme: dark) { body { background: #14141a; color: #ececf1; } }
54
+ main { max-width: 48rem; margin-inline: auto; }
55
+ .eyebrow { font: 600 .68rem/1 ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; color: #8a7f66; margin: 0 0 .5rem; }
56
+ h1 { font-size: 1.5rem; letter-spacing: -.01em; margin: 0 0 .3rem; }
57
+ .sub { color: #6a6a6a; margin: 0 0 1.5rem; }
58
+ @media (prefers-color-scheme: dark) { .sub { color: #a2a2ad; } }
59
+ #apps { display: flex; flex-direction: column; gap: 1rem; }
60
+ .card { background: #fff; border: 1px solid #e6e4de; border-radius: 12px; padding: 1.15rem 1.25rem; }
61
+ @media (prefers-color-scheme: dark) { .card { background: #1e1e28; border-color: #33333f; } }
62
+ .card .top { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
63
+ .card .name { font-weight: 600; font-size: 1.02rem; }
64
+ .badge { font: 600 .68rem/1 ui-monospace, monospace; text-transform: uppercase; letter-spacing: .06em;
65
+ border: 1px solid currentColor; border-radius: 999px; padding: .2rem .55rem; }
66
+ .badge.status-active, .badge.health-healthy { color: #2e8b45; }
67
+ .badge.status-suspended, .badge.health-degraded { color: #b5860b; }
68
+ .badge.status-uninstalled, .badge.health-unhealthy, .badge.egress-blocked { color: #b13a3a; }
69
+ .badge.egress-ok { color: #2e8b45; }
70
+ .badge.egress-no_endpoint { color: #8a8a8a; }
71
+ dl.meta { display: grid; grid-template-columns: max-content 1fr; gap: .25rem 1rem; margin: .8rem 0 0; font-size: .85rem; }
72
+ dl.meta dt { color: #8a8a8a; }
73
+ dl.meta dd { margin: 0; }
74
+ .traces { font: .78rem/1.4 ui-monospace, monospace; color: #8a8a8a; word-break: break-all; }
75
+ .actions { margin-top: 1rem; display: flex; gap: .5rem; flex-wrap: wrap; }
76
+ .rowbtn { padding: .4rem .8rem; font-size: .82rem; background: transparent; color: inherit;
77
+ border: 1px solid #cfccc4; border-radius: 7px; font-weight: 600; cursor: pointer; }
78
+ @media (prefers-color-scheme: dark) { .rowbtn { border-color: #40404d; } }
79
+ .rowbtn:hover:not(:disabled) { border-color: #b7d0ff; }
80
+ .rowbtn:disabled { opacity: .5; cursor: default; }
81
+ .rowbtn.danger { color: #b13a3a; border-color: #e3b3b3; }
82
+ .msg { margin-top: .8rem; font-size: .85rem; border-radius: 9px; padding: .5rem .7rem; display: none; }
83
+ .msg.ok { display: block; background: #eaf7ee; border: 1px solid #bfe4c9; color: #1b6b34; }
84
+ .msg.err { display: block; background: #fdecec; border: 1px solid #f5c2c2; color: #9a1c1c; }
85
+ @media (prefers-color-scheme: dark) {
86
+ .msg.ok { background: #16311f; border-color: #2f6b41; color: #a7e0ba; }
87
+ .msg.err { background: #3a1f22; border-color: #6b2f34; color: #f6b9b9; }
88
+ }
89
+ .muted { color: #9a9a9a; font-size: .9rem; }
90
+ .who { margin-top: 2rem; font-size: .82rem; color: #8a8a8a; }
91
+ .who a, a.back { color: inherit; }
92
+ .linkbtn { background: none; border: 0; color: inherit; font: inherit; text-decoration: underline; cursor: pointer; padding: 0; margin: 0; }
93
+ .logout { display: inline; }
94
+ </style>
95
+ </head>
96
+ <body>
97
+ <main>
98
+ <p class="eyebrow"><a class="back" href={backHref}>← {appDomain}</a></p>
99
+ <h1>Apps</h1>
100
+ <p class="sub">Installed apps and their webhook health for <strong>{appDomain}</strong>.{!canManage &&
101
+ " Viewing read-only — ask an admin or owner to suspend, uninstall, or rotate credentials."}</p>
102
+
103
+ <div id="apps" data-tenant={appDomain} data-can-manage={String(canManage)}>
104
+ <p class="muted">Loading…</p>
105
+ </div>
106
+
107
+ <p class="who">Signed in as {session.email} ·
108
+ <a href="/dashboard">Switch vendor</a> ·
109
+ <form class="logout" method="post" action="/api/auth/logout"><button type="submit" class="linkbtn">Sign out</button></form></p>
110
+ </main>
111
+
112
+ <!--
113
+ Client behavior is a STATIC same-origin file (public/js/dashboard-apps.js),
114
+ referenced via src so the strict CSP allows it through `script-src 'self'` — no
115
+ nonce, no per-build content-hash. Mirrors team.astro's script tag exactly.
116
+ -->
117
+ <script src="/js/dashboard-apps.js" is:inline defer></script>
118
+ </body>
119
+ </html>
@@ -34,6 +34,7 @@ if (!membership) {
34
34
  return Astro.redirect("/dashboard", 302);
35
35
  }
36
36
  const teamHref = `/dashboard/${encodeURIComponent(appDomain)}/team`;
37
+ const appsHref = `/dashboard/${encodeURIComponent(appDomain)}/apps`;
37
38
  ---
38
39
 
39
40
  <html lang="en">
@@ -90,6 +91,10 @@ const teamHref = `/dashboard/${encodeURIComponent(appDomain)}/team`;
90
91
  <p class="h">Preview &amp; deploy →</p>
91
92
  <p class="d">The tenant's shared preview: target vs actual, reconcile, preview URL.</p>
92
93
  </a>
94
+ <a class="card" href={appsHref}>
95
+ <p class="h">Apps →</p>
96
+ <p class="d">Installed apps, webhook health, and manage suspend/uninstall/rotate.</p>
97
+ </a>
93
98
  </div>
94
99
 
95
100
  <p class="who">Signed in as {session.email} ·
@@ -13,8 +13,12 @@ import ProductCard from "@/components/ProductCard.astro";
13
13
  import CollectionCard from "@/components/CollectionCard.astro";
14
14
  import Img from "@/components/Img.astro";
15
15
  import MarketingBlocks from "@/components/marketing/MarketingBlocks.astro";
16
+ import TierCards from "@/components/membership/TierCards.astro";
16
17
  import { withBase } from "@/lib/basePath";
17
18
  import { rawHtmlPolicy, rawHtmlResponse } from "@/lib/rawMarketingHtml";
19
+ import AppWidgetFrame from "@/components/apps/AppWidgetFrame.astro";
20
+ import { getRegistryService } from "@/lib/apps/adminService.js";
21
+ import { renderWidgetSlot } from "@/lib/apps/widgets/renderSlot.js";
18
22
  import type { CatalogCollection, CatalogProduct } from "@tot/public-runtime";
19
23
  import {
20
24
  isMarketingBlock,
@@ -22,9 +26,11 @@ import {
22
26
  type EditorialBlock,
23
27
  type NewsletterBlock,
24
28
  type PromoTilesBlock,
29
+ type MembershipBandBlock,
25
30
  type MarketingBlock,
26
31
  type FeaturedProductsBlock,
27
32
  } from "@/lib/storyblok/content-model";
33
+ import type { Tier } from "@/lib/membership/model";
28
34
  import {
29
35
  normalizeProductSectionSource,
30
36
  } from "@tot/public-runtime";
@@ -71,6 +77,7 @@ type Resolved =
71
77
  | { kind: "promo_tiles"; title?: string; tiles: PromoTilesBlock["tiles"] }
72
78
  | { kind: "collections"; title?: string; cols: CatalogCollection[] }
73
79
  | { kind: "products"; title?: string; products: CatalogProduct[] }
80
+ | { kind: "membership_band"; block: MembershipBandBlock }
74
81
  | { kind: "editorial"; block: EditorialBlock }
75
82
  | { kind: "newsletter"; block: NewsletterBlock };
76
83
 
@@ -124,6 +131,9 @@ for (const block of marketing ? [] : home.blocks) {
124
131
  resolved.push({ kind: "products", title: block.title, products });
125
132
  break;
126
133
  }
134
+ case "membership_band":
135
+ resolved.push({ kind: "membership_band", block });
136
+ break;
127
137
  case "editorial":
128
138
  resolved.push({ kind: "editorial", block });
129
139
  break;
@@ -132,6 +142,25 @@ for (const block of marketing ? [] : home.blocks) {
132
142
  break;
133
143
  }
134
144
  }
145
+
146
+ // Private-app widgets (D5) — home.section slot. Commerce-home only; marketing-
147
+ // tenant homes (raw HTML, above, and the marketing-block home) are out of
148
+ // scope for D5 (deferred). No dark-mode preference infra exists yet — "light"
149
+ // is the only theme hint until that lands.
150
+ const homeSectionWidgets = marketing
151
+ ? []
152
+ : await renderWidgetSlot(
153
+ {
154
+ registry: await getRegistryService(),
155
+ tenantId: tenant.tenant_id,
156
+ customizationEnv: Astro.locals.customizationEnv,
157
+ slot: "home.section",
158
+ route: "/",
159
+ locale: tenant.locale,
160
+ theme: "light",
161
+ },
162
+ Astro.locals.activeWidgetPlacements,
163
+ );
135
164
  ---
136
165
 
137
166
  <Layout
@@ -165,6 +194,8 @@ for (const block of marketing ? [] : home.blocks) {
165
194
  <img
166
195
  src={t.image}
167
196
  alt={t.label}
197
+ width={1600}
198
+ height={900}
168
199
  loading="lazy"
169
200
  class="absolute inset-0 h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
170
201
  />
@@ -200,6 +231,20 @@ for (const block of marketing ? [] : home.blocks) {
200
231
  </Section>
201
232
  );
202
233
  }
234
+ if (r.kind === "membership_band") {
235
+ const b = r.block;
236
+ // Base-path-prefix each tier CTA so home-band links stay in-tenant under
237
+ // path-prefix preview routing (no-op host-routed). TierCards owns the
238
+ // illustrative-figures affordance from the Tier data itself.
239
+ const tiers: Tier[] = b.tiers.map((t) =>
240
+ t.cta ? { ...t, cta: { ...t.cta, href: withBase(basePath, t.cta.href) } } : t,
241
+ );
242
+ return (
243
+ <section class="bg-[var(--color-surface)] py-[clamp(3rem,7vw,6rem)]">
244
+ <TierCards tiers={tiers} eyebrow={b.eyebrow} heading={b.title} subhead={b.subhead} />
245
+ </section>
246
+ );
247
+ }
203
248
  if (r.kind === "editorial") {
204
249
  const b = r.block;
205
250
  const right = b.align !== "left";
@@ -281,4 +326,16 @@ for (const block of marketing ? [] : home.blocks) {
281
326
  return null;
282
327
  })
283
328
  }
329
+ {/* Private-app widgets (D5, home.section) — sandboxed per install. */}
330
+ {homeSectionWidgets.map((w) => (
331
+ <Section>
332
+ <AppWidgetFrame
333
+ endpoint={w.endpoint}
334
+ launchToken={w.launchToken}
335
+ appOrigin={w.appOrigin}
336
+ installId={w.installId}
337
+ title={w.title ?? "App widget"}
338
+ />
339
+ </Section>
340
+ ))}
284
341
  </Layout>
@@ -10,7 +10,8 @@
10
10
  * SEO text; compliance facts come from tenant.compliance.
11
11
  */
12
12
  import type { APIContext } from "astro";
13
- import { createContentProvider } from "@/lib/storyblok/provider";
13
+ import { nonEmptyCollections } from "@/lib/tot/query.js";
14
+ import { listBlogPosts } from "@/lib/blog/provider.js";
14
15
 
15
16
  /** Collapse HTML/whitespace to a single trimmed line for a link description. */
16
17
  function oneLine(s: string | undefined, max = 140): string | undefined {
@@ -21,16 +22,23 @@ function oneLine(s: string | undefined, max = 140): string | undefined {
21
22
  }
22
23
 
23
24
  export async function GET(context: APIContext): Promise<Response> {
24
- const { tot, tenant, canonicalBase } = context.locals;
25
+ const { tot, tenant, canonicalBase, content } = context.locals;
25
26
  const base = canonicalBase.replace(/\/$/, "");
26
- const provider = createContentProvider(tenant);
27
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
- ]);
28
+ // Use the request-resolved ContentProvider (locals.content) so any published /
29
+ // dev-draft overlay is reflected, matching sitemap.xml.ts (not a fresh provider).
30
+ const [allCollections, activeProducts, pages, featured, home, blogPosts] =
31
+ await Promise.all([
32
+ tot.listCollections(),
33
+ tot.getAllActiveProducts(),
34
+ content.listPages(),
35
+ tot.getFeatured(12),
36
+ content.getHome().catch(() => null),
37
+ listBlogPosts(tenant.tenant_id),
38
+ ]);
39
+ // Empty synthesized collections (no active members) never make the guide —
40
+ // an LLM shouldn't be pointed at a URL that resolves to zero products.
41
+ const collections = nonEmptyCollections(allCollections, activeProducts);
34
42
 
35
43
  const lines: string[] = [`# ${tenant.displayName}`, ""];
36
44
 
@@ -75,11 +83,24 @@ export async function GET(context: APIContext): Promise<Response> {
75
83
  lines.push("");
76
84
  }
77
85
 
86
+ if (blogPosts.length) {
87
+ lines.push("## Blog");
88
+ lines.push(`- [All articles](${base}/blog/)`);
89
+ for (const post of blogPosts) {
90
+ const desc = oneLine(post.description || post.excerpt);
91
+ lines.push(`- [${post.title}](${base}/blog/${post.slug}/)${desc ? `: ${desc}` : ""}`);
92
+ }
93
+ lines.push("");
94
+ }
95
+
78
96
  lines.push("## Optional");
79
97
  lines.push(`- [Full product catalog (XML sitemap)](${base}/sitemap.xml)`);
80
98
  lines.push(`- [Search](${base}/search)`);
81
99
  lines.push(
82
- "- Every page ships Schema.org JSON-LD (Product, Offer, AggregateRating, Breadcrumb, Organization) for structured extraction.",
100
+ // Only the always-present graphs are advertised. AggregateRating/Review are
101
+ // emitted per-product ONLY where real review data exists (many stores have
102
+ // none), so we don't claim them site-wide — no fabricated capability.
103
+ "- Every page ships Schema.org JSON-LD (Product, Offer, Breadcrumb, Organization, WebSite, ItemList) for structured extraction; product ratings/reviews appear where the merchant has real review data.",
83
104
  );
84
105
  lines.push("");
85
106
 
@@ -12,24 +12,38 @@ import ProductCard from "@/components/ProductCard.astro";
12
12
  import Section from "@/components/Section.astro";
13
13
  import ImageGallery from "@/components/islands/ImageGallery.tsx";
14
14
  import VariantSelector from "@/components/islands/VariantSelector.tsx";
15
+ import CheckoutComplianceGate from "@/components/islands/CheckoutComplianceGate.tsx";
15
16
  import RecentlyViewed from "@/components/islands/RecentlyViewed.tsx";
16
17
  import NicotineWarning from "@/components/compliance/NicotineWarning.astro";
17
18
  import ReviewSummary from "@/components/commerce/ReviewSummary.astro";
18
19
  import ReviewList from "@/components/commerce/ReviewList.astro";
19
20
  import { isOnSale, isNew, isAvailable, fromPrice } from "@/lib/tot/query";
20
21
  import {
21
- buildVariantCartPayload,
22
+ buildMemberSignedCartPayload,
22
23
  normalizePurchaseOptions,
24
+ visiblePurchaseOptions,
25
+ isAutopilotEnabled,
26
+ resolveMembershipRule,
27
+ DEFAULT_STACKING_POLICY,
28
+ readReviewAggregate,
23
29
  } from "@tot/public-runtime";
24
30
  import type {
25
31
  ProductPurchaseOption,
26
32
  VariantCartPayload,
33
+ MembershipTierRule,
34
+ MembershipStackingPolicy,
35
+ PurchaseOptionPricingContext,
27
36
  } from "@tot/public-runtime";
28
- import { readCartSecret } from "@/lib/checkoutCommerce";
37
+ import { resolveCartSigning } from "@/lib/checkoutCommerce";
38
+ import { isRegulatedTenant } from "@/lib/compliance/enforcement";
39
+ import { resolveMemberContext } from "@/lib/membership/eligibility";
29
40
  import { truncate } from "@/lib/format";
30
41
  import { productLd, breadcrumbLd, reviewLd } from "@/lib/jsonld";
31
42
  import { isUsableImageUrl } from "@/lib/placeholder";
32
43
  import { withBase } from "@/lib/basePath";
44
+ import AppWidgetFrame from "@/components/apps/AppWidgetFrame.astro";
45
+ import { getRegistryService } from "@/lib/apps/adminService.js";
46
+ import { renderWidgetSlot } from "@/lib/apps/widgets/renderSlot.js";
33
47
 
34
48
  const { handle } = Astro.params;
35
49
  const { tenant, tot, canonicalBase, basePath, commerce } = Astro.locals;
@@ -46,6 +60,23 @@ if (!product || product.status === "archived") {
46
60
  }
47
61
  const isDraft = product.status !== "active";
48
62
 
63
+ // Private-app widgets (D5) — product.aside slot, one iframe per live install.
64
+ // No dark-mode preference is resolved anywhere in the storefront yet; "light"
65
+ // is the only theme hint until that infra exists.
66
+ const productAsideWidgets = await renderWidgetSlot(
67
+ {
68
+ registry: await getRegistryService(),
69
+ tenantId: tenant.tenant_id,
70
+ customizationEnv: Astro.locals.customizationEnv,
71
+ slot: "product.aside",
72
+ route: `/products/${product.handle}`,
73
+ productHandle: product.handle,
74
+ locale: tenant.locale,
75
+ theme: "light",
76
+ },
77
+ Astro.locals.activeWidgetPlacements,
78
+ );
79
+
49
80
  // Related: same collection first, then shared-tag fallback, exclude self+drafts.
50
81
  const related = await (async () => {
51
82
  const out = new Map<string, typeof product>();
@@ -79,9 +110,11 @@ function titleCase(s: string) {
79
110
  const mf = (ns: string, key: string) =>
80
111
  product.metafields.find((m) => m.namespace === ns && m.key === key)?.value;
81
112
 
82
- // Reviews — REAL data from the store's reviews app (Stamped), pulled as metafields.
83
- const reviewCount = Number(mf("stamped", "reviews_count") ?? mf("reviews", "count") ?? "0");
84
- const reviewAvg = Number(mf("stamped", "reviews_average") ?? mf("reviews", "rating") ?? "0");
113
+ // Reviews — REAL aggregate from the store's reviews app, mirrored to metafields.
114
+ // readReviewAggregate is the single, real-or-absent reader (§F9): it parses both
115
+ // the bare and JSON-blob rating forms and the real count key, and returns {0,0}
116
+ // unless BOTH numbers are real positives — so no fabricated rating can render.
117
+ const { average: reviewAvg, count: reviewCount } = readReviewAggregate(product);
85
118
 
86
119
  // Short description (merchant's concise summary) shown high on the PDP, like the
87
120
  // source store. The longer description_html still renders below the buy column.
@@ -148,6 +181,29 @@ const available = isAvailable(product);
148
181
  // side so the island can render a signed, tamper-proof form. In `signed` mode a
149
182
  // missing secret means we render NO cart (never a tamperable form); the
150
183
  // `unsigned-demo` mode needs no secret. Drafts still get a cart (preview links).
184
+ // Membership + Autopilot mechanics (F10). Member pricing renders only once a
185
+ // real eligibility signal flows through resolveMemberContext (Phase 2); Phase 1
186
+ // is anonymous. Autopilot (subscription options) is hidden when the tenant has
187
+ // set commerce.autopilot.enabled = false (hide-at-launch).
188
+ const member = resolveMemberContext();
189
+ const membershipProgram = tenant.commerce?.membership;
190
+ const membershipRule: MembershipTierRule | undefined = resolveMembershipRule(
191
+ member,
192
+ membershipProgram,
193
+ );
194
+ const pricing: {
195
+ membershipRule: MembershipTierRule;
196
+ stacking: MembershipStackingPolicy;
197
+ } | undefined = membershipRule
198
+ ? { membershipRule, stacking: membershipProgram?.stacking ?? DEFAULT_STACKING_POLICY }
199
+ : undefined;
200
+ const autopilotEnabled = isAutopilotEnabled(tenant.commerce?.autopilot);
201
+ // Server-side pricing context (F11b). The SIGNED price is the member-adjusted
202
+ // effective price for THIS request's resolved eligibility — never the list price,
203
+ // and never re-derived client-side. Phase 1 resolves anonymous, so this yields the
204
+ // list/subscription price; a Phase-2 member request signs their member price.
205
+ const pricingCtx: PurchaseOptionPricingContext = { member, program: membershipProgram };
206
+
151
207
  let checkoutCart:
152
208
  | {
153
209
  action: string;
@@ -156,10 +212,16 @@ let checkoutCart:
156
212
  }
157
213
  | undefined;
158
214
  if (commerce) {
159
- const secret = await readCartSecret(commerce);
160
- if (commerce.mode !== "signed" || secret) {
215
+ // Fail closed for production signed checkout: a signed tenant with no
216
+ // cart-validation secret builds NO cart (never a tamperable form). See
217
+ // resolveCartSigning — it also logs the misconfiguration loudly.
218
+ const { enabled, secret } = await resolveCartSigning(commerce);
219
+ if (enabled) {
161
220
  try {
162
- const purchaseOptions = normalizePurchaseOptions(product);
221
+ const purchaseOptions = visiblePurchaseOptions(
222
+ normalizePurchaseOptions(product),
223
+ { autopilotEnabled },
224
+ );
163
225
  const entries = await Promise.all(
164
226
  product.variants.map(
165
227
  async (v) => {
@@ -168,10 +230,12 @@ if (commerce) {
168
230
  async (purchaseOption) =>
169
231
  [
170
232
  purchaseOption.id,
171
- await buildVariantCartPayload(product, v, {
233
+ await buildMemberSignedCartPayload(product, v, {
172
234
  mode: commerce.mode,
173
235
  secret,
174
236
  purchaseOption,
237
+ pricing: pricingCtx,
238
+ autopilotEnabled,
175
239
  }),
176
240
  ] as const,
177
241
  ),
@@ -298,8 +362,22 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
298
362
  options={product.options}
299
363
  currency={product.price_range.currency}
300
364
  locale={tenant.locale}
365
+ productHandle={product.handle}
366
+ productTitle={product.title}
301
367
  checkoutCart={checkoutCart}
368
+ pricing={pricing}
302
369
  />
370
+ {/* Regulated-tenant checkout ENFORCEMENT gate (F12/T5). Intercepts the
371
+ managed-cart handoff to clear age + ship-to compliance server-side
372
+ before the external checkout. Only mounts when a store is wired AND
373
+ the tenant is regulated. */}
374
+ {checkoutCart && isRegulatedTenant(tenant) && compliance?.minAge && (
375
+ <CheckoutComplianceGate
376
+ client:load
377
+ preflightUrl={withBase(basePath, "/api/compliance/preflight")}
378
+ minAge={compliance.minAge}
379
+ />
380
+ )}
303
381
  <button
304
382
  type="button"
305
383
  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)]"
@@ -314,6 +392,19 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
314
392
  </button>
315
393
  </div>
316
394
 
395
+ {/* Private-app widgets (D5, product.aside) — sandboxed per install. */}
396
+ {productAsideWidgets.map((w) => (
397
+ <div class="mt-7">
398
+ <AppWidgetFrame
399
+ endpoint={w.endpoint}
400
+ launchToken={w.launchToken}
401
+ appOrigin={w.appOrigin}
402
+ installId={w.installId}
403
+ title={w.title ?? "App widget"}
404
+ />
405
+ </div>
406
+ ))}
407
+
317
408
  {/* Rich description + attribute lines — matches the source store's PDP:
318
409
  the longer description, then plain "Label: value" lines (no spec table). */}
319
410
  <div class="mt-9 border-t border-[var(--color-border)] pt-7">
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * Per-tenant XML sitemap. Built from the resolved tenant's canonical base +
3
3
  * catalog. ACTIVE products only — getAllActiveProducts() excludes the draft
4
- * canary (`trail-cap`), so drafts never leak into the sitemap. Editorial slugs
5
- * come from the ContentProvider.
4
+ * canary (`trail-cap`), so drafts never leak into the sitemap. Collections are
5
+ * filtered to those with at least one active product (nonEmptyCollections), so
6
+ * empty synthesized collection shells never become crawlable dead ends.
7
+ * Editorial slugs come from the ContentProvider. The canonical base is resolved
8
+ * per-request (prod domain OR path-prefixed preview host), so every emitted
9
+ * loc is prefix-correct without special-casing here.
6
10
  */
7
11
  import type { APIContext } from "astro";
12
+ import { nonEmptyCollections } from "@/lib/tot/query.js";
13
+ import { listBlogPosts } from "@/lib/blog/provider.js";
8
14
 
9
15
  interface SitemapUrl {
10
16
  loc: string;
@@ -22,7 +28,7 @@ function escapeXml(value: string): string {
22
28
  }
23
29
 
24
30
  export async function GET(context: APIContext): Promise<Response> {
25
- const { tot, canonicalBase, content, features } = context.locals;
31
+ const { tot, tenant, canonicalBase, content, features } = context.locals;
26
32
  const base = canonicalBase.replace(/\/$/, "");
27
33
 
28
34
  const urls: SitemapUrl[] = [{ loc: `${base}/` }];
@@ -30,10 +36,13 @@ export async function GET(context: APIContext): Promise<Response> {
30
36
  // Commerce URLs only for tenants with a catalog sitemap. Marketing tenants
31
37
  // emit homepage + content pages, keeping product/collection routes out.
32
38
  if (features.commerceSitemap) {
33
- const [products, collections] = await Promise.all([
39
+ const [products, allCollections] = await Promise.all([
34
40
  tot.getAllActiveProducts(),
35
41
  tot.listCollections(),
36
42
  ]);
43
+ // Only collections with at least one active product — empty synthesized
44
+ // shells (all-draft/dropped membership) stay out of the sitemap.
45
+ const collections = nonEmptyCollections(allCollections, products);
37
46
  urls.push({ loc: `${base}/collections` });
38
47
  for (const c of collections) {
39
48
  urls.push({ loc: `${base}/collections/${c.handle}` });
@@ -56,6 +65,14 @@ export async function GET(context: APIContext): Promise<Response> {
56
65
  urls.push({ loc: `${base}/${page.slug}` });
57
66
  }
58
67
 
68
+ const blogPosts = await listBlogPosts(tenant.tenant_id);
69
+ if (blogPosts.length > 0) {
70
+ urls.push({ loc: `${base}/blog/` });
71
+ for (const post of blogPosts) {
72
+ urls.push({ loc: `${base}/blog/${post.slug}/`, lastmod: post.modifiedAt || post.publishedAt });
73
+ }
74
+ }
75
+
59
76
  const body = `<?xml version="1.0" encoding="UTF-8"?>
60
77
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
61
78
  ${urls