@zalify/storefront-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/LICENSE.md +49 -0
  2. package/README.md +63 -0
  3. package/dist/commerce/countdown.d.ts +18 -0
  4. package/dist/commerce/countdown.js +28 -0
  5. package/dist/commerce/events.d.ts +29 -0
  6. package/dist/commerce/events.js +14 -0
  7. package/dist/commerce/facets.d.ts +27 -0
  8. package/dist/commerce/facets.js +71 -0
  9. package/dist/commerce/index.d.ts +11 -0
  10. package/dist/commerce/index.js +11 -0
  11. package/dist/commerce/money.d.ts +51 -0
  12. package/dist/commerce/money.js +83 -0
  13. package/dist/commerce/product.d.ts +102 -0
  14. package/dist/commerce/product.js +93 -0
  15. package/dist/editor/frame.d.ts +57 -0
  16. package/dist/editor/frame.js +241 -0
  17. package/dist/editor/host.d.ts +31 -0
  18. package/dist/editor/host.js +67 -0
  19. package/dist/editor/index.d.ts +9 -0
  20. package/dist/editor/index.js +9 -0
  21. package/dist/react/adapter.d.ts +49 -0
  22. package/dist/react/adapter.js +23 -0
  23. package/dist/react/blocks/_box.d.ts +10 -0
  24. package/dist/react/blocks/_box.js +17 -0
  25. package/dist/react/blocks/_slide.d.ts +24 -0
  26. package/dist/react/blocks/_slide.js +21 -0
  27. package/dist/react/blocks/_video-card.d.ts +11 -0
  28. package/dist/react/blocks/_video-card.js +39 -0
  29. package/dist/react/blocks/accordion.d.ts +12 -0
  30. package/dist/react/blocks/accordion.js +5 -0
  31. package/dist/react/blocks/button.d.ts +10 -0
  32. package/dist/react/blocks/button.js +15 -0
  33. package/dist/react/blocks/countdown.d.ts +9 -0
  34. package/dist/react/blocks/countdown.js +64 -0
  35. package/dist/react/blocks/custom-liquid.d.ts +11 -0
  36. package/dist/react/blocks/custom-liquid.js +4 -0
  37. package/dist/react/blocks/group.d.ts +9 -0
  38. package/dist/react/blocks/group.js +8 -0
  39. package/dist/react/blocks/image.d.ts +8 -0
  40. package/dist/react/blocks/image.js +7 -0
  41. package/dist/react/blocks/message.d.ts +14 -0
  42. package/dist/react/blocks/message.js +5 -0
  43. package/dist/react/blocks/quote.d.ts +9 -0
  44. package/dist/react/blocks/quote.js +5 -0
  45. package/dist/react/blocks/spacer.d.ts +8 -0
  46. package/dist/react/blocks/spacer.js +5 -0
  47. package/dist/react/blocks/story.d.ts +17 -0
  48. package/dist/react/blocks/story.js +7 -0
  49. package/dist/react/blocks/text.d.ts +9 -0
  50. package/dist/react/blocks/text.js +5 -0
  51. package/dist/react/blocks/video.d.ts +17 -0
  52. package/dist/react/blocks/video.js +56 -0
  53. package/dist/react/components/Facets.d.ts +21 -0
  54. package/dist/react/components/Facets.js +220 -0
  55. package/dist/react/components/Icon.d.ts +12 -0
  56. package/dist/react/components/Icon.js +66 -0
  57. package/dist/react/components/PaymentIcons.d.ts +5 -0
  58. package/dist/react/components/PaymentIcons.generated.d.ts +22 -0
  59. package/dist/react/components/PaymentIcons.generated.js +21 -0
  60. package/dist/react/components/PaymentIcons.js +18 -0
  61. package/dist/react/components/Price.d.ts +24 -0
  62. package/dist/react/components/Price.js +9 -0
  63. package/dist/react/components/ProductBadges.d.ts +24 -0
  64. package/dist/react/components/ProductBadges.js +57 -0
  65. package/dist/react/components/ProductCard.d.ts +17 -0
  66. package/dist/react/components/ProductCard.js +202 -0
  67. package/dist/react/components/Rating.d.ts +11 -0
  68. package/dist/react/components/Rating.js +35 -0
  69. package/dist/react/components/SwatchStyle.d.ts +23 -0
  70. package/dist/react/components/SwatchStyle.js +22 -0
  71. package/dist/react/components/VideoModal.d.ts +55 -0
  72. package/dist/react/components/VideoModal.js +102 -0
  73. package/dist/react/engine/CssVariables.d.ts +1 -0
  74. package/dist/react/engine/CssVariables.js +105 -0
  75. package/dist/react/engine/context.d.ts +43 -0
  76. package/dist/react/engine/context.js +45 -0
  77. package/dist/react/engine/fonts.d.ts +15 -0
  78. package/dist/react/engine/fonts.js +33 -0
  79. package/dist/react/engine/images.d.ts +24 -0
  80. package/dist/react/engine/images.js +40 -0
  81. package/dist/react/engine/render.d.ts +29 -0
  82. package/dist/react/engine/render.js +87 -0
  83. package/dist/react/engine/settings.d.ts +8 -0
  84. package/dist/react/engine/settings.js +68 -0
  85. package/dist/react/engine/store.d.ts +64 -0
  86. package/dist/react/engine/store.js +42 -0
  87. package/dist/react/engine/translate.d.ts +7 -0
  88. package/dist/react/engine/translate.js +33 -0
  89. package/dist/react/engine/types.d.ts +128 -0
  90. package/dist/react/engine/types.js +1 -0
  91. package/dist/react/index.d.ts +38 -0
  92. package/dist/react/index.js +39 -0
  93. package/dist/react/registries.d.ts +10 -0
  94. package/dist/react/registries.js +68 -0
  95. package/dist/react/registries.server.d.ts +2 -0
  96. package/dist/react/registries.server.js +98 -0
  97. package/dist/react/sections/404.d.ts +10 -0
  98. package/dist/react/sections/404.js +10 -0
  99. package/dist/react/sections/announcement-bar.d.ts +14 -0
  100. package/dist/react/sections/announcement-bar.js +84 -0
  101. package/dist/react/sections/article.d.ts +17 -0
  102. package/dist/react/sections/article.js +31 -0
  103. package/dist/react/sections/contact-form.d.ts +16 -0
  104. package/dist/react/sections/contact-form.js +8 -0
  105. package/dist/react/sections/custom-liquid.d.ts +14 -0
  106. package/dist/react/sections/custom-liquid.js +7 -0
  107. package/dist/react/sections/custom-section.d.ts +14 -0
  108. package/dist/react/sections/custom-section.js +9 -0
  109. package/dist/react/sections/featured-collection.d.ts +10 -0
  110. package/dist/react/sections/featured-collection.js +23 -0
  111. package/dist/react/sections/hero.d.ts +6 -0
  112. package/dist/react/sections/hero.js +35 -0
  113. package/dist/react/sections/image-with-text.d.ts +13 -0
  114. package/dist/react/sections/image-with-text.js +9 -0
  115. package/dist/react/sections/multicolumn.d.ts +15 -0
  116. package/dist/react/sections/multicolumn.js +8 -0
  117. package/dist/react/sections/page.d.ts +13 -0
  118. package/dist/react/sections/page.js +11 -0
  119. package/dist/react/sections/password.d.ts +15 -0
  120. package/dist/react/sections/password.js +9 -0
  121. package/dist/react/sections/rich-text.d.ts +12 -0
  122. package/dist/react/sections/rich-text.js +7 -0
  123. package/dist/react/sections/stories.d.ts +18 -0
  124. package/dist/react/sections/stories.js +10 -0
  125. package/dist/react/sections/video-bubble.d.ts +13 -0
  126. package/dist/react/sections/video-bubble.js +54 -0
  127. package/dist/react/sections/video-carousel.d.ts +19 -0
  128. package/dist/react/sections/video-carousel.js +9 -0
  129. package/dist/react/server.d.ts +7 -0
  130. package/dist/react/server.js +42 -0
  131. package/dist/schemas/bridge.d.ts +98 -0
  132. package/dist/schemas/bridge.js +50 -0
  133. package/dist/schemas/data.d.ts +54 -0
  134. package/dist/schemas/data.js +22 -0
  135. package/dist/schemas/index.d.ts +14 -0
  136. package/dist/schemas/index.js +14 -0
  137. package/dist/schemas/manifest.d.ts +63 -0
  138. package/dist/schemas/manifest.js +32 -0
  139. package/dist/schemas/schema.d.ts +159 -0
  140. package/dist/schemas/schema.js +129 -0
  141. package/dist/schemas/validate.d.ts +22 -0
  142. package/dist/schemas/validate.js +199 -0
  143. package/package.json +84 -0
  144. package/src/commerce/countdown.ts +38 -0
  145. package/src/commerce/events.ts +43 -0
  146. package/src/commerce/facets.ts +88 -0
  147. package/src/commerce/index.ts +11 -0
  148. package/src/commerce/money.ts +120 -0
  149. package/src/commerce/product.ts +177 -0
  150. package/src/editor/frame.ts +305 -0
  151. package/src/editor/host.ts +101 -0
  152. package/src/editor/index.ts +9 -0
  153. package/src/react/adapter.tsx +83 -0
  154. package/src/react/blocks/_box.tsx +37 -0
  155. package/src/react/blocks/_slide.tsx +96 -0
  156. package/src/react/blocks/_video-card.tsx +131 -0
  157. package/src/react/blocks/accordion.tsx +33 -0
  158. package/src/react/blocks/button.tsx +36 -0
  159. package/src/react/blocks/countdown.tsx +105 -0
  160. package/src/react/blocks/custom-liquid.tsx +21 -0
  161. package/src/react/blocks/group.tsx +32 -0
  162. package/src/react/blocks/image.tsx +29 -0
  163. package/src/react/blocks/message.tsx +28 -0
  164. package/src/react/blocks/quote.tsx +24 -0
  165. package/src/react/blocks/spacer.tsx +17 -0
  166. package/src/react/blocks/story.tsx +40 -0
  167. package/src/react/blocks/text.tsx +24 -0
  168. package/src/react/blocks/video.tsx +132 -0
  169. package/src/react/components/Facets.tsx +424 -0
  170. package/src/react/components/Icon.tsx +96 -0
  171. package/src/react/components/PaymentIcons.generated.ts +24 -0
  172. package/src/react/components/PaymentIcons.tsx +27 -0
  173. package/src/react/components/Price.tsx +72 -0
  174. package/src/react/components/ProductBadges.tsx +95 -0
  175. package/src/react/components/ProductCard.tsx +417 -0
  176. package/src/react/components/Rating.tsx +59 -0
  177. package/src/react/components/SwatchStyle.tsx +50 -0
  178. package/src/react/components/VideoModal.tsx +236 -0
  179. package/src/react/engine/CssVariables.tsx +142 -0
  180. package/src/react/engine/context.tsx +108 -0
  181. package/src/react/engine/fonts.ts +42 -0
  182. package/src/react/engine/images.tsx +58 -0
  183. package/src/react/engine/render.tsx +190 -0
  184. package/src/react/engine/settings.ts +89 -0
  185. package/src/react/engine/store.ts +103 -0
  186. package/src/react/engine/translate.ts +40 -0
  187. package/src/react/engine/types.ts +142 -0
  188. package/src/react/index.ts +78 -0
  189. package/src/react/registries.server.ts +110 -0
  190. package/src/react/registries.ts +80 -0
  191. package/src/react/sections/404.tsx +37 -0
  192. package/src/react/sections/announcement-bar.tsx +170 -0
  193. package/src/react/sections/article.tsx +97 -0
  194. package/src/react/sections/contact-form.tsx +104 -0
  195. package/src/react/sections/custom-liquid.tsx +39 -0
  196. package/src/react/sections/custom-section.tsx +44 -0
  197. package/src/react/sections/featured-collection.tsx +89 -0
  198. package/src/react/sections/hero.tsx +84 -0
  199. package/src/react/sections/image-with-text.tsx +61 -0
  200. package/src/react/sections/multicolumn.tsx +46 -0
  201. package/src/react/sections/page.tsx +43 -0
  202. package/src/react/sections/password.tsx +60 -0
  203. package/src/react/sections/rich-text.tsx +37 -0
  204. package/src/react/sections/stories.tsx +51 -0
  205. package/src/react/sections/video-bubble.tsx +145 -0
  206. package/src/react/sections/video-carousel.tsx +57 -0
  207. package/src/react/server.ts +60 -0
  208. package/src/schemas/bridge.ts +143 -0
  209. package/src/schemas/data.ts +72 -0
  210. package/src/schemas/index.ts +14 -0
  211. package/src/schemas/manifest.ts +85 -0
  212. package/src/schemas/schema.ts +287 -0
  213. package/src/schemas/validate.ts +302 -0
