@tokenoftrust/storefront-runner 1.4.0 → 1.4.2-rc.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 (193) hide show
  1. package/apps/storefront/astro.config.mjs +17 -1
  2. package/apps/storefront/integrations/materialize-guard.mjs +56 -0
  3. package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
  4. package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
  5. package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
  6. package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
  7. package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
  8. package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +21 -0
  10. package/apps/storefront/package.json +0 -1
  11. package/apps/storefront/public/js/dashboard-team.js +38 -2
  12. package/apps/storefront/src/components/Seo.astro +65 -1
  13. package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
  14. package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
  15. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
  16. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1756 -1382
  17. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
  18. package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
  19. package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
  20. package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
  21. package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
  22. package/apps/storefront/src/layouts/Layout.astro +48 -3
  23. package/apps/storefront/src/lib/activity/alerts.ts +426 -0
  24. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
  25. package/apps/storefront/src/lib/activity/deployVersion.ts +120 -0
  26. package/apps/storefront/src/lib/activity/ingest.ts +188 -0
  27. package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
  28. package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
  29. package/apps/storefront/src/lib/activity/query.ts +403 -0
  30. package/apps/storefront/src/lib/activity/recordActivity.ts +101 -0
  31. package/apps/storefront/src/lib/activity/store.ts +120 -0
  32. package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
  33. package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
  34. package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
  35. package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
  36. package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
  37. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
  38. package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
  39. package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
  40. package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
  41. package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
  42. package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
  43. package/apps/storefront/src/lib/apps/adminService.ts +4 -4
  44. package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
  45. package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
  46. package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
  47. package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
  48. package/apps/storefront/src/lib/apps/credentials.ts +2 -2
  49. package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
  50. package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
  51. package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
  52. package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
  53. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
  54. package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
  55. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
  56. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
  57. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
  58. package/apps/storefront/src/lib/apps/registryService.ts +6 -6
  59. package/apps/storefront/src/lib/apps/scopes.ts +2 -2
  60. package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
  61. package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
  62. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
  63. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
  64. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
  65. package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
  66. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
  67. package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
  68. package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
  69. package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
  70. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +6 -4
  71. package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
  72. package/apps/storefront/src/lib/auth/route.ts +44 -4
  73. package/apps/storefront/src/lib/auth/session.ts +7 -8
  74. package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
  75. package/apps/storefront/src/lib/basePath.ts +9 -2
  76. package/apps/storefront/src/lib/blog/access.ts +29 -0
  77. package/apps/storefront/src/lib/blog/collection.ts +313 -0
  78. package/apps/storefront/src/lib/blog/markdown.ts +174 -0
  79. package/apps/storefront/src/lib/blog/provider.ts +5 -0
  80. package/apps/storefront/src/lib/blog/reactions.ts +87 -0
  81. package/apps/storefront/src/lib/blog/rss.ts +92 -0
  82. package/apps/storefront/src/lib/blog/teaser.ts +83 -0
  83. package/apps/storefront/src/lib/blog/types.ts +34 -0
  84. package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
  85. package/apps/storefront/src/lib/d1/catalog.ts +2 -2
  86. package/apps/storefront/src/lib/d1/schema-apps.ts +147 -14
  87. package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
  88. package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
  89. package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
  90. package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
  91. package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
  92. package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
  93. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
  94. package/apps/storefront/src/lib/edgeCache.ts +17 -0
  95. package/apps/storefront/src/lib/env.ts +51 -0
  96. package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
  97. package/apps/storefront/src/lib/i18n.ts +25 -0
  98. package/apps/storefront/src/lib/jsonld.ts +118 -0
  99. package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
  100. package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
  101. package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
  102. package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
  103. package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
  104. package/apps/storefront/src/lib/newsletter/send.ts +265 -0
  105. package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
  106. package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
  107. package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
  108. package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
  109. package/apps/storefront/src/lib/publish/domainState.ts +22 -13
  110. package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
  111. package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -42
  112. package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
  113. package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
  114. package/apps/storefront/src/lib/search/index.ts +2 -2
  115. package/apps/storefront/src/lib/seo/alternates.ts +42 -0
  116. package/apps/storefront/src/lib/seo/meta.ts +65 -0
  117. package/apps/storefront/src/lib/social/golive.ts +212 -0
  118. package/apps/storefront/src/lib/social/notify.ts +32 -0
  119. package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
  120. package/apps/storefront/src/lib/the-build/provider.ts +27 -0
  121. package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
  122. package/apps/storefront/src/lib/the-build/toc.ts +95 -0
  123. package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
  124. package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
  125. package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
  126. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
  127. package/apps/storefront/src/middleware/index.ts +130 -2
  128. package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
  129. package/apps/storefront/src/pages/admin.astro +52 -1206
  130. package/apps/storefront/src/pages/api/activity.ts +137 -0
  131. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
  132. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
  133. package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
  134. package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
  135. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
  136. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
  137. package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
  138. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
  139. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
  140. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
  141. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
  142. package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
  143. package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
  144. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
  145. package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
  146. package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
  147. package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
  148. package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
  149. package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
  150. package/apps/storefront/src/pages/auth/login.astro +5 -4
  151. package/apps/storefront/src/pages/blog/[slug].astro +96 -16
  152. package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
  153. package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
  154. package/apps/storefront/src/pages/blog/index.astro +2 -0
  155. package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
  156. package/apps/storefront/src/pages/cockpit.astro +74 -12
  157. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
  158. package/apps/storefront/src/pages/search.astro +1 -1
  159. package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
  160. package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
  161. package/apps/storefront/src/pages/the-build/index.astro +146 -0
  162. package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
  163. package/apps/storefront/src/styles/admin.css +1080 -0
  164. package/apps/storefront/tsconfig.json +5 -0
  165. package/package.json +1 -1
  166. package/packages/public-runtime/package.json +1 -0
  167. package/packages/public-runtime/src/activity/README.md +146 -0
  168. package/packages/public-runtime/src/activity/catalog.ts +553 -0
  169. package/packages/public-runtime/src/activity/event.ts +168 -0
  170. package/packages/public-runtime/src/activity/index.ts +16 -0
  171. package/packages/public-runtime/src/activity/redaction.ts +263 -0
  172. package/packages/public-runtime/src/candidate-index.ts +13 -0
  173. package/packages/public-runtime/src/catalog-d1.ts +1 -1
  174. package/packages/public-runtime/src/customization-preview.ts +25 -2
  175. package/packages/public-runtime/src/customization-runtime.ts +125 -3
  176. package/packages/public-runtime/src/customization-versioning.ts +10 -0
  177. package/packages/public-runtime/src/index.ts +7 -0
  178. package/packages/public-runtime/src/static-bundle.ts +357 -0
  179. package/packages/public-runtime/src/tenant.ts +36 -8
  180. package/scripts/build/copy-tenant-assets.mjs +14 -4
  181. package/scripts/dev/ai-edit.mjs +1 -1
  182. package/scripts/dev/checkout-watch.mjs +2 -2
  183. package/scripts/dev/file-browser.mjs +2 -4
  184. package/scripts/dev/file-writer.mjs +1 -1
  185. package/scripts/dev/git-status.mjs +2 -2
  186. package/scripts/dev/locate-handler.mjs +3 -3
  187. package/scripts/dev/port-check.mjs +3 -3
  188. package/scripts/dev/publish.mjs +5 -9
  189. package/scripts/dev/shot.mjs +1 -1
  190. package/scripts/dev/unified-diff.mjs +8 -3
  191. package/scripts/tot-dev.mjs +6 -9
  192. package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
  193. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
