@tokenoftrust/storefront-runner 1.3.4-rc.3 → 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.
- package/apps/storefront/astro.config.mjs +15 -0
- package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
- package/apps/storefront/drizzle.config.apps.ts +13 -0
- package/apps/storefront/env.d.ts +10 -0
- package/apps/storefront/migrations/README.md +13 -7
- package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
- package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
- package/apps/storefront/package.json +13 -1
- package/apps/storefront/perf/README.md +64 -0
- package/apps/storefront/perf/assert-budgets.ts +159 -0
- package/apps/storefront/playwright.config.ts +23 -0
- package/apps/storefront/public/js/dashboard-apps.js +173 -0
- package/apps/storefront/public/shared/commerce-marketing.css +221 -0
- package/apps/storefront/src/components/CollectionCard.astro +1 -0
- package/apps/storefront/src/components/ProductCard.astro +1 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
- package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
- package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
- package/apps/storefront/src/components/content/Callout.astro +75 -0
- package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
- package/apps/storefront/src/components/content/ProseSections.astro +121 -0
- package/apps/storefront/src/components/content/ProseToc.astro +34 -0
- package/apps/storefront/src/components/content/RichText.astro +44 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
- package/apps/storefront/src/components/home/Hero.astro +14 -0
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
- package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
- package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
- package/apps/storefront/src/config/storeName.ts +34 -0
- package/apps/storefront/src/layouts/Layout.astro +97 -23
- package/apps/storefront/src/lib/analytics/budgets.json +69 -0
- package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
- package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
- package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
- package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
- package/apps/storefront/src/lib/apps/adminService.ts +106 -0
- package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
- package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
- package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
- package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
- package/apps/storefront/src/lib/apps/credentials.ts +153 -0
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
- package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
- package/apps/storefront/src/lib/apps/registryService.ts +579 -0
- package/apps/storefront/src/lib/apps/scopes.ts +79 -0
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
- package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
- package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
- package/apps/storefront/src/lib/auth/route.ts +16 -1
- package/apps/storefront/src/lib/auth/session.ts +8 -0
- package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
- package/apps/storefront/src/lib/blog/provider.ts +39 -0
- package/apps/storefront/src/lib/blog/types.ts +26 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
- package/apps/storefront/src/lib/chrome/model.ts +11 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
- package/apps/storefront/src/lib/content/callout.ts +78 -0
- package/apps/storefront/src/lib/content/index.ts +27 -0
- package/apps/storefront/src/lib/content/needsReview.ts +58 -0
- package/apps/storefront/src/lib/content/prose.ts +186 -0
- package/apps/storefront/src/lib/content/richtext.ts +76 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
- package/apps/storefront/src/lib/content-edit/client.ts +70 -14
- package/apps/storefront/src/lib/d1/catalog.ts +12 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
- package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
- package/apps/storefront/src/lib/dev/obstacleStore.ts +214 -0
- package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
- package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
- package/apps/storefront/src/lib/env.ts +12 -0
- package/apps/storefront/src/lib/jsonld.ts +12 -17
- package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
- package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
- package/apps/storefront/src/lib/publish/domainState.ts +351 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
- package/apps/storefront/src/lib/rawChrome.ts +34 -3
- package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
- package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
- package/apps/storefront/src/lib/subscription/model.ts +114 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
- package/apps/storefront/src/lib/tot/query.ts +32 -0
- package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
- package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
- package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
- package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
- package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
- package/apps/storefront/src/lib/webhooks/events.ts +98 -0
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
- package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
- package/apps/storefront/src/middleware/index.ts +28 -13
- package/apps/storefront/src/pages/404.astro +21 -9
- package/apps/storefront/src/pages/[...slug].astro +36 -2
- package/apps/storefront/src/pages/admin.astro +29 -3
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
- package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
- package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
- package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
- package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
- package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
- package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
- package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
- package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
- package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
- package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
- package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
- package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
- package/apps/storefront/src/pages/auth/login.astro +197 -41
- package/apps/storefront/src/pages/auth/magic.astro +75 -43
- package/apps/storefront/src/pages/blog/[slug].astro +107 -0
- package/apps/storefront/src/pages/blog/index.astro +98 -0
- package/apps/storefront/src/pages/capabilities.astro +8 -0
- package/apps/storefront/src/pages/cockpit.astro +496 -74
- package/apps/storefront/src/pages/collections/[handle].astro +8 -0
- package/apps/storefront/src/pages/collections/index.astro +10 -2
- package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
- package/apps/storefront/src/pages/index.astro +57 -0
- package/apps/storefront/src/pages/llms.txt.ts +31 -10
- package/apps/storefront/src/pages/products/[handle].astro +100 -9
- package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
- package/apps/storefront/src/pages/style-guide/index.astro +10 -0
- package/apps/storefront/src/styles/fonts.css +54 -0
- package/apps/storefront/src/styles/global.css +22 -2
- package/apps/storefront/src/themes/schema.ts +3 -25
- package/apps/storefront/tsconfig.json +1 -1
- package/apps/storefront/vitest.config.ts +4 -1
- package/package.json +1 -1
- package/packages/public-runtime/src/candidate-index.ts +311 -0
- package/packages/public-runtime/src/checkout.ts +94 -2
- package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
- package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
- package/packages/public-runtime/src/compliance/index.ts +22 -0
- package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
- package/packages/public-runtime/src/compliance/profile.ts +198 -0
- package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
- package/packages/public-runtime/src/compliance/verification.ts +81 -0
- package/packages/public-runtime/src/csp.ts +27 -3
- package/packages/public-runtime/src/customization-reconcile.ts +35 -0
- package/packages/public-runtime/src/customization-runtime.ts +8 -0
- package/packages/public-runtime/src/customization-versioning.ts +17 -0
- package/packages/public-runtime/src/extension-contract.ts +2 -1
- package/packages/public-runtime/src/hash.ts +25 -0
- package/packages/public-runtime/src/index.ts +6 -0
- package/packages/public-runtime/src/membership.ts +353 -0
- package/packages/public-runtime/src/product.ts +24 -2
- package/packages/public-runtime/src/review-trust-proof.ts +194 -0
- package/packages/public-runtime/src/tenant-assets.ts +40 -5
- package/packages/public-runtime/src/tenant.ts +236 -0
- package/packages/public-runtime/src/widget-postmessage.ts +205 -0
- package/scripts/dev/publish.mjs +158 -0
- package/scripts/dev/transient-files.mjs +2 -1
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/pages/storefront.css +23 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <AppWidgetFrame> — renders one private-app widget placement (PrivateApps
|
|
4
|
+
* epic, D5 Chunk D) as a sandboxed iframe. Its `sandbox`/`loading`/
|
|
5
|
+
* `referrerpolicy` come straight from `widgetFrameAttrs()`
|
|
6
|
+
* (`allow-scripts`, no `allow-same-origin` — see `frameProps.ts`), the SAME
|
|
7
|
+
* primitive `WidgetFrame.astro` uses for tenant `scripts.json` widgets. This
|
|
8
|
+
* component composes the `src` (endpoint + launch-token query param) and
|
|
9
|
+
* stamps `data-tot-widget-origin`/`data-tot-install-id` directly on the
|
|
10
|
+
* `<iframe>` for the parent-side `mountWidgetPostMessageBridge()`
|
|
11
|
+
* (`@tot/public-runtime`) to scan for.
|
|
12
|
+
*/
|
|
13
|
+
import { buildAppWidgetFrameAttrs, type AppWidgetFrameProps } from "@/lib/apps/widgets/frameProps.js";
|
|
14
|
+
|
|
15
|
+
type Props = AppWidgetFrameProps;
|
|
16
|
+
|
|
17
|
+
const attrs = buildAppWidgetFrameAttrs(Astro.props);
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
<iframe
|
|
21
|
+
src={attrs.src}
|
|
22
|
+
title={attrs.title}
|
|
23
|
+
sandbox={attrs.sandbox}
|
|
24
|
+
loading={attrs.loading}
|
|
25
|
+
referrerpolicy={attrs.referrerpolicy}
|
|
26
|
+
data-tot-widget-origin={attrs.dataWidgetOrigin}
|
|
27
|
+
data-tot-install-id={attrs.dataInstallId}
|
|
28
|
+
class="tot-app-widget-frame"
|
|
29
|
+
style={`border:0;width:100%;height:${attrs.height};`}
|
|
30
|
+
></iframe>
|
|
@@ -78,7 +78,7 @@ const groups = isMega
|
|
|
78
78
|
<a
|
|
79
79
|
href={hrefOf(child)}
|
|
80
80
|
class="flex items-center gap-1.5 rounded-sm px-2 py-1.5 text-sm text-muted transition-colors hover:bg-bg hover:text-primary"
|
|
81
|
-
{...(child.external ? { rel: "noopener noreferrer" } : {})}
|
|
81
|
+
{...(child.external ? { rel: "noopener noreferrer" } : { "data-astro-prefetch": true })}
|
|
82
82
|
>
|
|
83
83
|
{child.label}
|
|
84
84
|
{child.badge && (
|
|
@@ -99,6 +99,7 @@ const groups = isMega
|
|
|
99
99
|
<li>
|
|
100
100
|
<a
|
|
101
101
|
href={itemHref}
|
|
102
|
+
data-astro-prefetch
|
|
102
103
|
class:list={[
|
|
103
104
|
"flex items-center gap-1.5 border-b border-border py-2.5 text-sm font-medium text-text transition-colors last:border-b-0 hover:text-accent",
|
|
104
105
|
item.flag && "text-accent",
|
|
@@ -117,6 +118,7 @@ const groups = isMega
|
|
|
117
118
|
<li class="group relative">
|
|
118
119
|
<a
|
|
119
120
|
href={itemHref}
|
|
121
|
+
data-astro-prefetch
|
|
120
122
|
class:list={[
|
|
121
123
|
"inline-flex items-center gap-1 py-2 text-sm transition-colors hover:text-accent group-focus-within:text-accent",
|
|
122
124
|
item.flag && "font-semibold text-accent",
|
|
@@ -148,7 +150,7 @@ const groups = isMega
|
|
|
148
150
|
<a
|
|
149
151
|
href={hrefOf(child)}
|
|
150
152
|
class="flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm text-muted transition-colors hover:bg-bg hover:text-primary"
|
|
151
|
-
{...(child.external ? { rel: "noopener noreferrer" } : {})}
|
|
153
|
+
{...(child.external ? { rel: "noopener noreferrer" } : { "data-astro-prefetch": true })}
|
|
152
154
|
>
|
|
153
155
|
{child.label}
|
|
154
156
|
{child.badge && (
|
|
@@ -170,7 +172,7 @@ const groups = isMega
|
|
|
170
172
|
<a
|
|
171
173
|
href={hrefOf(child)}
|
|
172
174
|
class="block rounded-sm px-3 py-1.5 text-sm text-muted transition-colors hover:bg-bg hover:text-primary"
|
|
173
|
-
{...(child.external ? { rel: "noopener noreferrer" } : {})}
|
|
175
|
+
{...(child.external ? { rel: "noopener noreferrer" } : { "data-astro-prefetch": true })}
|
|
174
176
|
>
|
|
175
177
|
{child.label}
|
|
176
178
|
</a>
|
|
@@ -184,6 +186,7 @@ const groups = isMega
|
|
|
184
186
|
<li>
|
|
185
187
|
<a
|
|
186
188
|
href={itemHref}
|
|
189
|
+
data-astro-prefetch
|
|
187
190
|
class:list={[
|
|
188
191
|
"inline-flex items-center gap-1 py-2 text-sm transition-colors hover:text-accent",
|
|
189
192
|
item.flag && "font-semibold text-accent",
|
|
@@ -50,11 +50,21 @@ const showLogo = !!brand?.logo && /^(https?:|\/)/.test(brand.logo.src);
|
|
|
50
50
|
<div>
|
|
51
51
|
{
|
|
52
52
|
showLogo ? (
|
|
53
|
+
/*
|
|
54
|
+
Brand logo: a bare merchant URL shown at a fixed height via
|
|
55
|
+
object-contain. It can't go through <Img> — that forces an
|
|
56
|
+
aspect-ratio crop box + a placeholder background, which would alter
|
|
57
|
+
a transparent logo's appearance. Keep raw, but set explicit
|
|
58
|
+
width/height (reserve space → CLS-free) and, since the footer sits
|
|
59
|
+
below the fold, lazy/async loading hints to defer the fetch.
|
|
60
|
+
*/
|
|
53
61
|
<img
|
|
54
62
|
src={brand!.logo!.src}
|
|
55
63
|
alt={brand!.logo!.alt}
|
|
56
64
|
width={brand!.logo!.width ?? 160}
|
|
57
65
|
height={brand!.logo!.height ?? 48}
|
|
66
|
+
loading="lazy"
|
|
67
|
+
decoding="async"
|
|
58
68
|
class="h-9 w-auto max-w-[180px] object-contain"
|
|
59
69
|
/>
|
|
60
70
|
) : (
|
|
@@ -106,11 +106,21 @@ const mobileNav = nav.map((item) => ({
|
|
|
106
106
|
>
|
|
107
107
|
{
|
|
108
108
|
showLogo ? (
|
|
109
|
+
/*
|
|
110
|
+
Brand logo: a bare merchant URL shown at a fixed header height via
|
|
111
|
+
object-contain. It can't go through <Img> — that forces an
|
|
112
|
+
aspect-ratio crop box + a placeholder background, which would alter
|
|
113
|
+
a transparent logo's appearance. Keep raw, but set explicit
|
|
114
|
+
width/height (reserve space → CLS-free) and, since it sits
|
|
115
|
+
above the fold, eager/async loading hints.
|
|
116
|
+
*/
|
|
109
117
|
<img
|
|
110
118
|
src={logo!.src}
|
|
111
119
|
alt={logo!.alt}
|
|
112
120
|
width={logo!.width ?? 160}
|
|
113
121
|
height={logo!.height ?? 48}
|
|
122
|
+
loading="eager"
|
|
123
|
+
decoding="async"
|
|
114
124
|
class="h-9 w-auto max-w-[180px] object-contain"
|
|
115
125
|
/>
|
|
116
126
|
) : (
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* <RatingStars> — atom. A 0–5 star rating with fractional fill (a single clipped
|
|
4
4
|
* overlay, no half-star glyph juggling) and an accessible label. Tenant-agnostic:
|
|
5
|
-
* filled stars use `--color-
|
|
5
|
+
* filled stars use `--color-star` (the trust/review star token, defaults to the
|
|
6
|
+
* accent spark), the empty track uses `--color-border`.
|
|
6
7
|
*
|
|
7
8
|
* Presentation only — it renders whatever aggregate you pass. No review data is
|
|
8
9
|
* invented here (claims-ledger discipline: source ratings before shipping them).
|
|
@@ -42,7 +43,7 @@ const label =
|
|
|
42
43
|
<span class:list={["relative inline-block leading-none tracking-[0.08em]", dim]} aria-hidden="true">
|
|
43
44
|
<span class="text-[var(--color-border)]">{stars}</span>
|
|
44
45
|
<span
|
|
45
|
-
class="absolute inset-0 overflow-hidden whitespace-nowrap text-[var(--color-
|
|
46
|
+
class="absolute inset-0 overflow-hidden whitespace-nowrap text-[var(--color-star)]"
|
|
46
47
|
style={`width:${pct}%`}
|
|
47
48
|
>
|
|
48
49
|
{stars}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <Callout> — a tone-styled aside (see lib/content/callout.ts), the structured
|
|
4
|
+
* form of the f05 sheet's `.disclaimer` (info) and `.warn-band` (warning/legal)
|
|
5
|
+
* page patterns. A tenant supplies a {@link CalloutModel}; this paints it from
|
|
6
|
+
* f06 tokens using the SAME token formulas the raw sheet uses — the info tone
|
|
7
|
+
* mirrors `.disclaimer` (info tint over surface), warning/legal mirror
|
|
8
|
+
* `.warn-band` (warning tint + a warning left border) — so the raw-HTML path and
|
|
9
|
+
* the structured widget look identical.
|
|
10
|
+
*
|
|
11
|
+
* A11y is baked in via calloutPresentation(): role="note" (a static aside, not a
|
|
12
|
+
* live region) + a tone aria-label, and the icon is aria-hidden. The body is a
|
|
13
|
+
* <RichText>, so no HTML is authored. Arbitrary token classes are STATIC
|
|
14
|
+
* per-tone literals so the Tailwind JIT emits them (same discipline as
|
|
15
|
+
* IllustrativeLabel / ComplianceNotice).
|
|
16
|
+
*/
|
|
17
|
+
import type { CalloutModel, CalloutTone } from "@/lib/content/callout";
|
|
18
|
+
import { calloutPresentation } from "@/lib/content/callout";
|
|
19
|
+
import RichText from "@/components/content/RichText.astro";
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
callout: CalloutModel;
|
|
23
|
+
}
|
|
24
|
+
const { callout } = Astro.props;
|
|
25
|
+
const p = calloutPresentation(callout.tone);
|
|
26
|
+
|
|
27
|
+
// Static per-tone token classes (JIT-safe). Mirrors commerce-marketing.css:
|
|
28
|
+
// info → .disclaimer tint; warning/legal → .warn-band tint + left border.
|
|
29
|
+
const TONE: Record<
|
|
30
|
+
CalloutTone,
|
|
31
|
+
{ box: string; icon: string }
|
|
32
|
+
> = {
|
|
33
|
+
info: {
|
|
34
|
+
box: "bg-[color-mix(in_srgb,var(--color-info)_10%,var(--color-surface))] border-[color-mix(in_srgb,var(--color-info)_32%,var(--color-border))]",
|
|
35
|
+
icon: "text-[var(--color-info)]",
|
|
36
|
+
},
|
|
37
|
+
warning: {
|
|
38
|
+
box: "bg-[color-mix(in_srgb,var(--color-warning)_12%,var(--color-surface))] border-[color-mix(in_srgb,var(--color-warning)_40%,var(--color-border))] border-l-4 border-l-[var(--color-warning)]",
|
|
39
|
+
icon: "text-[var(--color-warning)]",
|
|
40
|
+
},
|
|
41
|
+
legal: {
|
|
42
|
+
box: "bg-[color-mix(in_srgb,var(--color-warning)_12%,var(--color-surface))] border-[color-mix(in_srgb,var(--color-warning)_40%,var(--color-border))] border-l-4 border-l-[var(--color-warning)]",
|
|
43
|
+
icon: "text-[var(--color-warning)]",
|
|
44
|
+
},
|
|
45
|
+
success: {
|
|
46
|
+
box: "bg-[color-mix(in_srgb,var(--color-success)_10%,var(--color-surface))] border-[color-mix(in_srgb,var(--color-success)_32%,var(--color-border))]",
|
|
47
|
+
icon: "text-[var(--color-success)]",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
const tone = TONE[callout.tone] ?? TONE.info;
|
|
51
|
+
---
|
|
52
|
+
<div
|
|
53
|
+
class={`my-5 flex max-w-[76ch] items-start gap-2.5 rounded-[var(--radius-md)] border p-3.5 text-sm text-[var(--color-text)] ${tone.box}`}
|
|
54
|
+
role={p.role}
|
|
55
|
+
aria-label={p.label}
|
|
56
|
+
data-widget="callout"
|
|
57
|
+
data-tone={callout.tone}
|
|
58
|
+
>
|
|
59
|
+
<svg
|
|
60
|
+
class={`mt-0.5 h-[18px] w-[18px] flex-none ${tone.icon}`}
|
|
61
|
+
viewBox="0 0 24 24"
|
|
62
|
+
fill="none"
|
|
63
|
+
stroke="currentColor"
|
|
64
|
+
stroke-width="2"
|
|
65
|
+
stroke-linecap="round"
|
|
66
|
+
stroke-linejoin="round"
|
|
67
|
+
aria-hidden="true"
|
|
68
|
+
>
|
|
69
|
+
<path d={p.iconPath} />
|
|
70
|
+
</svg>
|
|
71
|
+
<div>
|
|
72
|
+
{callout.title && <p class="font-semibold">{callout.title}</p>}
|
|
73
|
+
<p class={callout.title ? "mt-1" : ""}><RichText value={callout.body} /></p>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <NeedsReviewCallout> — the structured form of the f05 sheet's `.needs-review`
|
|
4
|
+
* flag + `.nr` badge (see lib/content/needsReview.ts). Marks a placeholder that
|
|
5
|
+
* needs human sign-off before go-live — the honest counterpart to f09's
|
|
6
|
+
* real-or-absent rule (say "needs confirming", never fabricate).
|
|
7
|
+
*
|
|
8
|
+
* This is a REVIEWER affordance: it is shown in preview/dev only. Live shopper
|
|
9
|
+
* pages omit it — <ProseSections includeNeedsReview={false}> drops the section,
|
|
10
|
+
* and the deploy path fails on any `blocker` via auditNeedsReview(). Styled from
|
|
11
|
+
* f06 tokens, mirroring the raw sheet (surface ground + a warning left border +
|
|
12
|
+
* the uppercase `.nr` badge). role="note" + data hooks for audit/e2e.
|
|
13
|
+
*/
|
|
14
|
+
import type { NeedsReview } from "@/lib/content/needsReview";
|
|
15
|
+
import { NEEDS_REVIEW_TITLE } from "@/lib/content/needsReview";
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
flag: NeedsReview;
|
|
19
|
+
}
|
|
20
|
+
const { flag } = Astro.props;
|
|
21
|
+
const severity = flag.severity ?? "note";
|
|
22
|
+
---
|
|
23
|
+
<div
|
|
24
|
+
class="my-5 flex max-w-[76ch] items-start gap-2.5 rounded-[var(--radius-md)] border border-[var(--color-border)] border-l-4 border-l-[var(--color-warning)] bg-[var(--color-surface)] p-3.5 text-sm text-[var(--color-text)]"
|
|
25
|
+
role="note"
|
|
26
|
+
aria-label={NEEDS_REVIEW_TITLE}
|
|
27
|
+
data-widget="needs-review"
|
|
28
|
+
data-needs-review
|
|
29
|
+
data-severity={severity}
|
|
30
|
+
>
|
|
31
|
+
<svg
|
|
32
|
+
class="mt-0.5 h-[18px] w-[18px] flex-none text-[var(--color-warning)]"
|
|
33
|
+
viewBox="0 0 24 24"
|
|
34
|
+
fill="none"
|
|
35
|
+
stroke="currentColor"
|
|
36
|
+
stroke-width="2"
|
|
37
|
+
stroke-linecap="round"
|
|
38
|
+
stroke-linejoin="round"
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
>
|
|
41
|
+
<path d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
|
|
42
|
+
</svg>
|
|
43
|
+
<div>
|
|
44
|
+
<p class="font-semibold">
|
|
45
|
+
{NEEDS_REVIEW_TITLE}
|
|
46
|
+
<span class="ml-1.5 inline-block rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-2)] px-1.5 py-px align-middle text-[0.6rem] font-extrabold uppercase tracking-[0.06em] text-[var(--color-warning)]">
|
|
47
|
+
{severity === "blocker" ? "Blocker" : "Review"}
|
|
48
|
+
</span>
|
|
49
|
+
</p>
|
|
50
|
+
<p class="mt-1">{flag.reason}</p>
|
|
51
|
+
{(flag.owner || flag.href) && (
|
|
52
|
+
<p class="mt-1 text-xs text-[var(--color-muted)]">
|
|
53
|
+
{flag.owner && <span>Owner: {flag.owner}</span>}
|
|
54
|
+
{flag.owner && flag.href && <span> · </span>}
|
|
55
|
+
{flag.href && (
|
|
56
|
+
<a
|
|
57
|
+
href={flag.href}
|
|
58
|
+
class="text-[var(--color-accent-ink)] underline underline-offset-[3px]"
|
|
59
|
+
>
|
|
60
|
+
Context
|
|
61
|
+
</a>
|
|
62
|
+
)}
|
|
63
|
+
</p>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <ProseSections> — renders a structured {@link ProseSection}[] (policy / about /
|
|
4
|
+
* compliance body) as a real document: <h2>/<h3> with anchor ids, <p>, <ul>/<ol>,
|
|
5
|
+
* a semantic <dl> glossary, <address> contact block, tone callouts, and (in
|
|
6
|
+
* preview only) needs-review flags. This is the F4 promotion of the old
|
|
7
|
+
* `set:html(body_html)` policy blob — a tenant drives the whole page from JSON,
|
|
8
|
+
* copying no HTML/CSS. Mirrors the f05 sheet's `.prose` / `.contact-card`
|
|
9
|
+
* patterns using f06 tokens; accessibility lives in the components, not content.
|
|
10
|
+
*
|
|
11
|
+
* `includeNeedsReview` gates the reviewer-only needs-review flags: true in
|
|
12
|
+
* preview/dev (the reviewer sees what still needs sign-off), false on live
|
|
13
|
+
* shopper pages (the flag is scaffolding, never shown to shoppers).
|
|
14
|
+
*/
|
|
15
|
+
import type { ProseSection } from "@/lib/content/prose";
|
|
16
|
+
import { normalizeProse, withHeadingIds } from "@/lib/content/prose";
|
|
17
|
+
import RichText from "@/components/content/RichText.astro";
|
|
18
|
+
import Callout from "@/components/content/Callout.astro";
|
|
19
|
+
import NeedsReviewCallout from "@/components/content/NeedsReviewCallout.astro";
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
sections: ProseSection[];
|
|
23
|
+
/** Show reviewer-only needs-review flags (preview/dev). Default false (live). */
|
|
24
|
+
includeNeedsReview?: boolean;
|
|
25
|
+
}
|
|
26
|
+
const { sections, includeNeedsReview = false } = Astro.props;
|
|
27
|
+
const rendered = withHeadingIds(normalizeProse(sections));
|
|
28
|
+
---
|
|
29
|
+
<div class="prose-sections max-w-[68ch] text-[var(--color-text)]" data-widget="prose-sections">
|
|
30
|
+
{
|
|
31
|
+
rendered.map((s) => {
|
|
32
|
+
switch (s.kind) {
|
|
33
|
+
case "heading":
|
|
34
|
+
return (s.level ?? 2) === 3 ? (
|
|
35
|
+
<h3 id={s.id} class="mt-6 scroll-mt-24 font-display text-xl font-semibold">
|
|
36
|
+
{s.text}
|
|
37
|
+
</h3>
|
|
38
|
+
) : (
|
|
39
|
+
<h2 id={s.id} class="mt-8 scroll-mt-24 font-display text-2xl font-semibold">
|
|
40
|
+
{s.text}
|
|
41
|
+
</h2>
|
|
42
|
+
);
|
|
43
|
+
case "paragraph":
|
|
44
|
+
return (
|
|
45
|
+
<p class="mt-3 leading-relaxed">
|
|
46
|
+
<RichText value={s.text} />
|
|
47
|
+
</p>
|
|
48
|
+
);
|
|
49
|
+
case "list":
|
|
50
|
+
return s.ordered ? (
|
|
51
|
+
<ol class="mt-3 list-decimal space-y-2 pl-5">
|
|
52
|
+
{s.items.map((item) => (
|
|
53
|
+
<li><RichText value={item} /></li>
|
|
54
|
+
))}
|
|
55
|
+
</ol>
|
|
56
|
+
) : (
|
|
57
|
+
<ul class="mt-3 list-disc space-y-2 pl-5">
|
|
58
|
+
{s.items.map((item) => (
|
|
59
|
+
<li><RichText value={item} /></li>
|
|
60
|
+
))}
|
|
61
|
+
</ul>
|
|
62
|
+
);
|
|
63
|
+
case "definitions":
|
|
64
|
+
return (
|
|
65
|
+
<dl class="mt-4 space-y-4">
|
|
66
|
+
{s.items.map((d) => (
|
|
67
|
+
<div>
|
|
68
|
+
<dt class="font-semibold text-[var(--color-text)]">{d.term}</dt>
|
|
69
|
+
<dd class="mt-1 text-[var(--color-muted)]">
|
|
70
|
+
<RichText value={d.description} />
|
|
71
|
+
</dd>
|
|
72
|
+
</div>
|
|
73
|
+
))}
|
|
74
|
+
</dl>
|
|
75
|
+
);
|
|
76
|
+
case "callout":
|
|
77
|
+
return <Callout callout={{ tone: s.tone, title: s.title, body: s.body }} />;
|
|
78
|
+
case "contact":
|
|
79
|
+
return (
|
|
80
|
+
<address class="mt-4 not-italic">
|
|
81
|
+
{s.heading && (
|
|
82
|
+
<p class="font-display text-lg font-semibold">{s.heading}</p>
|
|
83
|
+
)}
|
|
84
|
+
{s.email && (
|
|
85
|
+
<a
|
|
86
|
+
href={`mailto:${s.email}`}
|
|
87
|
+
class="mt-2 inline-flex min-h-[var(--tap-target)] items-center text-[var(--color-accent-ink)] underline underline-offset-[3px]"
|
|
88
|
+
>
|
|
89
|
+
{s.email}
|
|
90
|
+
</a>
|
|
91
|
+
)}
|
|
92
|
+
{s.phone && (
|
|
93
|
+
<a
|
|
94
|
+
href={`tel:${s.phone.replace(/[^+\d]/g, "")}`}
|
|
95
|
+
class="mt-1 flex min-h-[var(--tap-target)] items-center text-[var(--color-accent-ink)] underline underline-offset-[3px]"
|
|
96
|
+
>
|
|
97
|
+
{s.phone}
|
|
98
|
+
</a>
|
|
99
|
+
)}
|
|
100
|
+
{s.address && s.address.length > 0 && (
|
|
101
|
+
<p class="mt-2 text-[var(--color-muted)]">
|
|
102
|
+
{s.address.map((line, i) => (
|
|
103
|
+
<>
|
|
104
|
+
{i > 0 && <br />}
|
|
105
|
+
{line}
|
|
106
|
+
</>
|
|
107
|
+
))}
|
|
108
|
+
</p>
|
|
109
|
+
)}
|
|
110
|
+
</address>
|
|
111
|
+
);
|
|
112
|
+
case "needs_review":
|
|
113
|
+
return includeNeedsReview ? (
|
|
114
|
+
<NeedsReviewCallout flag={{ reason: s.reason, severity: s.severity, owner: s.owner, href: s.href }} />
|
|
115
|
+
) : null;
|
|
116
|
+
default:
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
</div>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <ProseToc> — the table-of-contents chip row for a long policy page (the f05
|
|
4
|
+
* sheet's `.toc` pattern), driven by {@link tableOfContents} over the same
|
|
5
|
+
* structured sections <ProseSections> renders. One chip per level-2 heading,
|
|
6
|
+
* anchor-linked to the heading id. Rendered as a real <nav> landmark with an
|
|
7
|
+
* accessible label. Renders nothing below the `min` heading count (a short page
|
|
8
|
+
* needs no TOC). Styled from f06 tokens.
|
|
9
|
+
*/
|
|
10
|
+
import type { ProseSection } from "@/lib/content/prose";
|
|
11
|
+
import { normalizeProse, withHeadingIds, tableOfContents } from "@/lib/content/prose";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
sections: ProseSection[];
|
|
15
|
+
/** Only render when there are at least this many entries. Default 3. */
|
|
16
|
+
min?: number;
|
|
17
|
+
}
|
|
18
|
+
const { sections, min = 3 } = Astro.props;
|
|
19
|
+
const entries = tableOfContents(withHeadingIds(normalizeProse(sections)));
|
|
20
|
+
---
|
|
21
|
+
{
|
|
22
|
+
entries.length >= min && (
|
|
23
|
+
<nav class="mt-5 flex flex-wrap gap-2" aria-label="On this page" data-widget="prose-toc">
|
|
24
|
+
{entries.map((e) => (
|
|
25
|
+
<a
|
|
26
|
+
href={`#${e.id}`}
|
|
27
|
+
class="inline-flex min-h-[var(--tap-target)] items-center rounded-[var(--radius-full)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-[0.82rem] font-semibold text-[var(--color-accent-ink)] no-underline hover:border-[var(--color-accent-ink)]"
|
|
28
|
+
>
|
|
29
|
+
{e.text}
|
|
30
|
+
</a>
|
|
31
|
+
))}
|
|
32
|
+
</nav>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <RichText> — renders an inline {@link RichText} (see lib/content/richtext.ts)
|
|
4
|
+
* as REAL DOM: text, <strong>/<em>, and safe <a>. Never uses `set:html`, so a
|
|
5
|
+
* paragraph/list-item/callout body carries typed runs instead of an HTML blob —
|
|
6
|
+
* closing the injection seam f09 + CSP push us to close.
|
|
7
|
+
*
|
|
8
|
+
* Accessibility lives here, not in tenant content: an external link gets
|
|
9
|
+
* rel="noopener noreferrer", target="_blank", and a visually-hidden
|
|
10
|
+
* "(opens in new tab)" so its behavior is announced. Link color/underline come
|
|
11
|
+
* from the f06 token contract via the inherited `.rte a` / token styles.
|
|
12
|
+
*/
|
|
13
|
+
import type { RichText } from "@/lib/content/richtext";
|
|
14
|
+
import { toRuns } from "@/lib/content/richtext";
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
value: RichText;
|
|
18
|
+
}
|
|
19
|
+
const runs = toRuns(Astro.props.value);
|
|
20
|
+
---
|
|
21
|
+
{
|
|
22
|
+
runs.map((run) =>
|
|
23
|
+
run.kind === "link" ? (
|
|
24
|
+
<a
|
|
25
|
+
href={run.href}
|
|
26
|
+
class="text-[var(--color-accent-ink)] underline underline-offset-[3px]"
|
|
27
|
+
{...(run.external
|
|
28
|
+
? { rel: "noopener noreferrer", target: "_blank" }
|
|
29
|
+
: {})}
|
|
30
|
+
>
|
|
31
|
+
{run.text}
|
|
32
|
+
{run.external && <span class="sr-only"> (opens in new tab)</span>}
|
|
33
|
+
</a>
|
|
34
|
+
) : run.strong && run.em ? (
|
|
35
|
+
<strong><em>{run.text}</em></strong>
|
|
36
|
+
) : run.strong ? (
|
|
37
|
+
<strong>{run.text}</strong>
|
|
38
|
+
) : run.em ? (
|
|
39
|
+
<em>{run.text}</em>
|
|
40
|
+
) : (
|
|
41
|
+
<Fragment>{run.text}</Fragment>
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <TrustStrip> — the quiet row of proof tokens a store repeats across pages
|
|
4
|
+
* ("Ships from USA", "Lab-tested", a rating). REAL-OR-ABSENT by construction
|
|
5
|
+
* (f09): sanitizeTrustStrip() drops any claim that is scraped review-widget
|
|
6
|
+
* markup and any rating that isn't a real positive aggregate, reusing f09's
|
|
7
|
+
* detector. If nothing real is left the strip renders NOTHING — never a
|
|
8
|
+
* fabricated badge. Plain text + one rating glyph; styled from f06 tokens.
|
|
9
|
+
* data-widget="trust-strip".
|
|
10
|
+
*/
|
|
11
|
+
import type { TrustStripItem } from "@/lib/content/trustStrip";
|
|
12
|
+
import { sanitizeTrustStrip, ratingAriaLabel } from "@/lib/content/trustStrip";
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
items: TrustStripItem[];
|
|
16
|
+
}
|
|
17
|
+
const items = sanitizeTrustStrip(Astro.props.items);
|
|
18
|
+
---
|
|
19
|
+
{
|
|
20
|
+
items.length > 0 && (
|
|
21
|
+
<div
|
|
22
|
+
class="border-y border-[var(--color-border)] bg-[var(--color-surface)]"
|
|
23
|
+
data-widget="trust-strip"
|
|
24
|
+
>
|
|
25
|
+
<ul class="container-prose flex flex-wrap items-center justify-center gap-x-6 gap-y-2 py-4 text-center font-mono text-[0.72rem] uppercase tracking-[0.1em] text-[var(--color-muted)]">
|
|
26
|
+
{items.map((item) =>
|
|
27
|
+
item.kind === "rating" ? (
|
|
28
|
+
<li
|
|
29
|
+
class="flex items-center gap-1.5 text-[var(--color-text)]"
|
|
30
|
+
data-trust="rating"
|
|
31
|
+
>
|
|
32
|
+
<span aria-hidden="true" class="text-[var(--color-warning)]">★</span>
|
|
33
|
+
<span aria-hidden="true">
|
|
34
|
+
{Math.round(item.average * 10) / 10}
|
|
35
|
+
{item.label ? ` ${item.label}` : ` (${item.count})`}
|
|
36
|
+
</span>
|
|
37
|
+
<span class="sr-only">{ratingAriaLabel(item)}</span>
|
|
38
|
+
</li>
|
|
39
|
+
) : (
|
|
40
|
+
<li data-trust="claim">{item.label}</li>
|
|
41
|
+
),
|
|
42
|
+
)}
|
|
43
|
+
</ul>
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -107,10 +107,24 @@ const hasImage = Boolean(heroImg || (prodImg && featureProduct));
|
|
|
107
107
|
light ? "ring-[var(--color-border)]" : "ring-white/10",
|
|
108
108
|
]}
|
|
109
109
|
>
|
|
110
|
+
{/*
|
|
111
|
+
heroImg is a bare merchant URL (HeroBlock.image string) — no
|
|
112
|
+
intrinsic dims and no `rehosted` flag, so it can't go through <Img>
|
|
113
|
+
(that would force a fixed aspect-ratio crop, changing appearance).
|
|
114
|
+
Instead give it the LCP hints the product branch's <Img priority>
|
|
115
|
+
gets — fetchpriority/decoding/eager — plus width/height so the
|
|
116
|
+
browser reserves space (CLS). The `auto` aspect-ratio semantics of
|
|
117
|
+
width/height attributes mean the final render still uses the
|
|
118
|
+
image's natural ratio; the dims below only size the pre-load box.
|
|
119
|
+
*/}
|
|
110
120
|
<img
|
|
111
121
|
src={heroImg}
|
|
112
122
|
alt={block.imageAlt ?? block.headline}
|
|
123
|
+
width={1600}
|
|
124
|
+
height={1000}
|
|
113
125
|
loading="eager"
|
|
126
|
+
fetchpriority="high"
|
|
127
|
+
decoding="sync"
|
|
114
128
|
class="h-full w-full object-cover"
|
|
115
129
|
/>
|
|
116
130
|
</div>
|