@tokenoftrust/storefront-runner 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +58 -0
- package/README.md +42 -0
- package/apps/storefront/astro.config.mjs +66 -0
- package/apps/storefront/dev-plugins/dev-loop-state.mjs +147 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +238 -0
- package/apps/storefront/env.d.ts +162 -0
- package/apps/storefront/migrations/0000_baseline.sql +54 -0
- package/apps/storefront/migrations/0001_products_fts.sql +9 -0
- package/apps/storefront/migrations/README.md +54 -0
- package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/_journal.json +20 -0
- package/apps/storefront/package.json +42 -0
- package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
- package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
- package/apps/storefront/public/brand/northwind-og.svg +13 -0
- package/apps/storefront/public/favicon.svg +5 -0
- package/apps/storefront/public/js/dashboard-team.js +147 -0
- package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
- package/apps/storefront/public/shared/commerce-chrome.css +369 -0
- package/apps/storefront/src/components/Badge.astro +34 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
- package/apps/storefront/src/components/CollectionCard.astro +56 -0
- package/apps/storefront/src/components/EmptyState.astro +55 -0
- package/apps/storefront/src/components/Img.astro +68 -0
- package/apps/storefront/src/components/ProductCard.astro +228 -0
- package/apps/storefront/src/components/Section.astro +39 -0
- package/apps/storefront/src/components/Seo.astro +70 -0
- package/apps/storefront/src/components/WidgetFrame.astro +34 -0
- package/apps/storefront/src/components/admin/AdminAIWorkflowsTab.astro +531 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +597 -0
- package/apps/storefront/src/components/admin/AdminFoxyCartSettingsPanel.astro +227 -0
- package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +465 -0
- package/apps/storefront/src/components/admin/AdminProductsTab.astro +901 -0
- package/apps/storefront/src/components/admin/AdminReportingTab.astro +435 -0
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +782 -0
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +1223 -0
- package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
- package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
- package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +201 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +201 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +234 -0
- package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
- package/apps/storefront/src/components/commerce/ArticleCard.astro +242 -0
- package/apps/storefront/src/components/commerce/BrandGrid.astro +175 -0
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
- package/apps/storefront/src/components/commerce/FoxySidecartStyles.astro +61 -0
- package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
- package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
- package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
- package/apps/storefront/src/components/commerce/ReviewList.astro +54 -0
- package/apps/storefront/src/components/commerce/ReviewSummary.astro +44 -0
- package/apps/storefront/src/components/commerce/TagFilterCloud.astro +149 -0
- package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +37 -0
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +99 -0
- package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +45 -0
- package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
- package/apps/storefront/src/components/compliance/PactActNotice.astro +35 -0
- package/apps/storefront/src/components/compliance/Prop65Warning.astro +80 -0
- package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +38 -0
- package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +35 -0
- package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +53 -0
- package/apps/storefront/src/components/home/Hero.astro +164 -0
- package/apps/storefront/src/components/islands/AgeGate.tsx +109 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
- package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
- package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
- package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
- package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
- package/apps/storefront/src/components/islands/VariantSelector.tsx +375 -0
- package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
- package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
- package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
- package/apps/storefront/src/components/marketing/Faq.astro +37 -0
- package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
- package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
- package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
- package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
- package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
- package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
- package/apps/storefront/src/components/marketing/Steps.astro +36 -0
- package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
- package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
- package/apps/storefront/src/components/membership/IllustrativeLabel.astro +56 -0
- package/apps/storefront/src/components/membership/TierCards.astro +164 -0
- package/apps/storefront/src/components/membership/TierComparisonTable.astro +148 -0
- package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
- package/apps/storefront/src/components/plp/FacetSidebar.astro +199 -0
- package/apps/storefront/src/components/plp/Pagination.astro +99 -0
- package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
- package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +95 -0
- package/apps/storefront/src/components/subscription/HowItWorksSteps.astro +67 -0
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +67 -0
- package/apps/storefront/src/components/subscription/SavingsExplainer.astro +101 -0
- package/apps/storefront/src/config/adminTargetTenant.ts +28 -0
- package/apps/storefront/src/config/devTenantSeed.ts +78 -0
- package/apps/storefront/src/config/devTenants.ts +16 -0
- package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
- package/apps/storefront/src/config/resolver.ts +102 -0
- package/apps/storefront/src/config/tenants.ts +87 -0
- package/apps/storefront/src/layouts/Layout.astro +368 -0
- package/apps/storefront/src/lib/admin/README.md +122 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +21 -0
- package/apps/storefront/src/lib/admin/ai/askEnvelope.ts +48 -0
- package/apps/storefront/src/lib/admin/ai/audit.ts +14 -0
- package/apps/storefront/src/lib/admin/ai/index.ts +6 -0
- package/apps/storefront/src/lib/admin/ai/operationalRuns.ts +155 -0
- package/apps/storefront/src/lib/admin/ai/recommendation.ts +20 -0
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +172 -0
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +65 -0
- package/apps/storefront/src/lib/admin/envelope.ts +10 -0
- package/apps/storefront/src/lib/admin/evidence/evidenceReference.ts +32 -0
- package/apps/storefront/src/lib/admin/memoizeByEnvelope.ts +27 -0
- package/apps/storefront/src/lib/admin/orderClient.ts +146 -0
- package/apps/storefront/src/lib/admin/seed.ts +54 -0
- package/apps/storefront/src/lib/admin/services/mock/aiWorkflows.ts +59 -0
- package/apps/storefront/src/lib/admin/services/mock/customers.ts +86 -0
- package/apps/storefront/src/lib/admin/services/mock/evidence.ts +99 -0
- package/apps/storefront/src/lib/admin/services/mock/foxyCartSettings.ts +78 -0
- package/apps/storefront/src/lib/admin/services/mock/fulfillment.ts +62 -0
- package/apps/storefront/src/lib/admin/services/mock/index.ts +12 -0
- package/apps/storefront/src/lib/admin/services/mock/orders.ts +184 -0
- package/apps/storefront/src/lib/admin/services/mock/products.ts +105 -0
- package/apps/storefront/src/lib/admin/services/mock/reporting.ts +51 -0
- package/apps/storefront/src/lib/admin/services/mock/settings.ts +70 -0
- package/apps/storefront/src/lib/admin/services/mock/tenants.ts +29 -0
- package/apps/storefront/src/lib/admin/services/orders.ts +300 -0
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +67 -0
- package/apps/storefront/src/lib/admin/viewmodels/customers.ts +56 -0
- package/apps/storefront/src/lib/admin/viewmodels/fulfillment.ts +49 -0
- package/apps/storefront/src/lib/admin/viewmodels/orders.ts +92 -0
- package/apps/storefront/src/lib/admin/viewmodels/products.ts +74 -0
- package/apps/storefront/src/lib/admin/viewmodels/reporting.ts +34 -0
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +64 -0
- package/apps/storefront/src/lib/adminTenantEnvelope.ts +90 -0
- package/apps/storefront/src/lib/analytics/index.ts +47 -0
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
- package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
- package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +119 -0
- package/apps/storefront/src/lib/auth/route.ts +146 -0
- package/apps/storefront/src/lib/auth/session.ts +203 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
- package/apps/storefront/src/lib/basePath.ts +41 -0
- package/apps/storefront/src/lib/cfImage.ts +55 -0
- package/apps/storefront/src/lib/chrome/model.ts +244 -0
- package/apps/storefront/src/lib/chrome/parseConfig.ts +34 -0
- package/apps/storefront/src/lib/colors.ts +97 -0
- package/apps/storefront/src/lib/commerce/foxyCartSettings.ts +253 -0
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +91 -0
- package/apps/storefront/src/lib/content-edit/client.ts +230 -0
- package/apps/storefront/src/lib/content-edit/index.ts +14 -0
- package/apps/storefront/src/lib/content-edit/route.ts +25 -0
- package/apps/storefront/src/lib/content-edit/types.ts +19 -0
- package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
- package/apps/storefront/src/lib/d1/catalog.ts +366 -0
- package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
- package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
- package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
- package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
- package/apps/storefront/src/lib/demoGate.ts +113 -0
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +78 -0
- package/apps/storefront/src/lib/dev/activityStore.ts +72 -0
- package/apps/storefront/src/lib/dev/ai-widget-client.js +308 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +14 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +155 -0
- package/apps/storefront/src/lib/dev/devDraft.ts +133 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +169 -0
- package/apps/storefront/src/lib/dev/livePreview.ts +77 -0
- package/apps/storefront/src/lib/dev/runtimeStore.ts +120 -0
- package/apps/storefront/src/lib/dev/storeStatus.ts +142 -0
- package/apps/storefront/src/lib/diag/redact.ts +20 -0
- package/apps/storefront/src/lib/edgeCache.ts +128 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
- package/apps/storefront/src/lib/env.ts +60 -0
- package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
- package/apps/storefront/src/lib/format.ts +38 -0
- package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
- package/apps/storefront/src/lib/imageAttrs.ts +55 -0
- package/apps/storefront/src/lib/jsonld.ts +166 -0
- package/apps/storefront/src/lib/maintenance.ts +71 -0
- package/apps/storefront/src/lib/membership/model.ts +97 -0
- package/apps/storefront/src/lib/memoryKv.ts +32 -0
- package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
- package/apps/storefront/src/lib/placeholder.ts +99 -0
- package/apps/storefront/src/lib/previewReload.ts +125 -0
- package/apps/storefront/src/lib/priceBuckets.ts +33 -0
- package/apps/storefront/src/lib/rawChrome.ts +505 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +136 -0
- package/apps/storefront/src/lib/search/index.ts +74 -0
- package/apps/storefront/src/lib/sections.ts +129 -0
- package/apps/storefront/src/lib/securityHeaders.ts +31 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +607 -0
- package/apps/storefront/src/lib/subscription/model.ts +68 -0
- package/apps/storefront/src/lib/tenantRouting.ts +91 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
- package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
- package/apps/storefront/src/lib/tot/query.ts +249 -0
- package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
- package/apps/storefront/src/lib/url.ts +142 -0
- package/apps/storefront/src/lib/wishlist.ts +63 -0
- package/apps/storefront/src/middleware/index.ts +455 -0
- package/apps/storefront/src/pages/404.astro +61 -0
- package/apps/storefront/src/pages/[...slug].astro +90 -0
- package/apps/storefront/src/pages/admin.astro +1675 -0
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +53 -0
- package/apps/storefront/src/pages/api/admin/orders.ts +71 -0
- package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
- package/apps/storefront/src/pages/api/auth/send.ts +85 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
- package/apps/storefront/src/pages/api/checkout-style.css.ts +43 -0
- package/apps/storefront/src/pages/api/cli/policy.ts +70 -0
- package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +164 -0
- package/apps/storefront/src/pages/api/csp-report.ts +25 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
- package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
- package/apps/storefront/src/pages/api/dev/activity.ts +124 -0
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +73 -0
- package/apps/storefront/src/pages/api/dev/diff.ts +68 -0
- package/apps/storefront/src/pages/api/dev/file.ts +57 -0
- package/apps/storefront/src/pages/api/dev/locate.ts +125 -0
- package/apps/storefront/src/pages/api/dev/publish.ts +128 -0
- package/apps/storefront/src/pages/api/dev/tree.ts +70 -0
- package/apps/storefront/src/pages/api/dev/write.ts +116 -0
- package/apps/storefront/src/pages/api/newsletter.ts +104 -0
- package/apps/storefront/src/pages/api/request-access.ts +172 -0
- package/apps/storefront/src/pages/auth/login.astro +149 -0
- package/apps/storefront/src/pages/auth/magic.astro +105 -0
- package/apps/storefront/src/pages/capabilities.astro +292 -0
- package/apps/storefront/src/pages/collections/[handle].astro +177 -0
- package/apps/storefront/src/pages/collections/index.astro +59 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
- package/apps/storefront/src/pages/dashboard/index.astro +160 -0
- package/apps/storefront/src/pages/index.astro +290 -0
- package/apps/storefront/src/pages/llms.txt.ts +92 -0
- package/apps/storefront/src/pages/products/[handle].astro +402 -0
- package/apps/storefront/src/pages/robots.txt.ts +24 -0
- package/apps/storefront/src/pages/saved.astro +35 -0
- package/apps/storefront/src/pages/search.astro +150 -0
- package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +1280 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +229 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +144 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +116 -0
- package/apps/storefront/src/pages/style-guide/index.astro +98 -0
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
- package/apps/storefront/src/styles/global.css +292 -0
- package/apps/storefront/src/themes/reference.ts +68 -0
- package/apps/storefront/src/themes/schema.ts +111 -0
- package/apps/storefront/src/themes/tenants/index.ts +51 -0
- package/apps/storefront/tsconfig.json +16 -0
- package/apps/storefront/vitest.config.ts +22 -0
- package/package.json +26 -0
- package/packages/public-runtime/LICENSE +58 -0
- package/packages/public-runtime/package.json +22 -0
- package/packages/public-runtime/src/binary-path.ts +68 -0
- package/packages/public-runtime/src/capabilities.ts +89 -0
- package/packages/public-runtime/src/catalog-api.ts +81 -0
- package/packages/public-runtime/src/catalog-d1.ts +192 -0
- package/packages/public-runtime/src/checkout-style-publish.ts +200 -0
- package/packages/public-runtime/src/checkout-style.ts +375 -0
- package/packages/public-runtime/src/csp.ts +109 -0
- package/packages/public-runtime/src/customization-preview.ts +197 -0
- package/packages/public-runtime/src/customization-reconcile.ts +63 -0
- package/packages/public-runtime/src/customization-runtime.ts +157 -0
- package/packages/public-runtime/src/customization-scripts.ts +64 -0
- package/packages/public-runtime/src/customization-versioning.ts +95 -0
- package/packages/public-runtime/src/foxy-orders.ts +136 -0
- package/packages/public-runtime/src/foxy.ts +196 -0
- package/packages/public-runtime/src/hot-standby-readiness.ts +140 -0
- package/packages/public-runtime/src/index.ts +34 -0
- package/packages/public-runtime/src/product.ts +159 -0
- package/packages/public-runtime/src/search.ts +45 -0
- package/packages/public-runtime/src/tenant-assets.ts +444 -0
- package/packages/public-runtime/src/tenant.ts +384 -0
- package/packages/public-runtime/tsconfig.json +8 -0
- package/packages/public-runtime/vitest.config.ts +8 -0
- package/pnpm-workspace.yaml +7 -0
- package/scripts/build/copy-tenant-assets.mjs +69 -0
- package/scripts/dev/ai-edit.mjs +183 -0
- package/scripts/dev/checkout-watch.mjs +116 -0
- package/scripts/dev/file-browser.mjs +159 -0
- package/scripts/dev/file-writer.mjs +141 -0
- package/scripts/dev/git-status.mjs +171 -0
- package/scripts/dev/locate-handler.mjs +163 -0
- package/scripts/dev/locate.mjs +112 -0
- package/scripts/dev/port-check.mjs +48 -0
- package/scripts/dev/safe-path.mjs +109 -0
- package/scripts/dev/shot.mjs +46 -0
- package/scripts/dev/tenant-source-root.mjs +76 -0
- package/scripts/dev/unified-diff.mjs +81 -0
- package/scripts/tot-dev.mjs +376 -0
- package/tenants/README.md +14 -0
- package/tenants/home/content/chrome.json +7 -0
- package/tenants/home/content/home.html +58 -0
- package/tenants/home/content/home.json +11 -0
- package/tenants/home/public/js/storefront.js +210 -0
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +212 -0
- package/tenants/home/theme.json +6 -0
- package/tsconfig.base.json +20 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image render decision — testable render logic for <Img>.
|
|
3
|
+
*
|
|
4
|
+
* Picks the `src` + responsive `srcset` for a catalog image, factored out of
|
|
5
|
+
* Img.astro so the branching is unit-testable without rendering the component:
|
|
6
|
+
* - unusable/missing URL -> deterministic placeholder data URI
|
|
7
|
+
* - rehosted (R2) -> Cloudflare Image Resizing (/cdn-cgi/image, format=auto)
|
|
8
|
+
* - referenced Shopify CDN -> Shopify's native ?width= scheme
|
|
9
|
+
* - anything else usable -> the URL as-is, no srcset
|
|
10
|
+
*/
|
|
11
|
+
import { placeholderDataUri, isUsableImageUrl } from "./placeholder.js";
|
|
12
|
+
import { cfImageUrl, cfImageSrcset, CF_IMAGE_WIDTHS } from "./cfImage.js";
|
|
13
|
+
|
|
14
|
+
export interface ResolveImageInput {
|
|
15
|
+
src?: string;
|
|
16
|
+
rehosted?: boolean;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
/** Placeholder seed (image id / product handle). */
|
|
20
|
+
seed: string;
|
|
21
|
+
label?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ResolvedImage {
|
|
25
|
+
src: string;
|
|
26
|
+
srcset?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isShopifyCdnUrl = (url: string): boolean =>
|
|
30
|
+
/\/\/cdn\.shopify\.com\//.test(url) || /\/cdn\/shop\//.test(url);
|
|
31
|
+
|
|
32
|
+
export function resolveImage(input: ResolveImageInput): ResolvedImage {
|
|
33
|
+
const { src, rehosted = false, width, height, seed, label } = input;
|
|
34
|
+
|
|
35
|
+
if (!isUsableImageUrl(src, rehosted)) {
|
|
36
|
+
return { src: placeholderDataUri({ seed, width, height, label }) };
|
|
37
|
+
}
|
|
38
|
+
const url = src!;
|
|
39
|
+
|
|
40
|
+
// Rehosted (R2) images go through Cloudflare Image Resizing — true resizing +
|
|
41
|
+
// format=auto (AVIF/WebP).
|
|
42
|
+
if (rehosted) {
|
|
43
|
+
return { src: cfImageUrl(url, { width }), srcset: cfImageSrcset(url, width) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Referenced Shopify CDN URLs already resize via ?width=; keep that scheme.
|
|
47
|
+
if (isShopifyCdnUrl(url)) {
|
|
48
|
+
const srcset = CF_IMAGE_WIDTHS.filter((w) => w <= width * 2)
|
|
49
|
+
.map((w) => `${url}${url.includes("?") ? "&" : "?"}width=${w} ${w}w`)
|
|
50
|
+
.join(", ");
|
|
51
|
+
return { src: url, srcset };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { src: url };
|
|
55
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema.org JSON-LD builders (brief §8): Product (with offers),
|
|
3
|
+
* BreadcrumbList, Organization. Kept pure so pages compose them and pass to <Seo>.
|
|
4
|
+
*/
|
|
5
|
+
import type { CatalogProduct, Review, TenantConfig } from "@tot/public-runtime";
|
|
6
|
+
import { isAvailable } from "./tot/query.js";
|
|
7
|
+
|
|
8
|
+
export function organizationLd(tenant: TenantConfig, base: string) {
|
|
9
|
+
// Logo sourced from the migrated brand assets (og image / logo), so the
|
|
10
|
+
// Organization schema reflects the real merchant brand, not a generic favicon.
|
|
11
|
+
const brand = tenant.theme_tokens?.brand;
|
|
12
|
+
const logo =
|
|
13
|
+
brand?.ogImage ?? brand?.logoLight ?? new URL("/favicon.svg", base + "/").toString();
|
|
14
|
+
return {
|
|
15
|
+
"@context": "https://schema.org",
|
|
16
|
+
"@type": "Organization",
|
|
17
|
+
name: tenant.displayName,
|
|
18
|
+
url: base,
|
|
19
|
+
logo,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** ItemList — the ordered products on a collection/PLP page. */
|
|
24
|
+
export function itemListLd(
|
|
25
|
+
products: CatalogProduct[],
|
|
26
|
+
base: string,
|
|
27
|
+
pageOffset = 0,
|
|
28
|
+
) {
|
|
29
|
+
return {
|
|
30
|
+
"@context": "https://schema.org",
|
|
31
|
+
"@type": "ItemList",
|
|
32
|
+
numberOfItems: products.length,
|
|
33
|
+
itemListElement: products.map((p, i) => ({
|
|
34
|
+
"@type": "ListItem",
|
|
35
|
+
position: pageOffset + i + 1,
|
|
36
|
+
url: new URL(`/products/${p.handle}`, base + "/").toString(),
|
|
37
|
+
name: p.title,
|
|
38
|
+
})),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* WebSite + SearchAction — enables Google's sitelinks search box, wired to the
|
|
44
|
+
* storefront's own /search?q= endpoint.
|
|
45
|
+
*/
|
|
46
|
+
export function websiteLd(tenant: TenantConfig, base: string) {
|
|
47
|
+
return {
|
|
48
|
+
"@context": "https://schema.org",
|
|
49
|
+
"@type": "WebSite",
|
|
50
|
+
name: tenant.displayName,
|
|
51
|
+
url: base,
|
|
52
|
+
potentialAction: {
|
|
53
|
+
"@type": "SearchAction",
|
|
54
|
+
target: {
|
|
55
|
+
"@type": "EntryPoint",
|
|
56
|
+
urlTemplate: `${base.replace(/\/$/, "")}/search?q={search_term_string}`,
|
|
57
|
+
},
|
|
58
|
+
"query-input": "required name=search_term_string",
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function breadcrumbLd(
|
|
64
|
+
crumbs: { label: string; href: string }[],
|
|
65
|
+
base: string,
|
|
66
|
+
) {
|
|
67
|
+
return {
|
|
68
|
+
"@context": "https://schema.org",
|
|
69
|
+
"@type": "BreadcrumbList",
|
|
70
|
+
itemListElement: crumbs.map((c, i) => ({
|
|
71
|
+
"@type": "ListItem",
|
|
72
|
+
position: i + 1,
|
|
73
|
+
name: c.label,
|
|
74
|
+
item: new URL(c.href, base + "/").toString(),
|
|
75
|
+
})),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function productLd(
|
|
80
|
+
p: CatalogProduct,
|
|
81
|
+
tenant: TenantConfig,
|
|
82
|
+
base: string,
|
|
83
|
+
) {
|
|
84
|
+
const url = new URL(`/products/${p.handle}`, base + "/").toString();
|
|
85
|
+
const images = p.images
|
|
86
|
+
.map((i) => i.url)
|
|
87
|
+
.filter((u) => !/cdn\.shopify\.com/.test(u)); // skip dead fixture URLs
|
|
88
|
+
|
|
89
|
+
const offers = p.variants.map((v) => ({
|
|
90
|
+
"@type": "Offer",
|
|
91
|
+
sku: v.sku,
|
|
92
|
+
price: v.price.toFixed(2),
|
|
93
|
+
priceCurrency: v.currency,
|
|
94
|
+
availability: v.inventory.available
|
|
95
|
+
? "https://schema.org/InStock"
|
|
96
|
+
: "https://schema.org/OutOfStock",
|
|
97
|
+
url,
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
const ratingMf = p.metafields.find(
|
|
101
|
+
(m) => m.namespace === "reviews" && m.key === "rating",
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"@context": "https://schema.org",
|
|
106
|
+
"@type": "Product",
|
|
107
|
+
name: p.title,
|
|
108
|
+
description: p.seo.description || p.description_text.slice(0, 300),
|
|
109
|
+
sku: p.variants[0]?.sku,
|
|
110
|
+
...(images.length ? { image: images } : {}),
|
|
111
|
+
...(p.vendor ? { brand: { "@type": "Brand", name: p.vendor } } : {}),
|
|
112
|
+
...(p.product_type ? { category: p.product_type } : {}),
|
|
113
|
+
offers: {
|
|
114
|
+
"@type": "AggregateOffer",
|
|
115
|
+
priceCurrency: p.price_range.currency,
|
|
116
|
+
lowPrice: p.price_range.min.toFixed(2),
|
|
117
|
+
highPrice: p.price_range.max.toFixed(2),
|
|
118
|
+
offerCount: p.variants.length,
|
|
119
|
+
availability: isAvailable(p)
|
|
120
|
+
? "https://schema.org/InStock"
|
|
121
|
+
: "https://schema.org/OutOfStock",
|
|
122
|
+
offers,
|
|
123
|
+
},
|
|
124
|
+
...(ratingMf
|
|
125
|
+
? {
|
|
126
|
+
aggregateRating: {
|
|
127
|
+
"@type": "AggregateRating",
|
|
128
|
+
ratingValue: ratingMf.value,
|
|
129
|
+
bestRating: "5",
|
|
130
|
+
// ASSUMPTION: review count placeholder; real capture is Phase 2.
|
|
131
|
+
ratingCount: 24,
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
: {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Review markup policy (docs/architecture/review-markup-policy.md): only ever
|
|
140
|
+
* emit `aggregateRating`/`review` from REAL sourced numbers. `average`/`count`
|
|
141
|
+
* must be the real migrated aggregate (0 disables the field — never emit a
|
|
142
|
+
* fabricated `ratingCount` the way `productLd`'s metafield fallback still does).
|
|
143
|
+
* `reviews` are capped at 10 to keep the Product payload bounded; pass them in
|
|
144
|
+
* source order (most-relevant-first is the caller's job, not this builder's).
|
|
145
|
+
*/
|
|
146
|
+
export function reviewLd(average: number, count: number, reviews: Review[]) {
|
|
147
|
+
const out: Record<string, unknown> = {};
|
|
148
|
+
if (average > 0 && count > 0) {
|
|
149
|
+
out.aggregateRating = {
|
|
150
|
+
"@type": "AggregateRating",
|
|
151
|
+
ratingValue: average,
|
|
152
|
+
reviewCount: count,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (reviews.length > 0) {
|
|
156
|
+
out.review = reviews.slice(0, 10).map((r) => ({
|
|
157
|
+
"@type": "Review",
|
|
158
|
+
reviewRating: { "@type": "Rating", ratingValue: r.rating, bestRating: 5 },
|
|
159
|
+
author: { "@type": "Person", name: r.author },
|
|
160
|
+
...(r.title ? { name: r.title } : {}),
|
|
161
|
+
reviewBody: r.body,
|
|
162
|
+
...(r.date ? { datePublished: r.date } : {}),
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site-wide maintenance kill-switch.
|
|
3
|
+
*
|
|
4
|
+
* When `MAINTENANCE_MODE` is truthy the middleware returns {@link maintenanceResponse}
|
|
5
|
+
* for EVERY host and path — before tenant resolution, the access gate, the edge
|
|
6
|
+
* cache, or any render. It is a deliberate full close (a "Coming Soon" holding
|
|
7
|
+
* page), NOT a login gate: auth paths are not exempt, no tenant is resolved, and
|
|
8
|
+
* nothing tenant-specific is served. Use it as the fail-safe lever to take the
|
|
9
|
+
* whole storefront offline in one config flip.
|
|
10
|
+
*
|
|
11
|
+
* Back online: set `MAINTENANCE_MODE=0` (or unset it) and redeploy.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** True for "1" / "on" / "true" / "yes" (case/space-insensitive); false otherwise. */
|
|
15
|
+
export function isMaintenanceOn(raw: string | null | undefined): boolean {
|
|
16
|
+
const v = (raw ?? "").trim().toLowerCase();
|
|
17
|
+
return v === "1" || v === "on" || v === "true" || v === "yes";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const MAINTENANCE_HTML = `<!doctype html>
|
|
21
|
+
<html lang="en">
|
|
22
|
+
<head>
|
|
23
|
+
<meta charset="utf-8" />
|
|
24
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
25
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
26
|
+
<title>Coming soon</title>
|
|
27
|
+
<style>
|
|
28
|
+
:root { color-scheme: light dark; }
|
|
29
|
+
* { box-sizing: border-box; }
|
|
30
|
+
html, body { height: 100%; margin: 0; }
|
|
31
|
+
body {
|
|
32
|
+
display: flex; align-items: center; justify-content: center;
|
|
33
|
+
min-height: 100%; padding: 2rem;
|
|
34
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
35
|
+
background: radial-gradient(1200px 800px at 50% -10%, #1e293b, #0f172a 60%, #020617);
|
|
36
|
+
color: #e2e8f0;
|
|
37
|
+
}
|
|
38
|
+
main { max-width: 34rem; text-align: center; }
|
|
39
|
+
.badge {
|
|
40
|
+
display: inline-block; letter-spacing: .18em; text-transform: uppercase;
|
|
41
|
+
font-size: .72rem; font-weight: 600; color: #94a3b8;
|
|
42
|
+
border: 1px solid rgba(148,163,184,.35); border-radius: 999px;
|
|
43
|
+
padding: .35rem .8rem; margin-bottom: 1.75rem;
|
|
44
|
+
}
|
|
45
|
+
h1 { font-size: clamp(2rem, 6vw, 3rem); line-height: 1.1; margin: 0 0 1rem; color: #f8fafc; }
|
|
46
|
+
p { font-size: 1.05rem; line-height: 1.6; color: #cbd5e1; margin: 0 auto; max-width: 28rem; }
|
|
47
|
+
.rule { width: 3rem; height: 3px; background: #38bdf8; border-radius: 2px; margin: 2rem auto 0; }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<main>
|
|
52
|
+
<span class="badge">Token of Trust</span>
|
|
53
|
+
<h1>Coming soon</h1>
|
|
54
|
+
<p>We're putting the finishing touches on the storefront. It'll be back online shortly — thanks for your patience.</p>
|
|
55
|
+
<div class="rule"></div>
|
|
56
|
+
</main>
|
|
57
|
+
</body>
|
|
58
|
+
</html>`;
|
|
59
|
+
|
|
60
|
+
/** A `503 Service Unavailable` holding page — cached nowhere, never indexed. */
|
|
61
|
+
export function maintenanceResponse(): Response {
|
|
62
|
+
return new Response(MAINTENANCE_HTML, {
|
|
63
|
+
status: 503,
|
|
64
|
+
headers: {
|
|
65
|
+
"content-type": "text/html; charset=utf-8",
|
|
66
|
+
"cache-control": "no-store",
|
|
67
|
+
"retry-after": "3600",
|
|
68
|
+
"x-robots-tag": "noindex, nofollow",
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Membership / pricing tier model — the typed input for the platform tier
|
|
3
|
+
* widgets (<TierCards>, <TierComparisonTable>). These are presentational,
|
|
4
|
+
* EDUCATION-ONLY widgets: they render a tier/plan comparison (the
|
|
5
|
+
* Guest/Member/Autopilot pattern) but carry NO pricing, checkout, or
|
|
6
|
+
* subscription mechanics — a tenant wires the real subscribe/manage flow in the
|
|
7
|
+
* product/commerce layer and hands off to it via a plain CTA link.
|
|
8
|
+
*
|
|
9
|
+
* Because a tier's headline FIGURE (a price, a savings %, a "billed monthly"
|
|
10
|
+
* line) is a claim, the widgets treat figures as ILLUSTRATIVE by default: unless
|
|
11
|
+
* a tier explicitly asserts `real: true`, the widget marks its figures as
|
|
12
|
+
* illustrative ("e.g." affordance + a group-level "Illustrative figures" label)
|
|
13
|
+
* so example numbers are never presented as a live offer. This mirrors the
|
|
14
|
+
* platform-owned / never-faked discipline of the compliance widgets (decision
|
|
15
|
+
* `compliance-widgets-platform-owned`).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A tier's headline figure (price, savings, or cadence line). Display only —
|
|
20
|
+
* this is a string the widget renders verbatim, NEVER a number it computes a
|
|
21
|
+
* price from. Marked illustrative unless the owning {@link Tier} sets `real`.
|
|
22
|
+
*/
|
|
23
|
+
export interface TierFigure {
|
|
24
|
+
/** The headline figure text, e.g. "$0", "$19/mo", "Save 15%". Display only. */
|
|
25
|
+
value: string;
|
|
26
|
+
/** Optional caption under the figure, e.g. "billed monthly". Display only. */
|
|
27
|
+
caption?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One feature row shown on a tier card. `included` drives the check / dash glyph
|
|
32
|
+
* for a simple yes/no; omit it for a plain descriptive bullet.
|
|
33
|
+
*/
|
|
34
|
+
export interface TierFeature {
|
|
35
|
+
/** Feature text, e.g. "Free shipping over $50". */
|
|
36
|
+
label: string;
|
|
37
|
+
/** true → included (check), false → excluded (dash). Omit for a plain note. */
|
|
38
|
+
included?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A CTA on a tier card. This is an EDUCATION/HANDOFF link only — it points at a
|
|
43
|
+
* tenant page (or the product-owned subscribe flow); the widget never renders a
|
|
44
|
+
* subscribe/checkout control of its own.
|
|
45
|
+
*/
|
|
46
|
+
export interface TierCta {
|
|
47
|
+
label: string;
|
|
48
|
+
href: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One membership/pricing tier (e.g. Guest, Member, Autopilot).
|
|
53
|
+
*/
|
|
54
|
+
export interface Tier {
|
|
55
|
+
/** Tier name, e.g. "Member". */
|
|
56
|
+
name: string;
|
|
57
|
+
/** One-line positioning under the name. */
|
|
58
|
+
tagline?: string;
|
|
59
|
+
/** Headline figure (price/savings). Illustrative unless `real` is set. */
|
|
60
|
+
figure?: TierFigure;
|
|
61
|
+
/** Feature bullets shown on the card. */
|
|
62
|
+
features?: TierFeature[];
|
|
63
|
+
/** Education/handoff CTA (never a checkout mechanic). */
|
|
64
|
+
cta?: TierCta;
|
|
65
|
+
/** Visually emphasize this tier as recommended. */
|
|
66
|
+
featured?: boolean;
|
|
67
|
+
/** Small ribbon text on a featured tier, e.g. "Most popular". */
|
|
68
|
+
badge?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Assert this tier's figures are REAL, live values (opt out of the
|
|
71
|
+
* illustrative affordance). Default (undefined/false) → figures are treated
|
|
72
|
+
* as illustrative examples. Only set when the tenant confirms the numbers are
|
|
73
|
+
* an accurate live offer.
|
|
74
|
+
*/
|
|
75
|
+
real?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One row of the {@link TierComparisonTable} feature matrix. `cells` aligns to
|
|
80
|
+
* the table's `tiers` array by index: a boolean renders a check/dash, a string
|
|
81
|
+
* renders as text (e.g. "5 / mo"), and `null`/omitted renders an em dash.
|
|
82
|
+
*/
|
|
83
|
+
export interface ComparisonRow {
|
|
84
|
+
/** Row label (the feature/attribute compared across tiers). */
|
|
85
|
+
label: string;
|
|
86
|
+
/** Per-tier cell, aligned to the table's tiers by index. */
|
|
87
|
+
cells: Array<boolean | string | null>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* True when the widget should mark this tier's figures as illustrative. A tier
|
|
92
|
+
* that asserts `real: true` opts out; everything else is illustrative by
|
|
93
|
+
* default so example numbers are never shown as a live offer.
|
|
94
|
+
*/
|
|
95
|
+
export function isIllustrative(tier: Pick<Tier, "real">): boolean {
|
|
96
|
+
return tier.real !== true;
|
|
97
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory KVNamespace for local dev. Lets the real KvTenantResolver + putTenant
|
|
3
|
+
* run under `astro dev` (where no Cloudflare KV binding exists) so the dynamic
|
|
4
|
+
* tenant path and a data-driven default are testable without a Cloudflare
|
|
5
|
+
* account. NOT used in production — the Workers runtime binds the real
|
|
6
|
+
* TENANT_CACHE namespace. Process-local and non-persistent by design.
|
|
7
|
+
*/
|
|
8
|
+
export class MemoryKv implements KVNamespace {
|
|
9
|
+
private readonly store = new Map<string, string>();
|
|
10
|
+
|
|
11
|
+
async get(key: string): Promise<string | null> {
|
|
12
|
+
return this.store.has(key) ? (this.store.get(key) as string) : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async put(key: string, value: string): Promise<void> {
|
|
16
|
+
this.store.set(key, value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async list(
|
|
20
|
+
opts?: { prefix?: string; cursor?: string; limit?: number },
|
|
21
|
+
): Promise<{ keys: { name: string }[]; list_complete: boolean; cursor?: string }> {
|
|
22
|
+
const prefix = opts?.prefix ?? "";
|
|
23
|
+
const keys = [...this.store.keys()]
|
|
24
|
+
.filter((k) => k.startsWith(prefix))
|
|
25
|
+
.map((name) => ({ name }));
|
|
26
|
+
return { keys, list_complete: true };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async delete(key: string): Promise<void> {
|
|
30
|
+
this.store.delete(key);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessagesClient — the storefront's seam to tot20's inline-body send (`POST /api/messages`).
|
|
3
|
+
*
|
|
4
|
+
* The storefront RENDERS its own branded email HTML and hands it to tot20 to TRANSMIT, so we
|
|
5
|
+
* reuse tot20's Mandrill SPF/DKIM deliverability (the whole point) while owning the body/brand.
|
|
6
|
+
* Used for the magic-link invite email (see lib/grants/magicLinkInvite.ts).
|
|
7
|
+
*
|
|
8
|
+
* POST <core>/api/messages
|
|
9
|
+
* { channel:"email", to, subject, html, totApiKey, totSecretKey } → 200 { sent:true, id? }
|
|
10
|
+
*
|
|
11
|
+
* Creds go in the BODY (`totApiKey`/`totSecretKey`) — tot20's `normalizeSecureUserRequest`, same
|
|
12
|
+
* wire rule as the other operator clients. The storefront's operator key already holds
|
|
13
|
+
* `messaging:send`. DELIVERABILITY: do NOT set a non-`@tokenoftrust.com` From — omit it so the
|
|
14
|
+
* send stays SPF/DKIM-aligned with tot20's domain (a misaligned From lands in spam).
|
|
15
|
+
*
|
|
16
|
+
* Built like `@/lib/grants/developerInvites` (interface + Http impl + Mock + env factory).
|
|
17
|
+
*/
|
|
18
|
+
import { readEnv } from "@/lib/env";
|
|
19
|
+
|
|
20
|
+
export interface InlineEmail {
|
|
21
|
+
/** Recipient address. */
|
|
22
|
+
to: string;
|
|
23
|
+
/** Subject line (your copy). */
|
|
24
|
+
subject: string;
|
|
25
|
+
/** Fully-rendered branded HTML body (your template). */
|
|
26
|
+
html: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type MessageSendResult =
|
|
30
|
+
| { sent: true; id?: string }
|
|
31
|
+
| { sent: false; status: number; reason: string };
|
|
32
|
+
|
|
33
|
+
export interface MessagesClient {
|
|
34
|
+
sendInlineEmail(msg: InlineEmail): Promise<MessageSendResult>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- HTTP transport seam (injected by tests) --------------------------------
|
|
38
|
+
export interface HttpResponseLike {
|
|
39
|
+
status: number;
|
|
40
|
+
json(): Promise<unknown>;
|
|
41
|
+
}
|
|
42
|
+
export interface HttpTransport {
|
|
43
|
+
post(url: string, headers: Record<string, string>, body: unknown): Promise<HttpResponseLike>;
|
|
44
|
+
}
|
|
45
|
+
export class FetchHttpTransport implements HttpTransport {
|
|
46
|
+
async post(url: string, headers: Record<string, string>, body: unknown) {
|
|
47
|
+
return fetch(url, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { "content-type": "application/json", accept: "application/json", ...headers },
|
|
50
|
+
body: JSON.stringify(body),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class MessagesClientError extends Error {}
|
|
56
|
+
|
|
57
|
+
export interface HttpMessagesClientConfig {
|
|
58
|
+
baseUrl: string;
|
|
59
|
+
apiKey: string;
|
|
60
|
+
secretKey?: string;
|
|
61
|
+
transport?: HttpTransport;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class HttpMessagesClient implements MessagesClient {
|
|
65
|
+
private readonly baseUrl: string;
|
|
66
|
+
private readonly apiKey: string;
|
|
67
|
+
private readonly secretKey?: string;
|
|
68
|
+
private readonly transport: HttpTransport;
|
|
69
|
+
|
|
70
|
+
constructor(config: HttpMessagesClientConfig) {
|
|
71
|
+
if (!config.baseUrl) throw new MessagesClientError("messages: TOT_CORE_URL is not configured");
|
|
72
|
+
if (!config.apiKey) throw new MessagesClientError("messages: storefront ToT credentials are not configured");
|
|
73
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
74
|
+
this.apiKey = config.apiKey;
|
|
75
|
+
this.secretKey = config.secretKey;
|
|
76
|
+
this.transport = config.transport ?? new FetchHttpTransport();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private credentialFields(): Record<string, string> {
|
|
80
|
+
const f: Record<string, string> = { totApiKey: this.apiKey };
|
|
81
|
+
if (this.secretKey) f.totSecretKey = this.secretKey;
|
|
82
|
+
return f;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async sendInlineEmail(msg: InlineEmail): Promise<MessageSendResult> {
|
|
86
|
+
let res: HttpResponseLike;
|
|
87
|
+
try {
|
|
88
|
+
res = await this.transport.post(
|
|
89
|
+
`${this.baseUrl}/api/messages`,
|
|
90
|
+
{},
|
|
91
|
+
{
|
|
92
|
+
channel: "email",
|
|
93
|
+
to: msg.to,
|
|
94
|
+
subject: msg.subject,
|
|
95
|
+
html: msg.html,
|
|
96
|
+
// No fromEmail/fromName → stays SPF/DKIM-aligned with tot20's domain (deliverability).
|
|
97
|
+
...this.credentialFields(),
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
} catch (cause) {
|
|
101
|
+
return { sent: false, status: 0, reason: cause instanceof Error ? cause.message : String(cause) };
|
|
102
|
+
}
|
|
103
|
+
let body: unknown;
|
|
104
|
+
try {
|
|
105
|
+
body = await res.json();
|
|
106
|
+
} catch {
|
|
107
|
+
return { sent: false, status: res.status, reason: `non-JSON body (status ${res.status})` };
|
|
108
|
+
}
|
|
109
|
+
return normalizeSend(res.status, body);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Parse tot20's `{ content, metadata }` send response into a typed result (fail closed). */
|
|
114
|
+
export function normalizeSend(status: number, body: unknown): MessageSendResult {
|
|
115
|
+
const raw = (body ?? {}) as Record<string, unknown>;
|
|
116
|
+
const c = (raw.content && typeof raw.content === "object" ? raw.content : raw) as Record<string, unknown>;
|
|
117
|
+
if (c.sent === true || (status >= 200 && status < 300 && c.sent !== false)) {
|
|
118
|
+
return { sent: true, ...(typeof c.id === "string" ? { id: c.id } : {}) };
|
|
119
|
+
}
|
|
120
|
+
const reason =
|
|
121
|
+
typeof c.message === "string" ? c.message : typeof c.reason === "string" ? c.reason : `send failed (status ${status})`;
|
|
122
|
+
return { sent: false, status, reason };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- Mock (tests + local dev) -----------------------------------------------
|
|
126
|
+
export class MockMessagesClient implements MessagesClient {
|
|
127
|
+
sent: InlineEmail[] = [];
|
|
128
|
+
constructor(private readonly result: MessageSendResult = { sent: true, id: "mock-msg" }) {}
|
|
129
|
+
async sendInlineEmail(msg: InlineEmail): Promise<MessageSendResult> {
|
|
130
|
+
this.sent.push(msg);
|
|
131
|
+
return this.result;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- Factory ----------------------------------------------------------------
|
|
136
|
+
export async function createMessagesClient(
|
|
137
|
+
overrides: Partial<HttpMessagesClientConfig> = {},
|
|
138
|
+
): Promise<HttpMessagesClient> {
|
|
139
|
+
const baseUrl = overrides.baseUrl ?? (await readEnv("TOT_CORE_URL")) ?? "";
|
|
140
|
+
const apiKey = overrides.apiKey ?? (await readEnv("TOT_API_KEY")) ?? "";
|
|
141
|
+
const secretKey = overrides.secretKey ?? (await readEnv("TOT_SECRET_KEY"));
|
|
142
|
+
return new HttpMessagesClient({ ...overrides, baseUrl, apiKey, secretKey });
|
|
143
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic local placeholder art (brief: images note).
|
|
3
|
+
*
|
|
4
|
+
* Fixture image URLs are Shopify-CDN paths that 404 and we ship offline. Rather
|
|
5
|
+
* than a grey box, we synthesize a tasteful, aspect-correct SVG keyed by a
|
|
6
|
+
* stable seed (image id or product handle) so each product gets a consistent,
|
|
7
|
+
* on-brand gradient + monogram. The <Img> component swaps these out for real
|
|
8
|
+
* rehosted URLs when CatalogImage.rehosted === true.
|
|
9
|
+
*
|
|
10
|
+
* Palette is pulled from the theme's CSS vars at render time where possible, but
|
|
11
|
+
* the SVG is self-contained (inlines colors) so it works as a data URI / <img src>.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Quiet, on-brand duotone pairs (spruce/greige/amber family). Picked by seed.
|
|
15
|
+
const PAIRS: Array<[string, string]> = [
|
|
16
|
+
["#243B35", "#C9C2B2"], // spruce -> sand
|
|
17
|
+
["#33514A", "#E3DCCB"], // pine -> paper
|
|
18
|
+
["#1F3A34", "#B5612F"], // anchor -> amber
|
|
19
|
+
["#3A4A45", "#D8D1C2"], // slate-sage -> light sand
|
|
20
|
+
["#2C3E39", "#A88B5C"], // deep green -> ochre
|
|
21
|
+
["#1C2624", "#7E8B86"], // ink -> stone
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function hash(seed: string): number {
|
|
25
|
+
let h = 2166136261;
|
|
26
|
+
for (let i = 0; i < seed.length; i++) {
|
|
27
|
+
h ^= seed.charCodeAt(i);
|
|
28
|
+
h = Math.imul(h, 16777619);
|
|
29
|
+
}
|
|
30
|
+
return Math.abs(h);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PlaceholderOpts {
|
|
34
|
+
seed: string;
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
/** Short label drawn faintly (e.g. product initials). */
|
|
38
|
+
label?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Returns a complete SVG string (aspect-correct, deterministic). */
|
|
42
|
+
export function placeholderSvg({ seed, width, height, label }: PlaceholderOpts): string {
|
|
43
|
+
const h = hash(seed);
|
|
44
|
+
const pair = PAIRS[h % PAIRS.length] ?? PAIRS[0]!;
|
|
45
|
+
const [a, b] = pair;
|
|
46
|
+
const angle = (h % 6) * 30; // 0..150deg
|
|
47
|
+
const cx = 20 + (h % 60);
|
|
48
|
+
const cy = 20 + ((h >> 3) % 60);
|
|
49
|
+
const initials = (label ?? seed)
|
|
50
|
+
.split(/[\s-]+/)
|
|
51
|
+
.map((w) => w[0])
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.slice(0, 2)
|
|
54
|
+
.join("")
|
|
55
|
+
.toUpperCase();
|
|
56
|
+
|
|
57
|
+
// Two soft radial blooms over a linear base — reads as considered, not noisy.
|
|
58
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-hidden="true">
|
|
59
|
+
<defs>
|
|
60
|
+
<linearGradient id="g" gradientTransform="rotate(${angle})">
|
|
61
|
+
<stop offset="0%" stop-color="${a}"/>
|
|
62
|
+
<stop offset="100%" stop-color="${b}"/>
|
|
63
|
+
</linearGradient>
|
|
64
|
+
<radialGradient id="r" cx="${cx}%" cy="${cy}%" r="70%">
|
|
65
|
+
<stop offset="0%" stop-color="${b}" stop-opacity="0.55"/>
|
|
66
|
+
<stop offset="100%" stop-color="${a}" stop-opacity="0"/>
|
|
67
|
+
</radialGradient>
|
|
68
|
+
</defs>
|
|
69
|
+
<rect width="100%" height="100%" fill="url(#g)"/>
|
|
70
|
+
<rect width="100%" height="100%" fill="url(#r)"/>
|
|
71
|
+
<text x="50%" y="50%" dy="0.35em" text-anchor="middle"
|
|
72
|
+
font-family="'Space Grotesk', system-ui, sans-serif"
|
|
73
|
+
font-size="${Math.round(Math.min(width, height) * 0.16)}"
|
|
74
|
+
font-weight="600" letter-spacing="0.04em"
|
|
75
|
+
fill="${b}" fill-opacity="0.22">${initials || "•"}</text>
|
|
76
|
+
</svg>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Base64 data URI form (for <img src> and OG images). */
|
|
80
|
+
export function placeholderDataUri(opts: PlaceholderOpts): string {
|
|
81
|
+
const svg = placeholderSvg(opts);
|
|
82
|
+
// btoa is available in Workers + browsers; encodeURIComponent path is the
|
|
83
|
+
// SSR-safe fallback that also handles non-latin labels.
|
|
84
|
+
const encoded = encodeURIComponent(svg)
|
|
85
|
+
.replace(/'/g, "%27")
|
|
86
|
+
.replace(/"/g, "%22");
|
|
87
|
+
return `data:image/svg+xml,${encoded}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True if a CatalogImage URL is usable vs a dead/synthetic fixture path. */
|
|
91
|
+
export function isUsableImageUrl(url: string | undefined, rehosted?: boolean): boolean {
|
|
92
|
+
if (!url) return false;
|
|
93
|
+
if (rehosted) return true;
|
|
94
|
+
// The synthetic apparel fixtures use a fake shop id (…/s/files/1/0001/…) whose
|
|
95
|
+
// URLs 404 — draw placeholders for those. REAL store CDN URLs (e.g. a live POC
|
|
96
|
+
// migration) are usable as-is.
|
|
97
|
+
if (/\/s\/files\/1\/0001\//.test(url)) return false;
|
|
98
|
+
return /^https?:\/\//.test(url);
|
|
99
|
+
}
|