@@ -0,0 +1,199 @@
1
+ import { DEFAULT_MAX_BLOCKS, allowedChildTypes, isInputSetting, isLocalBlockDefinition, } from "./schema.js";
2
+ function checkOrder(order, keys, path, issues) {
3
+ if (!order)
4
+ return;
5
+ const seen = new Set();
6
+ for (const key of order) {
7
+ if (seen.has(key)) {
8
+ issues.push({
9
+ path,
10
+ code: 'order-duplicate-key',
11
+ message: `"${key}" appears more than once in the order array`,
12
+ });
13
+ }
14
+ seen.add(key);
15
+ if (!keys || !(key in keys)) {
16
+ issues.push({
17
+ path,
18
+ code: 'order-unknown-key',
19
+ message: `order references "${key}" which has no entry`,
20
+ });
21
+ }
22
+ }
23
+ }
24
+ /** Local block definitions in a section's schema, keyed by type. */
25
+ function localDefinitions(rules) {
26
+ const defs = {};
27
+ for (const rule of rules ?? []) {
28
+ if (isLocalBlockDefinition(rule)) {
29
+ defs[rule.type] = { name: rule.name, settings: rule.settings };
30
+ }
31
+ }
32
+ return defs;
33
+ }
34
+ function checkBlocks(parentPath, parentRules, parentMaxBlocks, blocks, blockOrder, ctx, issues) {
35
+ checkOrder(blockOrder, blocks, parentPath, issues);
36
+ if (!blocks)
37
+ return;
38
+ if (ctx) {
39
+ const allowed = allowedChildTypes(parentRules, ctx.allBlockTypes);
40
+ const localDefs = localDefinitions(parentRules);
41
+ const count = blockOrder?.length ?? Object.keys(blocks).length;
42
+ const cap = parentRules?.length
43
+ ? (parentMaxBlocks ?? DEFAULT_MAX_BLOCKS)
44
+ : 0;
45
+ if (count > cap) {
46
+ issues.push({
47
+ path: parentPath,
48
+ code: 'max-blocks-exceeded',
49
+ message: `${count} blocks exceed the allowed maximum of ${cap}`,
50
+ });
51
+ }
52
+ for (const [key, block] of Object.entries(blocks)) {
53
+ const blockPath = `${parentPath}.blocks.${key}`;
54
+ const schema = ctx.manifest.blocks[block.type] ?? localDefs[block.type];
55
+ if (!schema) {
56
+ issues.push({
57
+ path: blockPath,
58
+ code: 'unknown-type',
59
+ message: `block type "${block.type}" is not in the manifest`,
60
+ });
61
+ }
62
+ else if (!allowed.includes(block.type)) {
63
+ issues.push({
64
+ path: blockPath,
65
+ code: 'block-not-allowed',
66
+ message: `block type "${block.type}" is not allowed here`,
67
+ });
68
+ }
69
+ checkBlocks(blockPath, schema?.blocks, undefined, block.blocks, block.block_order, ctx, issues);
70
+ }
71
+ }
72
+ else {
73
+ for (const [key, block] of Object.entries(blocks)) {
74
+ checkBlocks(`${parentPath}.blocks.${key}`, undefined, undefined, block.blocks, block.block_order, null, issues);
75
+ }
76
+ }
77
+ }
78
+ /**
79
+ * Validate a template (or section group) against the L1 invariants,
80
+ * and — when a manifest is given — against the section/block schemas.
81
+ */
82
+ export function validateTemplate(template, manifest) {
83
+ const issues = [];
84
+ const ctx = manifest
85
+ ? { manifest, allBlockTypes: Object.keys(manifest.blocks) }
86
+ : null;
87
+ checkOrder(template.order, template.sections, 'order', issues);
88
+ for (const [key, section] of Object.entries(template.sections ?? {})) {
89
+ const sectionPath = `sections.${key}`;
90
+ const schema = ctx?.manifest.sections[section.type];
91
+ if (ctx && !schema) {
92
+ issues.push({
93
+ path: sectionPath,
94
+ code: 'unknown-type',
95
+ message: `section type "${section.type}" is not in the manifest`,
96
+ });
97
+ }
98
+ checkBlocks(sectionPath, schema?.blocks, schema?.max_blocks, section.blocks, section.block_order, ctx, issues);
99
+ }
100
+ return issues;
101
+ }
102
+ function checkSettings(settings, path, issues) {
103
+ if (!settings)
104
+ return;
105
+ const ids = new Set();
106
+ for (const [index, setting] of settings.entries()) {
107
+ if (!isInputSetting(setting))
108
+ continue;
109
+ if (!setting.id) {
110
+ issues.push({
111
+ path: `${path}[${index}]`,
112
+ code: 'setting-missing-id',
113
+ message: `input setting of type "${setting.type}" has no id`,
114
+ });
115
+ continue;
116
+ }
117
+ if (ids.has(setting.id)) {
118
+ issues.push({
119
+ path: `${path}[${index}]`,
120
+ code: 'setting-duplicate-id',
121
+ message: `duplicate setting id "${setting.id}"`,
122
+ });
123
+ }
124
+ ids.add(setting.id);
125
+ }
126
+ }
127
+ /** Validate a manifest's own consistency. */
128
+ export function validateManifest(manifest) {
129
+ const issues = [];
130
+ for (const field of [
131
+ 'contractVersion',
132
+ 'theme',
133
+ 'sections',
134
+ 'blocks',
135
+ 'settingsSchema',
136
+ 'templates',
137
+ 'sectionGroups',
138
+ 'locales',
139
+ 'hash',
140
+ ]) {
141
+ if (manifest[field] === undefined || manifest[field] === null) {
142
+ issues.push({
143
+ path: field,
144
+ code: 'manifest-missing-field',
145
+ message: `manifest is missing "${field}"`,
146
+ });
147
+ }
148
+ }
149
+ const allBlockTypes = Object.keys(manifest.blocks ?? {});
150
+ for (const [type, section] of Object.entries(manifest.sections ?? {})) {
151
+ checkSettings(section.settings, `sections.${type}.settings`, issues);
152
+ for (const [ruleIndex, rule] of (section.blocks ?? []).entries()) {
153
+ if (isLocalBlockDefinition(rule)) {
154
+ checkSettings(rule.settings, `sections.${type}.blocks[${ruleIndex}].settings`, issues);
155
+ }
156
+ else if (rule.type !== '@theme' &&
157
+ rule.type !== '@app' &&
158
+ !allBlockTypes.includes(rule.type)) {
159
+ issues.push({
160
+ path: `sections.${type}.blocks`,
161
+ code: 'unknown-type',
162
+ message: `block rule references unknown block type "${rule.type}"`,
163
+ });
164
+ }
165
+ }
166
+ for (const [index, preset] of (section.presets ?? []).entries()) {
167
+ const allowed = allowedChildTypes(section.blocks, allBlockTypes);
168
+ const walk = (blocks, rules, where) => {
169
+ for (const block of blocks ?? []) {
170
+ if (!rules.includes(block.type)) {
171
+ issues.push({
172
+ path: where,
173
+ code: 'block-not-allowed',
174
+ message: `preset uses block type "${block.type}" which the parent does not allow`,
175
+ });
176
+ }
177
+ const child = manifest.blocks[block.type];
178
+ walk(block.blocks, allowedChildTypes(child?.blocks, allBlockTypes), where);
179
+ }
180
+ };
181
+ walk(preset.blocks, allowed, `sections.${type}.presets[${index}]`);
182
+ }
183
+ }
184
+ for (const [type, block] of Object.entries(manifest.blocks ?? {})) {
185
+ checkSettings(block.settings, `blocks.${type}.settings`, issues);
186
+ for (const rule of block.blocks ?? []) {
187
+ if (rule.type !== '@theme' &&
188
+ rule.type !== '@app' &&
189
+ !allBlockTypes.includes(rule.type)) {
190
+ issues.push({
191
+ path: `blocks.${type}.blocks`,
192
+ code: 'unknown-type',
193
+ message: `block rule references unknown block type "${rule.type}"`,
194
+ });
195
+ }
196
+ }
197
+ }
198
+ return issues;
199
+ }
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "@zalify/storefront-kit",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "The Zalify storefront SDK: framework-agnostic commerce logic (/commerce), the theme contract types and validators (/schemas), the canvas-editor bridge (/editor), and the React theme engine + shared components (/ui, /react/server). Consumed as TypeScript source inside the zalify-storefronts monorepo; published as compiled ESM + d.ts.",
6
+ "license": "SEE LICENSE IN LICENSE.md",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ "./commerce": {
10
+ "types": "./dist/commerce/index.d.ts",
11
+ "default": "./dist/commerce/index.js"
12
+ },
13
+ "./schemas": {
14
+ "types": "./dist/schemas/index.d.ts",
15
+ "default": "./dist/schemas/index.js"
16
+ },
17
+ "./editor": {
18
+ "types": "./dist/editor/index.d.ts",
19
+ "default": "./dist/editor/index.js"
20
+ },
21
+ "./editor/frame": {
22
+ "types": "./dist/editor/frame.d.ts",
23
+ "default": "./dist/editor/frame.js"
24
+ },
25
+ "./editor/host": {
26
+ "types": "./dist/editor/host.d.ts",
27
+ "default": "./dist/editor/host.js"
28
+ },
29
+ "./react": {
30
+ "types": "./dist/react/index.d.ts",
31
+ "default": "./dist/react/index.js"
32
+ },
33
+ "./react/server": {
34
+ "types": "./dist/react/server.d.ts",
35
+ "default": "./dist/react/server.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "src",
41
+ "!src/**/*.test.ts"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "peerDependencies": {
47
+ "react": ">=18"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "react": {
51
+ "optional": true
52
+ }
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^24.0.0",
56
+ "@types/react": "^18.3.28",
57
+ "typescript": "^5.9.2"
58
+ },
59
+ "repository": {
60
+ "type": "git",
61
+ "url": "git+https://github.com/zalify/zalify-theme-2026.git",
62
+ "directory": "packages/storefront-kit"
63
+ },
64
+ "homepage": "https://github.com/zalify/zalify-theme-2026/tree/main/packages/storefront-kit#readme",
65
+ "bugs": {
66
+ "url": "https://github.com/zalify/zalify-theme-2026/issues"
67
+ },
68
+ "keywords": [
69
+ "zalify",
70
+ "shopify",
71
+ "storefront",
72
+ "theme",
73
+ "hydrogen",
74
+ "nextjs",
75
+ "sections",
76
+ "headless",
77
+ "sdk"
78
+ ],
79
+ "scripts": {
80
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
81
+ "test": "node --test \"src/schemas/*.test.ts\" \"src/editor/*.test.ts\"",
82
+ "typecheck": "tsc -p tsconfig.json"
83
+ }
84
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Countdown math shared by the theme's <countdown-timer> island and the
3
+ * React countdown block. Rendering (digit slots, hidden flips) stays in
4
+ * each runtime; only the clock logic lives here.
5
+ */
6
+
7
+ /** Tolerant end-time parse — accepts ISO and "2026/08/01 00:00:00+0800". */
8
+ export function parseEndTime(raw: string): number {
9
+ const direct = Date.parse(raw);
10
+ if (!Number.isNaN(direct)) return direct;
11
+ return Date.parse(raw.replaceAll('/', '-').replace(' ', 'T'));
12
+ }
13
+
14
+ /** Whole seconds remaining until `end` (epoch ms), floored at 0. */
15
+ export function secondsUntil(end: number, now: number = Date.now()): number {
16
+ return Math.max(0, Math.floor((end - now) / 1000));
17
+ }
18
+
19
+ export interface CountdownParts {
20
+ days: number;
21
+ hours: number;
22
+ minutes: number;
23
+ seconds: number;
24
+ }
25
+
26
+ export function splitCountdown(remainSeconds: number): CountdownParts {
27
+ return {
28
+ days: Math.floor(remainSeconds / 86400),
29
+ hours: Math.floor(remainSeconds / 3600) % 24,
30
+ minutes: Math.floor(remainSeconds / 60) % 60,
31
+ seconds: remainSeconds % 60,
32
+ };
33
+ }
34
+
35
+ /** Two-digit display padding ("07"). */
36
+ export function pad2(value: number): string {
37
+ return String(value).padStart(2, '0');
38
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Typed pub/sub bus for cross-component state — THE event contract
3
+ * every storefront implementation speaks (the Liquid theme's islands
4
+ * and the React apps use the same names and payloads).
5
+ *
6
+ * Add new event names + payload shapes to ThemeEvents; both emit() and
7
+ * on() are checked against it. Keep payloads small and serializable —
8
+ * components should re-read the DOM or refetch, not sync big state here.
9
+ */
10
+ import type {CardProduct} from './product';
11
+
12
+ export interface ThemeEvents {
13
+ 'cart:updated': {itemCount: number; sections?: Record<string, string>};
14
+ 'cart:error': {message: string};
15
+ /** React runtimes: open the quick-add dialog for a product.
16
+ (The Liquid theme reaches its <dialog> directly instead.) */
17
+ 'quick-add:open': {product: CardProduct};
18
+ }
19
+
20
+ /* SSR-safe: EventTarget exists in workers/Node 18+/browsers, but guard
21
+ anyway so importing this module can never throw at load time. */
22
+ const bus: EventTarget | null =
23
+ typeof EventTarget === 'undefined' ? null : new EventTarget();
24
+
25
+ export function emit<K extends keyof ThemeEvents>(
26
+ type: K,
27
+ detail: ThemeEvents[K],
28
+ ): void {
29
+ bus?.dispatchEvent(new CustomEvent(type, {detail}));
30
+ }
31
+
32
+ /** Subscribe. Returns an unsubscribe function; also accepts an AbortSignal. */
33
+ export function on<K extends keyof ThemeEvents>(
34
+ type: K,
35
+ handler: (detail: ThemeEvents[K]) => void,
36
+ options?: {signal?: AbortSignal},
37
+ ): () => void {
38
+ if (!bus) return () => {};
39
+ const listener = (event: Event) =>
40
+ handler((event as CustomEvent<ThemeEvents[K]>).detail);
41
+ bus.addEventListener(type, listener, options);
42
+ return () => bus.removeEventListener(type, listener);
43
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Facets (filter/sort) URL codec shared by every storefront:
3
+ *
4
+ * - cleanFormParams: form entries → clean query params (empty values
5
+ * dropped) — the theme island's #formUrl and the React Facets form
6
+ * both build their navigation URLs with it.
7
+ * - parseFilters/parseSort/parseSearchSort: URL → Storefront API
8
+ * query inputs, used by the React apps' route loaders.
9
+ *
10
+ * URL contract (Hydrogen-skeleton style):
11
+ * sort_by=<handle> theme sort handle
12
+ * filter.<key>=<json> one param per applied Storefront filter
13
+ * e.g. filter.variantOption={"name":"color","value":"Red"}
14
+ * filter.price={"min":10,"max":50}
15
+ */
16
+
17
+ /** Form entries → query params, dropping empty values. */
18
+ export function cleanFormParams(
19
+ entries: Iterable<[string, FormDataEntryValue | string]>,
20
+ ): URLSearchParams {
21
+ const params = new URLSearchParams();
22
+ for (const [name, value] of entries) {
23
+ if (typeof value === 'string' && value !== '') params.append(name, value);
24
+ }
25
+ return params;
26
+ }
27
+
28
+ export type ProductFilterInput = Record<string, unknown>;
29
+
30
+ export function parseFilters(
31
+ searchParams: URLSearchParams,
32
+ ): ProductFilterInput[] {
33
+ const filters: ProductFilterInput[] = [];
34
+ for (const [param, raw] of searchParams.entries()) {
35
+ if (!param.startsWith('filter.')) continue;
36
+ const key = param.slice('filter.'.length);
37
+ if (!key) continue;
38
+ let value: unknown = raw;
39
+ try {
40
+ value = JSON.parse(raw);
41
+ } catch {
42
+ // plain-string filter values (e.g. tag) pass through as-is
43
+ }
44
+ filters.push({[key]: value});
45
+ }
46
+ return filters;
47
+ }
48
+
49
+ export interface SortSelection {
50
+ sortKey: string;
51
+ reverse: boolean;
52
+ }
53
+
54
+ const DEFAULT_SORT: SortSelection = {sortKey: 'MANUAL', reverse: false};
55
+
56
+ const SORT_MAP: Record<string, SortSelection> = {
57
+ manual: DEFAULT_SORT,
58
+ 'best-selling': {sortKey: 'BEST_SELLING', reverse: false},
59
+ 'title-ascending': {sortKey: 'TITLE', reverse: false},
60
+ 'title-descending': {sortKey: 'TITLE', reverse: true},
61
+ 'price-ascending': {sortKey: 'PRICE', reverse: false},
62
+ 'price-descending': {sortKey: 'PRICE', reverse: true},
63
+ 'created-ascending': {sortKey: 'CREATED', reverse: false},
64
+ 'created-descending': {sortKey: 'CREATED', reverse: true},
65
+ };
66
+
67
+ /** Collection pages: theme sort handle → ProductCollectionSortKeys. */
68
+ export function parseSort(searchParams: URLSearchParams): SortSelection {
69
+ return SORT_MAP[searchParams.get('sort_by') ?? ''] ?? DEFAULT_SORT;
70
+ }
71
+
72
+ const DEFAULT_SEARCH_SORT: SortSelection = {
73
+ sortKey: 'RELEVANCE',
74
+ reverse: false,
75
+ };
76
+
77
+ const SEARCH_SORT_MAP: Record<string, SortSelection> = {
78
+ relevance: DEFAULT_SEARCH_SORT,
79
+ 'price-ascending': {sortKey: 'PRICE', reverse: false},
80
+ 'price-descending': {sortKey: 'PRICE', reverse: true},
81
+ };
82
+
83
+ /** Search page: theme sort handle → SearchSortKeys. */
84
+ export function parseSearchSort(searchParams: URLSearchParams): SortSelection {
85
+ return (
86
+ SEARCH_SORT_MAP[searchParams.get('sort_by') ?? ''] ?? DEFAULT_SEARCH_SORT
87
+ );
88
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @zalify/storefront-kit/commerce — framework-agnostic commerce logic shared by
3
+ * every Zalify storefront (Liquid theme islands, Hydrogen, Next.js…).
4
+ * Pure TypeScript: no DOM assumptions beyond web-standard globals, no
5
+ * React. Consumers bundle it as source.
6
+ */
7
+ export * from './money';
8
+ export * from './events';
9
+ export * from './countdown';
10
+ export * from './facets';
11
+ export * from './product';
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Money formatting for both storefront runtimes:
3
+ *
4
+ * - The Liquid theme's islands receive integer cents plus the shop's
5
+ * money format string (window.Theme.moneyFormat) → formatMoneyCents.
6
+ * - React apps receive Storefront API Money objects → formatMoney.
7
+ */
8
+
9
+ /** Money shape from the Storefront API. */
10
+ export interface Money {
11
+ amount: string;
12
+ currencyCode: string;
13
+ }
14
+
15
+ export interface UnitPriceMeasurement {
16
+ referenceValue?: number | null;
17
+ referenceUnit?: string | null;
18
+ }
19
+
20
+ /**
21
+ * Format integer cents using a Shopify money format string
22
+ * (e.g. "${{amount}}") — mirror of Liquid's `| money` for client JS.
23
+ */
24
+ export function formatMoneyCents(cents: number, format: string): string {
25
+ const amount = cents / 100;
26
+ return format.replace(/\{\{\s*(\w+)\s*\}\}/, (_, key: string) => {
27
+ switch (key) {
28
+ case 'amount_no_decimals':
29
+ return String(Math.round(amount));
30
+ case 'amount_with_comma_separator':
31
+ return amount.toFixed(2).replace('.', ',');
32
+ case 'amount_no_decimals_with_comma_separator':
33
+ return String(Math.round(amount));
34
+ default:
35
+ return amount.toFixed(2);
36
+ }
37
+ });
38
+ }
39
+
40
+ /** Format a Storefront API Money via Intl (drops .00 like the theme). */
41
+ export function formatMoney(money: Money | null | undefined): string {
42
+ if (!money) return '';
43
+ const amount = Number(money.amount);
44
+ try {
45
+ return new Intl.NumberFormat('en', {
46
+ style: 'currency',
47
+ currency: money.currencyCode,
48
+ minimumFractionDigits: amount % 1 === 0 ? 0 : 2,
49
+ }).format(amount);
50
+ } catch {
51
+ return `${money.currencyCode} ${money.amount}`;
52
+ }
53
+ }
54
+
55
+ export function isOnSale(
56
+ price: Money | null | undefined,
57
+ compareAt: Money | null | undefined,
58
+ ): boolean {
59
+ if (!price || !compareAt) return false;
60
+ return Number(compareAt.amount) > Number(price.amount);
61
+ }
62
+
63
+ /** A discount allocation as the Storefront API returns it. */
64
+ export interface DiscountAllocation {
65
+ /** CartCodeDiscountAllocation */
66
+ code?: string | null;
67
+ /** CartAutomaticDiscountAllocation / CartCustomDiscountAllocation */
68
+ title?: string | null;
69
+ discountedAmount?: Money | null;
70
+ }
71
+
72
+ export interface AggregatedDiscount {
73
+ label: string;
74
+ amount: Money;
75
+ }
76
+
77
+ /**
78
+ * Group Storefront API discount allocations by code/title and sum
79
+ * their amounts. The API returns cart-level `discountAllocations` as
80
+ * one entry per line the discount applies to (an order discount over
81
+ * four lines yields four allocations of the same code) — Liquid's
82
+ * `cart_level_discount_applications` comes pre-aggregated, and
83
+ * checkout shows one summed row per code. This is the React apps'
84
+ * equivalent for the cart/drawer summary rows.
85
+ */
86
+ export function aggregateDiscountAllocations(
87
+ allocations: ReadonlyArray<DiscountAllocation> | null | undefined,
88
+ ): AggregatedDiscount[] {
89
+ const byLabel = new Map<
90
+ string,
91
+ {label: string; total: number; currencyCode: string}
92
+ >();
93
+ for (const allocation of allocations ?? []) {
94
+ const label = allocation.code ?? allocation.title ?? '';
95
+ const amount = Number(allocation.discountedAmount?.amount ?? 0);
96
+ const currencyCode = allocation.discountedAmount?.currencyCode ?? '';
97
+ const entry = byLabel.get(label);
98
+ if (entry) entry.total += amount;
99
+ else byLabel.set(label, {label, total: amount, currencyCode});
100
+ }
101
+ return [...byLabel.values()].map(({label, total, currencyCode}) => ({
102
+ label,
103
+ amount: {amount: total.toFixed(2), currencyCode},
104
+ }));
105
+ }
106
+
107
+ /**
108
+ * Mirror of Liquid's `unit_price_with_measurement` the way the theme's
109
+ * own JS formats it: "$4.99/100ml".
110
+ */
111
+ export function formatUnitPrice(
112
+ unitPrice: Money,
113
+ measurement?: UnitPriceMeasurement | null,
114
+ ): string {
115
+ const referenceValue = measurement?.referenceValue ?? 1;
116
+ const referenceUnit = (measurement?.referenceUnit ?? '').toLowerCase();
117
+ const reference =
118
+ (referenceValue !== 1 ? String(referenceValue) : '') + referenceUnit;
119
+ return `${formatMoney(unitPrice)}/${reference}`;
120
+ }