@standhigher/besttrack-page-extension 0.3.0 → 0.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/README.md CHANGED
@@ -1,34 +1,71 @@
1
1
  # @standhigher/besttrack-page-extension
2
2
 
3
- V0.7.1 adds the besttrack.branded built-in template. Its announcement, order
4
- query, order items, recommendations, quick links and Blog blocks use the
5
- shared, host-injected `TrackingPageQuery` contract. The
6
- `ReadyToGoTrackingQuery` name remains a compatible alias. Logo and content are
7
- JSON-only block props; brand colour, font and radius use the template Theme
8
- Tokens and may be overridden through PageDocument.theme.
9
-
10
- V0.7.0 provides the Ready-to-go built-in template: order query, shipment
11
- progress, delivery information and recommendations. The consumer surface follows
12
- the Shopify Track Page layout (query card, five-step progress, shipping
13
- timeline, package contents and product cards) using inline styles and `--pb-*`
14
- Theme Tokens. Live tracking is injected by the host Runtime; this package never
15
- stores credentials or network endpoints in a `PageDocument`.
16
-
17
- V0.7.2 Sales uses the shared, transient `TrackingPageQuery` contract exported
18
- by this package. The external Go Consumer Runtime API owns live-query
19
- authorization and transport; Sales only receives its display-safe result and
20
- never falls back to mock data after a live failure.
21
-
22
- Sales keeps the stable `besttrack.sales` v1 template and seven block IDs.
23
- Its host must inject the query from an authorized Consumer Runtime flow; this
24
- package provides neither a Go client nor a BFF. The editor validates
25
- merchant-authored text, preview-only tracking placeholders and collection
26
- links, while the storefront renders only site-relative or HTTPS resource
27
- links. See `docs/integration/sales-template.md` and
28
- `docs/integration/consumer-runtime-api.md` for the host contract.
29
-
30
- New Sales pages use the `hero` visual Variant: a token-driven announcement,
31
- merchant HTTPS hero image and tracking-only query card. Existing `commerce`
32
- Variants and published v1 documents stay valid. The browser does not offer an
33
- order-number mode because the shared Consumer Runtime API currently accepts
34
- only a tracking number.
3
+ This package provides the built-in Ready-to-go, Branded, and Sales tracking
4
+ templates for `@standhigher/puck-page-builder`. Each template has stable
5
+ namespaced v1 block IDs, default block order, theme tokens, and exported
6
+ `TemplatePolicy` metadata. All default blocks are singleton. Protected
7
+ query/structure blocks now use the core `BlockDefinition.policy` contract
8
+ (`required`, `singleton`, and `allowDelete: false`), which the editor enforces.
9
+
10
+ ## Query provider
11
+
12
+ Branded and Sales still receive a host-owned `TrackingPageQuery` through `query`.
13
+ Ready-to-go storefront pages that keep the original Shopify Track Page backend
14
+ should inject `transport` instead of inventing a new request body:
15
+
16
+ ```tsx
17
+ <ReadyToGoRuntimeProvider
18
+ transport={{
19
+ post: (url, body) => apiPost(withShopifyAppProxyPrefix(url, APP_PROXY_PREFIX), body)
20
+ }}
21
+ >
22
+ <WebRenderer document={document} registry={registry} />
23
+ </ReadyToGoRuntimeProvider>
24
+ ```
25
+
26
+ That path reuses the original lookup: `POST /track/query?_t=`, snake_case
27
+ `{ order_number, email, tracking_number, lang }`, one retry, independent
28
+ `POST /products/recommend` with `{ page, page_size }`, and the original
29
+ progress / shipping / empty-state mapping. The host only supplies fetch,
30
+ App Proxy prefix, and credentials.
31
+
32
+ Branded and Sales keep the discriminated `query` callback:
33
+
34
+ ```tsx
35
+ const query: TrackingPageQuery = async (request) => {
36
+ if (request.mode === "tracking") {
37
+ return serverAuthorizedTrackingLookup(request.trackingNumber);
38
+ }
39
+ return serverAuthorizedOrderLookup(request.orderNumber, request.email);
40
+ };
41
+
42
+ <SalesRuntimeProvider query={query} watermark={{ visible: true }}>
43
+ <WebRenderer document={document} registry={registry} />
44
+ </SalesRuntimeProvider>
45
+ ```
46
+
47
+ `TrackingPageQueryRequest` is either `{ mode: "tracking", trackingNumber }`
48
+ or `{ mode: "order", orderNumber, email }`. Ready-to-go matches the original
49
+ Track Page and only requires non-empty trimmed fields. Branded and Sales still
50
+ perform local PRD format checks. The host must authorize again on the server.
51
+ A host must not submit an order number through the tracking-number mode.
52
+
53
+ Branded and Sales render a two-tab query card on the Hero’s right edge at
54
+ desktop widths and as a single full-width card on narrow screens. Each tab
55
+ retains its own input. Loading, error, empty, and replacement results remain
56
+ inside the scrollable query card; successful requests scroll the result into
57
+ view. The package never stores tokens and never writes a query result into a
58
+ `PageDocument`.
59
+
60
+ ## Watermarks and data safety
61
+
62
+ The optional `watermark` provider prop is a host override. Ready-to-go uses the
63
+ original powered-by hide rule when it is omitted. Branded and Sales only render
64
+ `watermark.visible` and its optional label; they do not infer entitlement.
65
+ Query results and watermark state are transient.
66
+
67
+ Document props and bindings must remain JSON-only and must not contain a token,
68
+ secret, customer order data, email address, or live query result. Merchant
69
+ resources use stable Shopify IDs; product prices and availability are Runtime
70
+ data. Production URLs must be public HTTPS URLs. Missing display data and
71
+ unsafe resource URLs render controlled fallbacks.
@@ -0,0 +1,127 @@
1
+ // src/tracking-page-runtime.ts
2
+ function isValidTrackingNumber(value) {
3
+ return /^[A-Za-z0-9_-]{6,64}$/.test(value);
4
+ }
5
+ function isValidOrderNumber(value) {
6
+ return /^\S{1,64}$/.test(value);
7
+ }
8
+ function isValidOrderEmail(value) {
9
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) && value.length <= 254;
10
+ }
11
+ function isEmptyTrackingPageResult(result) {
12
+ return result.outcome === "empty";
13
+ }
14
+ function isTrackingPageResourceReference(value, kind) {
15
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
16
+ const candidate = value;
17
+ return typeof candidate.id === "string" && candidate.id.startsWith("gid://shopify/") && (candidate.kind === "product" || candidate.kind === "collection" || candidate.kind === "media") && (kind === void 0 || candidate.kind === kind);
18
+ }
19
+ function formatTrackingPageMoney(value, locale = "en-US") {
20
+ if (!Number.isInteger(value.amount) || !/^[A-Z]{3}$/.test(value.currencyCode)) return void 0;
21
+ try {
22
+ const amount = new Intl.NumberFormat(locale, { style: "currency", currency: value.currencyCode }).format(value.amount / 100);
23
+ const compareAt = value.compareAtAmount !== void 0 && Number.isInteger(value.compareAtAmount) && value.compareAtAmount > value.amount ? new Intl.NumberFormat(locale, { style: "currency", currency: value.currencyCode }).format(value.compareAtAmount / 100) : void 0;
24
+ return { amount, compareAt, startsAt: value.startsAt === true };
25
+ } catch {
26
+ return void 0;
27
+ }
28
+ }
29
+
30
+ // src/tracking-page-url.ts
31
+ function hasControlCharacter(value) {
32
+ return [...value].some((character) => character.charCodeAt(0) < 32);
33
+ }
34
+ function isLocalDevelopmentHost(hostname) {
35
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
36
+ }
37
+ function allowsLocalhostByDefault() {
38
+ return typeof window !== "undefined" && isLocalDevelopmentHost(window.location.hostname);
39
+ }
40
+ function safeTrackingPageUrl(value, options = {}) {
41
+ if (typeof value !== "string" || !value || value.trim() !== value || hasControlCharacter(value)) return void 0;
42
+ try {
43
+ const url = new URL(value);
44
+ if (url.username || url.password) return void 0;
45
+ if (url.protocol === "https:" && !isLocalDevelopmentHost(url.hostname)) return url.toString();
46
+ if (url.protocol === "http:" && isLocalDevelopmentHost(url.hostname) && (options.allowLocalhost ?? allowsLocalhostByDefault())) return url.toString();
47
+ return void 0;
48
+ } catch {
49
+ return void 0;
50
+ }
51
+ }
52
+ function isSafeTrackingPageUrl(value, options) {
53
+ return safeTrackingPageUrl(value, options) !== void 0;
54
+ }
55
+
56
+ // src/shopify-resource-contract.ts
57
+ var resourcePattern = /^gid:\/\/shopify\/(Product|Collection)\/[1-9]\d*$/;
58
+ function isShopifyResourceReference(value, kind) {
59
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
60
+ const candidate = value;
61
+ const match = typeof candidate.id === "string" ? resourcePattern.exec(candidate.id) : null;
62
+ const expectedKind = match?.[1] === "Product" ? "product" : match?.[1] === "Collection" ? "collection" : void 0;
63
+ return candidate.kind === expectedKind && expectedKind !== void 0 && (kind === void 0 || expectedKind === kind) && typeof candidate.title === "string" && Boolean(candidate.title.trim()) && candidate.title.length <= 160 && (candidate.handle === void 0 || typeof candidate.handle === "string" && /^[a-z0-9][a-z0-9-]{0,254}$/i.test(candidate.handle));
64
+ }
65
+ function resourceKey(reference) {
66
+ return `${reference.kind}:${reference.id}`;
67
+ }
68
+ function isResolvedResource(value) {
69
+ if (!isShopifyResourceReference(value)) return false;
70
+ const candidate = value;
71
+ return candidate.status === "resolved" && (candidate.availability === "available" || candidate.availability === "sold-out" || candidate.availability === "unavailable" || candidate.availability === "unknown") && (candidate.href === void 0 || typeof candidate.href === "string") && (candidate.imageUrl === void 0 || typeof candidate.imageUrl === "string");
72
+ }
73
+ async function resolveShopifyResources(references, resolver) {
74
+ const resources = {};
75
+ const errors = {};
76
+ const valid = /* @__PURE__ */ new Map();
77
+ references.forEach((reference, index) => {
78
+ if (!isShopifyResourceReference(reference)) {
79
+ errors[`invalid:${index}`] = { code: "invalid-reference" };
80
+ return;
81
+ }
82
+ valid.set(resourceKey(reference), reference);
83
+ });
84
+ if (!valid.size) return { resources, errors };
85
+ let resolved;
86
+ try {
87
+ resolved = await resolver.resolve({ references: [...valid.values()] });
88
+ } catch {
89
+ valid.forEach((reference) => {
90
+ errors[resourceKey(reference)] = { code: "resolution-failed" };
91
+ });
92
+ return { resources, errors };
93
+ }
94
+ resolved.forEach((candidate) => {
95
+ if (!isResolvedResource(candidate)) return;
96
+ const key = resourceKey(candidate);
97
+ if (!valid.has(key)) return;
98
+ const href = safeTrackingPageUrl(candidate.href);
99
+ const imageUrl = safeTrackingPageUrl(candidate.imageUrl);
100
+ resources[key] = { id: candidate.id, kind: candidate.kind, title: candidate.title.trim(), ...candidate.handle ? { handle: candidate.handle } : {}, status: "resolved", availability: candidate.availability, ...href ? { href } : {}, ...imageUrl ? { imageUrl } : {} };
101
+ });
102
+ valid.forEach((reference, key) => {
103
+ if (!resources[key]) errors[key] = { code: "missing" };
104
+ });
105
+ return { resources, errors };
106
+ }
107
+ function getResolvedShopifyResource(reference, resolution) {
108
+ return isShopifyResourceReference(reference) ? resolution?.resources[resourceKey(reference)] : void 0;
109
+ }
110
+ function getShopifyResourceResolutionError(reference, resolution) {
111
+ return isShopifyResourceReference(reference) ? resolution?.errors[resourceKey(reference)] : void 0;
112
+ }
113
+
114
+ export {
115
+ isValidTrackingNumber,
116
+ isValidOrderNumber,
117
+ isValidOrderEmail,
118
+ isEmptyTrackingPageResult,
119
+ isTrackingPageResourceReference,
120
+ formatTrackingPageMoney,
121
+ safeTrackingPageUrl,
122
+ isSafeTrackingPageUrl,
123
+ isShopifyResourceReference,
124
+ resolveShopifyResources,
125
+ getResolvedShopifyResource,
126
+ getShopifyResourceResolutionError
127
+ };