@@ -4,6 +4,7 @@
4
4
  * truth; lifecycle actions require a later subscription API and audit gates.
5
5
  */
6
6
  import { getSubscriptionsViewModel } from "@/lib/admin/services/mock";
7
+ import { formatDate } from "@/lib/format";
7
8
  import type { AdminTenantEnvelope } from "@/lib/admin/envelope";
8
9
  import type {
9
10
  SubscriptionAttentionRow,
@@ -64,9 +65,6 @@ function formatMoney(value: number): string {
64
65
  return `$${value.toFixed(2)}`;
65
66
  }
66
67
 
67
- function formatDate(iso: string): string {
68
- return new Date(iso).toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" });
69
- }
70
68
  ---
71
69
 
72
70
  <section
@@ -0,0 +1,87 @@
1
+ ---
2
+ // Paywall card shown under the teaser of a gated post the viewer can't read in
3
+ // full. Mirrors the "Coming soon" host gate (`src/lib/auth/gatePage.ts`):
4
+ // badge + headline + one line of value + a single CTA — restyled with the site's
5
+ // design tokens so it reads as native article chrome, and preceded by a fade that
6
+ // dissolves the truncated body into the card.
7
+ import type { BlogAccess } from "@/lib/blog/types.js";
8
+
9
+ interface Props {
10
+ access: BlogAccess;
11
+ next: string;
12
+ signedIn: boolean;
13
+ loginHref: string;
14
+ }
15
+
16
+ const { next, signedIn, loginHref } = Astro.props;
17
+ ---
18
+
19
+ <div class="paywall-fade" aria-hidden="true"></div>
20
+ <aside class="paywall" data-next={next} role="note">
21
+ <span class="paywall-badge">Members only</span>
22
+ {signedIn ? (
23
+ <>
24
+ <h2>You're signed in</h2>
25
+ <p>Membership tiers for this story are coming soon.</p>
26
+ {/* Unreachable in v1 (authenticated ⇒ member) — coded per the B4.0 spike so
27
+ a future paid tier only has to point this CTA at a membership route. */}
28
+ <a class="paywall-cta" href={loginHref}>Become a member</a>
29
+ </>
30
+ ) : (
31
+ <>
32
+ <h2>This story is for members</h2>
33
+ <p>Sign in with Token of Trust to keep reading — it's free.</p>
34
+ <a class="paywall-cta" href={loginHref}>Sign in to keep reading</a>
35
+ </>
36
+ )}
37
+ </aside>
38
+
39
+ <style>
40
+ .paywall-fade {
41
+ height: 7rem;
42
+ margin-top: -7rem;
43
+ position: relative;
44
+ pointer-events: none;
45
+ background: linear-gradient(to bottom, transparent, var(--color-bg));
46
+ }
47
+ .paywall {
48
+ display: flex;
49
+ flex-direction: column;
50
+ align-items: center;
51
+ text-align: center;
52
+ gap: .9rem;
53
+ max-width: 34rem;
54
+ margin: 0 auto;
55
+ padding: clamp(1.75rem, 4vw, 2.5rem);
56
+ border: 1px solid var(--color-border);
57
+ border-radius: var(--radius-lg);
58
+ background: var(--color-surface);
59
+ box-shadow: var(--shadow-md);
60
+ }
61
+ .paywall-badge {
62
+ display: inline-block;
63
+ letter-spacing: .18em;
64
+ text-transform: uppercase;
65
+ font-size: .72rem;
66
+ font-weight: 700;
67
+ color: var(--color-muted);
68
+ border: 1px solid var(--color-border);
69
+ border-radius: var(--radius-full);
70
+ padding: .35rem .8rem;
71
+ }
72
+ .paywall h2 { margin: 0; font-size: clamp(1.5rem, 3vw, 2rem); line-height: 1.15; }
73
+ .paywall p { margin: 0; max-width: 28rem; color: var(--color-muted); font-size: 1.02rem; line-height: 1.6; }
74
+ .paywall-cta {
75
+ margin-top: .4rem;
76
+ display: inline-block;
77
+ text-decoration: none;
78
+ font-weight: 700;
79
+ font-size: 1rem;
80
+ color: var(--color-primary-contrast);
81
+ background: var(--color-primary);
82
+ border-radius: var(--radius-md);
83
+ padding: .8rem 1.6rem;
84
+ transition: background .15s ease;
85
+ }
86
+ .paywall-cta:hover { background: var(--color-primary-hover); }
87
+ </style>
@@ -0,0 +1,43 @@
1
+ ---
2
+ // Visual-only lock badge for a gated post on listing card grids. Reads
3
+ // `post.access` and renders nothing for public posts. No gating logic lives
4
+ // here — entitlement is decided in the route via `viewerHasBlogAccess`; this
5
+ // is purely a "this story is members-only" indicator on listings and archives.
6
+ import type { BlogAccess } from "@/lib/blog/types.js";
7
+
8
+ interface Props {
9
+ access?: BlogAccess;
10
+ }
11
+
12
+ const { access } = Astro.props;
13
+ const gated = (access ?? "public") !== "public";
14
+ ---
15
+
16
+ {gated && (
17
+ <p class="access-badge">
18
+ <svg class="lock" width="12" height="12" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
19
+ <rect x="4" y="11" width="16" height="9" rx="2"></rect>
20
+ <path d="M8 11V7a4 4 0 0 1 8 0v4"></path>
21
+ </svg>
22
+ Members
23
+ </p>
24
+ )}
25
+
26
+ <style>
27
+ .access-badge {
28
+ display: inline-flex;
29
+ gap: .3rem;
30
+ align-items: center;
31
+ align-self: flex-start;
32
+ margin: 0 0 .55rem;
33
+ padding: .2rem .55rem;
34
+ border: 1px solid color-mix(in srgb, var(--color-accent) 45%, var(--color-border));
35
+ border-radius: var(--radius-full);
36
+ background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface));
37
+ color: var(--color-accent-ink);
38
+ font: 700 .68rem/1 var(--font-mono);
39
+ letter-spacing: .06em;
40
+ text-transform: uppercase;
41
+ }
42
+ .lock { flex: 0 0 auto; }
43
+ </style>
@@ -0,0 +1,57 @@
1
+ ---
2
+ // Reader-facing card grid for a list of blog posts, shared by the collection's
3
+ // category and tag index pages. `base` is the collection route base (e.g.
4
+ // "/blog"); `basePath` is the tenant/preview path prefix from Astro.locals.
5
+ import type { BlogPostSummary } from "@/lib/blog/types.js";
6
+ import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
7
+
8
+ interface Props {
9
+ posts: BlogPostSummary[];
10
+ basePath: string;
11
+ base: string;
12
+ }
13
+
14
+ const { posts, basePath, base } = Astro.props;
15
+ const href = (path: string) => `${basePath}${path}` || "/";
16
+ ---
17
+
18
+ <section class="post-grid" aria-label="Posts">
19
+ {posts.map((post, index) => (
20
+ <article class="post-card">
21
+ <a href={href(`${base}/${post.slug}/`)} aria-label={`Read ${post.title}`}>
22
+ {post.featuredImage ? (
23
+ <img
24
+ src={post.featuredImage.src}
25
+ alt={post.featuredImage.alt}
26
+ width={post.featuredImage.width}
27
+ height={post.featuredImage.height}
28
+ loading={index < 3 ? "eager" : "lazy"}
29
+ decoding="async"
30
+ />
31
+ ) : <div class="image-placeholder" aria-hidden="true"></div>}
32
+ <div class="post-card-copy">
33
+ <p class="post-meta"><time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}</time>{post.tags[0] && <> · {post.tags[0]}</>}</p>
34
+ <PostAccessBadge access={post.access} />
35
+ <h2>{post.title}</h2>
36
+ <p>{post.excerpt}</p>
37
+ <span>Read article <span aria-hidden="true">→</span></span>
38
+ </div>
39
+ </a>
40
+ </article>
41
+ ))}
42
+ </section>
43
+
44
+ <style>
45
+ .post-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.35rem; align-items: start; }
46
+ .post-card { height: 100%; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-sm); transition: transform .2s ease, box-shadow .2s ease; }
47
+ .post-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-md); }
48
+ .post-card a { display: flex; height: 100%; flex-direction: column; }
49
+ .post-card img, .image-placeholder { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, white), color-mix(in srgb, var(--color-accent) 24%, white)); }
50
+ .post-card-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
51
+ .post-meta { margin: 0 0 .7rem; color: var(--color-muted); font: 600 .72rem/1.45 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
52
+ .post-card h2 { font-size: 1.18rem; line-height: 1.25; }
53
+ .post-card-copy > p:not(.post-meta) { display: -webkit-box; margin: .75rem 0 1rem; overflow: hidden; color: var(--color-muted); font-size: .92rem; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
54
+ .post-card-copy > span { margin-top: auto; color: var(--color-accent-ink); font-weight: 700; font-size: .88rem; }
55
+ @media (max-width: 980px) { .post-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
56
+ @media (max-width: 520px) { .post-grid { grid-template-columns: 1fr; } }
57
+ </style>
@@ -0,0 +1,214 @@
1
+ ---
2
+ /**
3
+ * E1.2 — reader reaction bar. Server-renders the post's current reaction tallies
4
+ * (from the E1.1 KV counters) and offers the fixed vocabulary as clickable
5
+ * buttons; a client script POSTs the chosen reaction to the public react endpoint
6
+ * and updates the displayed count optimistically, then reconciles against the
7
+ * authoritative tallies the endpoint returns. Anonymous, no identity, no
8
+ * moderation — an emoji tally, the first reader-sentiment signal (complements
9
+ * view counts). Tenant/collection-generic: mounts on any post route.
10
+ */
11
+ import { getReactionCounts, REACTIONS, type Reaction } from "@/lib/blog/reactions.js";
12
+ import { readKv } from "@/lib/env";
13
+ import { withBase } from "@/lib/basePath";
14
+
15
+ interface Props {
16
+ /** Collection id the post lives in (e.g. "blog", "the-build"). */
17
+ collection: string;
18
+ /** Post slug. */
19
+ slug: string;
20
+ }
21
+
22
+ const { collection, slug } = Astro.props;
23
+ const { tenant, basePath, cspNonce } = Astro.locals;
24
+
25
+ const META: Record<Reaction, { emoji: string; label: string }> = {
26
+ like: { emoji: "👍", label: "Like" },
27
+ insightful: { emoji: "💡", label: "Insightful" },
28
+ celebrate: { emoji: "🎉", label: "Celebrate" },
29
+ };
30
+
31
+ const kv = await readKv("TENANT_CACHE");
32
+ const counts = await getReactionCounts(kv, tenant.tenant_id, collection, slug);
33
+
34
+ // The react endpoint is base-path-scoped like every other same-origin POST
35
+ // (mirrors the newsletter form action); host-routed tenants get an empty prefix.
36
+ const endpoint = withBase(
37
+ basePath,
38
+ `/api/blog/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}/react`,
39
+ );
40
+ ---
41
+
42
+ <section class="reaction-bar" aria-label="React to this article" data-reaction-bar data-endpoint={endpoint}>
43
+ <span class="reaction-bar__prompt">React</span>
44
+ <div class="reaction-bar__buttons">
45
+ {
46
+ REACTIONS.map((reaction) => (
47
+ <button
48
+ type="button"
49
+ class="reaction-btn"
50
+ data-reaction={reaction}
51
+ aria-pressed="false"
52
+ aria-label={META[reaction].label}
53
+ >
54
+ <span class="reaction-btn__emoji" aria-hidden="true">
55
+ {META[reaction].emoji}
56
+ </span>
57
+ <span class="reaction-btn__label">{META[reaction].label}</span>
58
+ <span class="reaction-btn__count" data-reaction-count>
59
+ {counts[reaction]}
60
+ </span>
61
+ </button>
62
+ ))
63
+ }
64
+ </div>
65
+ </section>
66
+
67
+ <style>
68
+ .reaction-bar {
69
+ display: flex;
70
+ flex-wrap: wrap;
71
+ align-items: center;
72
+ gap: 0.75rem 1rem;
73
+ width: min(720px, 100%);
74
+ margin: clamp(2.5rem, 6vw, 4rem) auto 0;
75
+ padding-top: clamp(1.5rem, 4vw, 2.25rem);
76
+ border-top: 1px solid var(--color-border);
77
+ }
78
+ .reaction-bar__prompt {
79
+ color: var(--color-muted);
80
+ font-size: 0.8rem;
81
+ font-weight: 700;
82
+ text-transform: uppercase;
83
+ letter-spacing: 0.04em;
84
+ }
85
+ .reaction-bar__buttons {
86
+ display: flex;
87
+ flex-wrap: wrap;
88
+ gap: 0.6rem;
89
+ }
90
+ .reaction-btn {
91
+ display: inline-flex;
92
+ align-items: center;
93
+ gap: 0.45rem;
94
+ padding: 0.5rem 0.85rem;
95
+ border: 1px solid var(--color-border);
96
+ border-radius: var(--radius-full);
97
+ background: var(--color-surface);
98
+ color: var(--color-ink, inherit);
99
+ font: inherit;
100
+ font-size: 0.9rem;
101
+ line-height: 1;
102
+ cursor: pointer;
103
+ transition: border-color 0.15s ease, background 0.15s ease, transform 0.08s ease;
104
+ }
105
+ .reaction-btn:hover {
106
+ border-color: var(--color-accent);
107
+ }
108
+ .reaction-btn:active {
109
+ transform: translateY(1px);
110
+ }
111
+ .reaction-btn[aria-pressed="true"] {
112
+ border-color: var(--color-accent);
113
+ background: color-mix(in srgb, var(--color-accent) 12%, var(--color-surface));
114
+ }
115
+ .reaction-btn__emoji {
116
+ font-size: 1.1rem;
117
+ }
118
+ .reaction-btn__label {
119
+ font-weight: 700;
120
+ }
121
+ .reaction-btn__count {
122
+ min-width: 1.25ch;
123
+ padding-left: 0.15rem;
124
+ color: var(--color-muted);
125
+ font-variant-numeric: tabular-nums;
126
+ font-weight: 700;
127
+ }
128
+ .reaction-btn[aria-pressed="true"] .reaction-btn__count {
129
+ color: var(--color-accent-ink);
130
+ }
131
+ </style>
132
+
133
+ <script is:inline nonce={cspNonce}>
134
+ (() => {
135
+ // One article (and one reaction bar) per page.
136
+ const root = document.querySelector("[data-reaction-bar]");
137
+ if (!root) return;
138
+
139
+ const endpoint = root.dataset.endpoint;
140
+ if (!endpoint) return;
141
+
142
+ // Remember this browser's reactions so a reload shows the pressed state and
143
+ // the same reader can't trivially re-inflate a tally by re-clicking.
144
+ const storeKey = "blogreact:" + endpoint;
145
+ let reacted = {};
146
+ try {
147
+ reacted = JSON.parse(localStorage.getItem(storeKey) || "{}") || {};
148
+ } catch {
149
+ reacted = {};
150
+ }
151
+ const persist = () => {
152
+ try {
153
+ localStorage.setItem(storeKey, JSON.stringify(reacted));
154
+ } catch {
155
+ /* storage disabled — pressed state is best-effort */
156
+ }
157
+ };
158
+
159
+ const buttons = Array.from(root.querySelectorAll("[data-reaction]"));
160
+ const countEl = (btn) => btn.querySelector("[data-reaction-count]");
161
+ const readCount = (btn) => {
162
+ const n = parseInt((countEl(btn)?.textContent || "0").trim(), 10);
163
+ return Number.isFinite(n) && n >= 0 ? n : 0;
164
+ };
165
+ const setCount = (btn, n) => {
166
+ const el = countEl(btn);
167
+ if (el) el.textContent = String(n);
168
+ };
169
+
170
+ for (const btn of buttons) {
171
+ if (reacted[btn.dataset.reaction]) btn.setAttribute("aria-pressed", "true");
172
+ }
173
+
174
+ const applyServerCounts = (counts) => {
175
+ if (!counts || typeof counts !== "object") return;
176
+ for (const btn of buttons) {
177
+ const v = counts[btn.dataset.reaction];
178
+ if (typeof v === "number" && v >= 0) setCount(btn, v);
179
+ }
180
+ };
181
+
182
+ for (const btn of buttons) {
183
+ btn.addEventListener("click", async () => {
184
+ const reaction = btn.dataset.reaction;
185
+ if (!reaction || reacted[reaction]) return;
186
+
187
+ // Optimistic: bump immediately, mark pressed.
188
+ const before = readCount(btn);
189
+ setCount(btn, before + 1);
190
+ btn.setAttribute("aria-pressed", "true");
191
+ reacted[reaction] = true;
192
+ persist();
193
+
194
+ try {
195
+ const res = await fetch(endpoint, {
196
+ method: "POST",
197
+ headers: { "content-type": "application/json" },
198
+ body: JSON.stringify({ reaction }),
199
+ });
200
+ const data = await res.json().catch(() => null);
201
+ // The endpoint always returns the authoritative tallies (even on
202
+ // rate-limit / invalid) — reconcile the display to them.
203
+ if (data && data.counts) applyServerCounts(data.counts);
204
+ } catch {
205
+ // Network fault: revert the optimistic bump and pressed state.
206
+ setCount(btn, before);
207
+ btn.setAttribute("aria-pressed", "false");
208
+ delete reacted[reaction];
209
+ persist();
210
+ }
211
+ });
212
+ }
213
+ })();
214
+ </script>
@@ -36,12 +36,46 @@ interface Props {
36
36
  ogImage?: string;
37
37
  noindex?: boolean;
38
38
  jsonLd?: Record<string, unknown> | Record<string, unknown>[];
39
+ /** Alternate RSS/Atom feeds this page advertises (threaded to <Seo>). */
40
+ feeds?: { title: string; href: string }[];
39
41
  /** Wider pages (PLP/home) drop the prose max-width. */
40
42
  fullBleed?: boolean;
43
+ // --- B1.6 article SEO hardening: pass-through to <Seo> (all additive) ---
44
+ article?: import("@/lib/seo/meta.js").ArticleMeta;
45
+ imageAlt?: string;
46
+ imageWidth?: number;
47
+ imageHeight?: number;
48
+ twitterSite?: string;
49
+ twitterCreator?: string;
50
+ canonicalOverride?: string;
51
+ // --- I2 hreflang / <html lang> (additive) ---
52
+ /** Per-variant `hreflang` alternates for a translated article; threaded to
53
+ * <Seo>. Absent/empty ⇒ no alternate links (singleton unchanged). */
54
+ alternates?: import("@/lib/seo/alternates.js").Alternate[];
55
+ /** Overrides `<html lang>` (e.g. a translated article's `inLanguage`). Absent ⇒
56
+ * the tenant-locale default, so non-translated pages are unchanged. */
57
+ lang?: string;
41
58
  }
42
59
 
43
- const { title, description, path, ogType, ogImage, noindex, jsonLd } =
44
- Astro.props;
60
+ const {
61
+ title,
62
+ description,
63
+ path,
64
+ ogType,
65
+ ogImage,
66
+ noindex,
67
+ jsonLd,
68
+ feeds,
69
+ article,
70
+ imageAlt,
71
+ imageWidth,
72
+ imageHeight,
73
+ twitterSite,
74
+ twitterCreator,
75
+ canonicalOverride,
76
+ alternates,
77
+ lang,
78
+ } = Astro.props;
45
79
  const { tenant, themeStyle, canonicalBase, basePath, cspNonce, features, capabilities } =
46
80
  Astro.locals;
47
81
 
@@ -156,7 +190,7 @@ const footerWidgets = await renderWidgetSlot(
156
190
  ---
157
191
 
158
192
  <!doctype html>
159
- <html lang={tenant.locale.split("-")[0] ?? "en"} data-base-path={basePath || undefined}>
193
+ <html lang={lang ?? tenant.locale.split("-")[0] ?? "en"} data-base-path={basePath || undefined}>
160
194
  <head>
161
195
  <meta charset="utf-8" />
162
196
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -185,6 +219,15 @@ const footerWidgets = await renderWidgetSlot(
185
219
  ogImage={ogImage}
186
220
  noindex={noindex}
187
221
  jsonLd={allJsonLd}
222
+ feeds={feeds}
223
+ article={article}
224
+ imageAlt={imageAlt}
225
+ imageWidth={imageWidth}
226
+ imageHeight={imageHeight}
227
+ twitterSite={twitterSite}
228
+ twitterCreator={twitterCreator}
229
+ canonicalOverride={canonicalOverride}
230
+ alternates={alternates}
188
231
  />
189
232
 
190
233
  {
@@ -357,6 +400,8 @@ const footerWidgets = await renderWidgetSlot(
357
400
  function fix(a) {
358
401
  var href = a.getAttribute("href");
359
402
  if (!href || href.charAt(0) !== "/" || href.charAt(1) === "/") return;
403
+ // Global asset namespaces are root-served under every tenant.
404
+ if (href.indexOf("/tenants/") === 0 || href.indexOf("/shared/") === 0) return;
360
405
  if (href === base || href.indexOf(base + "/") === 0) return;
361
406
  a.setAttribute("href", base + href);
362
407
  }