shop-client 3.9.0 → 3.9.1

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 (41) hide show
  1. package/README.md +65 -0
  2. package/dist/ai/enrich.d.ts +93 -0
  3. package/dist/ai/enrich.js +25 -0
  4. package/dist/checkout.js +6 -0
  5. package/dist/chunk-2MF53V33.js +196 -0
  6. package/dist/chunk-CN7L3BHG.js +147 -0
  7. package/dist/chunk-CXUCPK6X.js +460 -0
  8. package/dist/chunk-MOBWPEY4.js +420 -0
  9. package/dist/chunk-ROH545KI.js +274 -0
  10. package/dist/chunk-RR6YTQWP.js +90 -0
  11. package/dist/chunk-V52MFQZE.js +233 -0
  12. package/dist/chunk-VPPCOJC3.js +865 -0
  13. package/dist/collections.d.ts +2 -1
  14. package/dist/collections.js +8 -0
  15. package/dist/index.d.ts +7 -84
  16. package/dist/index.js +753 -0
  17. package/dist/products.d.ts +2 -1
  18. package/dist/products.js +8 -0
  19. package/dist/store.d.ts +53 -1
  20. package/dist/store.js +9 -0
  21. package/dist/{store-iQARl6J3.d.ts → types-luPg5O08.d.ts} +1 -208
  22. package/dist/utils/detect-country.d.ts +32 -0
  23. package/dist/utils/detect-country.js +6 -0
  24. package/dist/utils/func.d.ts +61 -0
  25. package/dist/utils/func.js +24 -0
  26. package/dist/utils/rate-limit.js +10 -0
  27. package/package.json +16 -3
  28. package/dist/checkout.mjs +0 -1
  29. package/dist/chunk-6GPWNCDO.mjs +0 -130
  30. package/dist/chunk-EJO5U4BT.mjs +0 -2
  31. package/dist/chunk-FFKWCNLU.mjs +0 -1
  32. package/dist/chunk-KYLPIEU3.mjs +0 -2
  33. package/dist/chunk-MB2INNNP.mjs +0 -1
  34. package/dist/chunk-MI7754VX.mjs +0 -2
  35. package/dist/chunk-SZQPMLZG.mjs +0 -1
  36. package/dist/collections.mjs +0 -1
  37. package/dist/enrich-OZHBXKK6.mjs +0 -1
  38. package/dist/index.mjs +0 -2
  39. package/dist/products.mjs +0 -1
  40. package/dist/store.mjs +0 -1
  41. package/dist/utils/rate-limit.mjs +0 -1
package/README.md CHANGED
@@ -622,6 +622,45 @@ Notes:
622
622
  - npm publishes use OIDC with provenance; no `NPM_TOKEN` secret is required.
623
623
  - Ensure your npm package settings add this GitHub repo as a trusted publisher and set the environment name to `npm-publish`.
624
624
 
625
+ ### Additional Utilities
626
+
627
+ ```typescript
628
+ import {
629
+ calculateDiscount,
630
+ extractDomainWithoutSuffix,
631
+ generateStoreSlug,
632
+ genProductSlug,
633
+ detectShopCountry,
634
+ } from 'shop-client';
635
+
636
+ // Discount calculation (percentage, rounded to nearest integer)
637
+ calculateDiscount(8000, 10000); // 20
638
+
639
+ // Extract base domain without public suffix
640
+ extractDomainWithoutSuffix('www.example.co.uk'); // 'example'
641
+
642
+ // Create an SEO-friendly store slug
643
+ generateStoreSlug('https://shop.example.com'); // 'shop-example-com'
644
+
645
+ // Create a product slug from product data
646
+ genProductSlug({
647
+ title: 'Summer Dress',
648
+ handle: 'summer-dress',
649
+ vendor: 'Acme',
650
+ }); // 'acme-summer-dress'
651
+
652
+ // Detect Shopify store country with confidence score
653
+ const result = await detectShopCountry('anuki.in');
654
+ // result.country → 'IN', result.confidence → 0.9
655
+ ```
656
+
657
+ Notes:
658
+ - `calculateDiscount` expects prices in the same unit (e.g., cents) and returns an integer percentage.
659
+ - `extractDomainWithoutSuffix` removes known TLDs/suffixes, leaving the registrable label.
660
+ - `generateStoreSlug` preserves domain components and replaces separators with hyphens.
661
+ - `genProductSlug` builds a stable, vendor-prefixed slug using product fields.
662
+ - `detectShopCountry` combines multiple signals to infer store country and confidence.
663
+
625
664
  ### Store Type Classification
626
665
 
627
666
  Determine the store’s primary verticals and target audiences using showcased products. Classification uses only each product’s `body_html` content and aggregates per-product results, optionally pruned by store-level signals.
@@ -657,6 +696,32 @@ Details:
657
696
  - If `OPENROUTER_API_KEY` is absent or `OPENROUTER_OFFLINE=1`, uses offline regex heuristics.
658
697
  - Applies store-level pruning based on title/description to improve consistency.
659
698
 
699
+ ### AI Enrichment
700
+
701
+ ```typescript
702
+ import { classifyProduct, generateSEOContent } from 'shop-client';
703
+
704
+ // Classify a single product (offline or via OpenRouter when configured)
705
+ const classification = await classifyProduct({
706
+ title: 'Organic Cotton T-Shirt',
707
+ bodyHtml: '<p>Soft, breathable cotton tee</p>',
708
+ tags: ['organic', 'cotton', 'unisex'],
709
+ });
710
+ // classification → { adult_unisex: { clothing: ['t-shirts'] } }
711
+
712
+ // Generate basic SEO content for a product
713
+ const seo = await generateSEOContent({
714
+ title: 'Organic Cotton T-Shirt',
715
+ description: 'Soft, breathable tee for everyday wear',
716
+ tags: ['organic', 'cotton', 'unisex'],
717
+ });
718
+ // seo.metaTitle, seo.metaDescription, seo.keywords
719
+ ```
720
+
721
+ Notes:
722
+ - `classifyProduct` mirrors the store-level classification logic but operates on a single product.
723
+ - `generateSEOContent` produces lightweight, deterministic metadata suitable for catalogs and PDPs.
724
+
660
725
  ## 🏗️ Type Definitions
661
726
 
662
727
  ### StoreInfo
@@ -0,0 +1,93 @@
1
+ import { k as ProductClassification, l as SEOContent, a as ShopifySingleProduct } from '../types-luPg5O08.js';
2
+
3
+ interface EnrichedProductResult {
4
+ bodyHtml: string;
5
+ pageHtml: string;
6
+ extractedMainHtml: string;
7
+ mergedMarkdown: string;
8
+ }
9
+ /**
10
+ * Fetch Shopify Product AJAX API
11
+ * /products/{handle}.js
12
+ */
13
+ declare function fetchAjaxProduct(domain: string, handle: string): Promise<ShopifySingleProduct>;
14
+ /**
15
+ * Fetch full product page HTML
16
+ */
17
+ declare function fetchProductPage(domain: string, handle: string): Promise<string>;
18
+ /**
19
+ * Extract the main Shopify product section WITHOUT cheerio
20
+ * Uses regex + indexing (fast & reliable)
21
+ */
22
+ declare function extractMainSection(html: string): string | null;
23
+ /**
24
+ * Convert HTML → Clean Markdown using Turndown
25
+ * Includes Shopify cleanup rules + GFM support
26
+ */
27
+ declare function htmlToMarkdown(html: string | null, options?: {
28
+ useGfm?: boolean;
29
+ }): string;
30
+ /**
31
+ * Merge the two markdown sources using OpenAI GPT
32
+ */
33
+ declare function mergeWithLLM(bodyInput: string, pageInput: string, options?: {
34
+ apiKey?: string;
35
+ inputType?: "markdown" | "html";
36
+ model?: string;
37
+ outputFormat?: "markdown" | "json";
38
+ }): Promise<string>;
39
+ /**
40
+ * MAIN WORKFLOW
41
+ */
42
+ declare function enrichProduct(domain: string, handle: string, options?: {
43
+ apiKey?: string;
44
+ useGfm?: boolean;
45
+ inputType?: "markdown" | "html";
46
+ model?: string;
47
+ outputFormat?: "markdown" | "json";
48
+ }): Promise<EnrichedProductResult>;
49
+ /**
50
+ * Classify product content into a three-tier hierarchy using LLM.
51
+ * Returns strictly validated JSON with audience, vertical, and optional category/subCategory.
52
+ */
53
+ declare function classifyProduct(productContent: string, options?: {
54
+ apiKey?: string;
55
+ model?: string;
56
+ }): Promise<ProductClassification>;
57
+ /**
58
+ * Generate SEO and marketing content for a product. Returns strictly validated JSON.
59
+ */
60
+ declare function generateSEOContent(product: {
61
+ title: string;
62
+ description?: string;
63
+ vendor?: string;
64
+ price?: number;
65
+ tags?: string[];
66
+ }, options?: {
67
+ apiKey?: string;
68
+ model?: string;
69
+ }): Promise<SEOContent>;
70
+ /**
71
+ * Determine store type (primary vertical and audience) from store information.
72
+ * Accepts flexible input for showcase products/collections (titles or handles) and returns
73
+ * strictly validated `vertical` and `audience` values.
74
+ */
75
+ declare function determineStoreType(storeInfo: {
76
+ title: string;
77
+ description?: string | null;
78
+ showcase: {
79
+ products: Array<{
80
+ title: string;
81
+ productType?: string | null;
82
+ }> | string[];
83
+ collections: Array<{
84
+ title: string;
85
+ }> | string[];
86
+ };
87
+ }, options?: {
88
+ apiKey?: string;
89
+ model?: string;
90
+ }): Promise<Partial<Record<ProductClassification["audience"], Partial<Record<ProductClassification["vertical"], string[]>>>>>;
91
+ declare function pruneBreakdownForSignals(breakdown: Partial<Record<ProductClassification["audience"], Partial<Record<ProductClassification["vertical"], string[]>>>>, text: string): Partial<Record<ProductClassification["audience"], Partial<Record<ProductClassification["vertical"], string[]>>>>;
92
+
93
+ export { type EnrichedProductResult, classifyProduct, determineStoreType, enrichProduct, extractMainSection, fetchAjaxProduct, fetchProductPage, generateSEOContent, htmlToMarkdown, mergeWithLLM, pruneBreakdownForSignals };
@@ -0,0 +1,25 @@
1
+ import {
2
+ classifyProduct,
3
+ determineStoreType,
4
+ enrichProduct,
5
+ extractMainSection,
6
+ fetchAjaxProduct,
7
+ fetchProductPage,
8
+ generateSEOContent,
9
+ htmlToMarkdown,
10
+ mergeWithLLM,
11
+ pruneBreakdownForSignals
12
+ } from "../chunk-VPPCOJC3.js";
13
+ import "../chunk-2MF53V33.js";
14
+ export {
15
+ classifyProduct,
16
+ determineStoreType,
17
+ enrichProduct,
18
+ extractMainSection,
19
+ fetchAjaxProduct,
20
+ fetchProductPage,
21
+ generateSEOContent,
22
+ htmlToMarkdown,
23
+ mergeWithLLM,
24
+ pruneBreakdownForSignals
25
+ };
@@ -0,0 +1,6 @@
1
+ import {
2
+ createCheckoutOperations
3
+ } from "./chunk-RR6YTQWP.js";
4
+ export {
5
+ createCheckoutOperations
6
+ };
@@ -0,0 +1,196 @@
1
+ // src/utils/rate-limit.ts
2
+ var RateLimiter = class {
3
+ constructor(options) {
4
+ this.queue = [];
5
+ this.inFlight = 0;
6
+ this.refillTimer = null;
7
+ this.options = options;
8
+ this.tokens = options.maxRequestsPerInterval;
9
+ }
10
+ startRefill() {
11
+ if (this.refillTimer) return;
12
+ this.refillTimer = setInterval(() => {
13
+ this.tokens = this.options.maxRequestsPerInterval;
14
+ this.tryRun();
15
+ }, this.options.intervalMs);
16
+ if (this.refillTimer && typeof this.refillTimer.unref === "function") {
17
+ this.refillTimer.unref();
18
+ }
19
+ }
20
+ ensureRefillStarted() {
21
+ if (!this.refillTimer) {
22
+ this.startRefill();
23
+ }
24
+ }
25
+ configure(next) {
26
+ this.options = { ...this.options, ...next };
27
+ this.options.maxRequestsPerInterval = Math.max(
28
+ 1,
29
+ this.options.maxRequestsPerInterval
30
+ );
31
+ this.options.intervalMs = Math.max(10, this.options.intervalMs);
32
+ this.options.maxConcurrency = Math.max(1, this.options.maxConcurrency);
33
+ }
34
+ schedule(fn) {
35
+ return new Promise((resolve, reject) => {
36
+ this.ensureRefillStarted();
37
+ this.queue.push({ fn, resolve, reject });
38
+ this.tryRun();
39
+ });
40
+ }
41
+ tryRun() {
42
+ while (this.queue.length > 0 && this.inFlight < this.options.maxConcurrency && this.tokens > 0) {
43
+ const task = this.queue.shift();
44
+ this.tokens -= 1;
45
+ this.inFlight += 1;
46
+ Promise.resolve().then(task.fn).then((result) => task.resolve(result)).catch((err) => task.reject(err)).finally(() => {
47
+ this.inFlight -= 1;
48
+ setTimeout(() => this.tryRun(), 0);
49
+ });
50
+ }
51
+ }
52
+ };
53
+ var enabled = false;
54
+ var defaultOptions = {
55
+ maxRequestsPerInterval: 5,
56
+ // 5 requests
57
+ intervalMs: 1e3,
58
+ // per second
59
+ maxConcurrency: 5
60
+ // up to 5 in parallel
61
+ };
62
+ var limiter = new RateLimiter(defaultOptions);
63
+ var hostLimiters = /* @__PURE__ */ new Map();
64
+ var classLimiters = /* @__PURE__ */ new Map();
65
+ function getHost(input) {
66
+ try {
67
+ if (typeof input === "string") {
68
+ return new URL(input).host;
69
+ }
70
+ if (input instanceof URL) {
71
+ return input.host;
72
+ }
73
+ const url = input.url;
74
+ if (url) {
75
+ return new URL(url).host;
76
+ }
77
+ } catch {
78
+ }
79
+ return void 0;
80
+ }
81
+ function getHostLimiter(host) {
82
+ if (!host) return void 0;
83
+ const exact = hostLimiters.get(host);
84
+ if (exact) return exact;
85
+ for (const [key, lim] of hostLimiters.entries()) {
86
+ if (key.startsWith("*.") && host.endsWith(key.slice(2))) {
87
+ return lim;
88
+ }
89
+ }
90
+ return void 0;
91
+ }
92
+ function configureRateLimit(options) {
93
+ var _a, _b, _c, _d, _e, _f;
94
+ if (typeof options.enabled === "boolean") {
95
+ enabled = options.enabled;
96
+ }
97
+ const { perHost, perClass } = options;
98
+ const globalOpts = {};
99
+ if (typeof options.maxRequestsPerInterval === "number") {
100
+ globalOpts.maxRequestsPerInterval = options.maxRequestsPerInterval;
101
+ }
102
+ if (typeof options.intervalMs === "number") {
103
+ globalOpts.intervalMs = options.intervalMs;
104
+ }
105
+ if (typeof options.maxConcurrency === "number") {
106
+ globalOpts.maxConcurrency = options.maxConcurrency;
107
+ }
108
+ if (Object.keys(globalOpts).length) {
109
+ limiter.configure(globalOpts);
110
+ }
111
+ if (perHost) {
112
+ for (const host of Object.keys(perHost)) {
113
+ const opts = perHost[host];
114
+ const existing = hostLimiters.get(host);
115
+ if (existing) {
116
+ existing.configure(opts);
117
+ } else {
118
+ hostLimiters.set(
119
+ host,
120
+ new RateLimiter({
121
+ maxRequestsPerInterval: (_a = opts.maxRequestsPerInterval) != null ? _a : defaultOptions.maxRequestsPerInterval,
122
+ intervalMs: (_b = opts.intervalMs) != null ? _b : defaultOptions.intervalMs,
123
+ maxConcurrency: (_c = opts.maxConcurrency) != null ? _c : defaultOptions.maxConcurrency
124
+ })
125
+ );
126
+ }
127
+ }
128
+ }
129
+ if (perClass) {
130
+ for (const klass of Object.keys(perClass)) {
131
+ const opts = perClass[klass];
132
+ const existing = classLimiters.get(klass);
133
+ if (existing) {
134
+ existing.configure(opts);
135
+ } else {
136
+ classLimiters.set(
137
+ klass,
138
+ new RateLimiter({
139
+ maxRequestsPerInterval: (_d = opts.maxRequestsPerInterval) != null ? _d : defaultOptions.maxRequestsPerInterval,
140
+ intervalMs: (_e = opts.intervalMs) != null ? _e : defaultOptions.intervalMs,
141
+ maxConcurrency: (_f = opts.maxConcurrency) != null ? _f : defaultOptions.maxConcurrency
142
+ })
143
+ );
144
+ }
145
+ }
146
+ }
147
+ }
148
+ function sleep(ms) {
149
+ return new Promise((resolve) => setTimeout(resolve, ms));
150
+ }
151
+ async function rateLimitedFetch(input, init) {
152
+ var _a, _b, _c, _d, _e, _f, _g;
153
+ const klass = init == null ? void 0 : init.rateLimitClass;
154
+ const byClass = klass ? classLimiters.get(klass) : void 0;
155
+ const byHost = getHostLimiter(getHost(input));
156
+ const eff = enabled ? (_a = byClass != null ? byClass : byHost) != null ? _a : limiter : void 0;
157
+ const maxRetries = Math.max(0, (_c = (_b = init == null ? void 0 : init.retry) == null ? void 0 : _b.maxRetries) != null ? _c : 2);
158
+ const baseDelayMs = Math.max(0, (_e = (_d = init == null ? void 0 : init.retry) == null ? void 0 : _d.baseDelayMs) != null ? _e : 200);
159
+ const retryOnStatuses = (_g = (_f = init == null ? void 0 : init.retry) == null ? void 0 : _f.retryOnStatuses) != null ? _g : [429, 503];
160
+ let attempt = 0;
161
+ let lastError = null;
162
+ let response = null;
163
+ while (attempt <= maxRetries) {
164
+ try {
165
+ if (eff) {
166
+ response = await eff.schedule(() => fetch(input, init));
167
+ } else {
168
+ response = await fetch(input, init);
169
+ }
170
+ if (!response || response.ok || !retryOnStatuses.includes(response.status)) {
171
+ return response;
172
+ }
173
+ } catch (err) {
174
+ lastError = err;
175
+ }
176
+ attempt += 1;
177
+ if (attempt > maxRetries) break;
178
+ const jitter = Math.floor(Math.random() * 100);
179
+ const delay = baseDelayMs * 2 ** (attempt - 1) + jitter;
180
+ await sleep(delay);
181
+ }
182
+ if (response) return response;
183
+ throw lastError != null ? lastError : new Error("rateLimitedFetch failed without response");
184
+ }
185
+ function getRateLimitStatus() {
186
+ return {
187
+ enabled,
188
+ options: { ...defaultOptions }
189
+ };
190
+ }
191
+
192
+ export {
193
+ configureRateLimit,
194
+ rateLimitedFetch,
195
+ getRateLimitStatus
196
+ };
@@ -0,0 +1,147 @@
1
+ // src/utils/func.ts
2
+ import { parse } from "tldts";
3
+ function extractDomainWithoutSuffix(domain) {
4
+ const parsedDomain = parse(domain);
5
+ return parsedDomain.domainWithoutSuffix;
6
+ }
7
+ function generateStoreSlug(domain) {
8
+ var _a;
9
+ const input = new URL(domain);
10
+ const parsedDomain = parse(input.href);
11
+ const domainName = (_a = parsedDomain.domainWithoutSuffix) != null ? _a : input.hostname.split(".")[0];
12
+ return (domainName || "").toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
13
+ }
14
+ var genProductSlug = ({
15
+ handle,
16
+ storeDomain
17
+ }) => {
18
+ const storeSlug = generateStoreSlug(storeDomain);
19
+ return `${handle}-by-${storeSlug}`;
20
+ };
21
+ var calculateDiscount = (price, compareAtPrice) => !compareAtPrice || compareAtPrice === 0 ? 0 : Math.max(
22
+ 0,
23
+ Math.round(100 - price / compareAtPrice * 100)
24
+ // Removed the decimal precision
25
+ );
26
+ function sanitizeDomain(input, opts) {
27
+ var _a;
28
+ if (typeof input !== "string") {
29
+ throw new Error("sanitizeDomain: input must be a string");
30
+ }
31
+ let raw = input.trim();
32
+ if (!raw) {
33
+ throw new Error("sanitizeDomain: input cannot be empty");
34
+ }
35
+ const hasProtocol = /^[a-z]+:\/\//i.test(raw);
36
+ if (!hasProtocol && !raw.startsWith("//")) {
37
+ raw = `https://${raw}`;
38
+ }
39
+ const stripWWW = (_a = opts == null ? void 0 : opts.stripWWW) != null ? _a : true;
40
+ try {
41
+ let url;
42
+ if (raw.startsWith("//")) {
43
+ url = new URL(`https:${raw}`);
44
+ } else if (raw.includes("://")) {
45
+ url = new URL(raw);
46
+ } else {
47
+ url = new URL(`https://${raw}`);
48
+ }
49
+ let hostname = url.hostname.toLowerCase();
50
+ const hadWWW = /^www\./i.test(url.hostname);
51
+ if (stripWWW) hostname = hostname.replace(/^www\./, "");
52
+ if (!hostname.includes(".")) {
53
+ throw new Error("sanitizeDomain: invalid domain (missing suffix)");
54
+ }
55
+ const parsed = parse(hostname);
56
+ if (!parsed.publicSuffix || parsed.isIcann === false) {
57
+ throw new Error("sanitizeDomain: invalid domain (missing suffix)");
58
+ }
59
+ if (!stripWWW && hadWWW) {
60
+ return `www.${parsed.domain || hostname}`;
61
+ }
62
+ return parsed.domain || hostname;
63
+ } catch {
64
+ let hostname = raw.toLowerCase();
65
+ hostname = hostname.replace(/^[a-z]+:\/\//, "");
66
+ hostname = hostname.replace(/^\/\//, "");
67
+ hostname = hostname.replace(/[/:#?].*$/, "");
68
+ const hadWWW = /^www\./i.test(hostname);
69
+ if (stripWWW) hostname = hostname.replace(/^www\./, "");
70
+ if (!hostname.includes(".")) {
71
+ throw new Error("sanitizeDomain: invalid domain (missing suffix)");
72
+ }
73
+ const parsed = parse(hostname);
74
+ if (!parsed.publicSuffix || parsed.isIcann === false) {
75
+ throw new Error("sanitizeDomain: invalid domain (missing suffix)");
76
+ }
77
+ if (!stripWWW && hadWWW) {
78
+ return `www.${parsed.domain || hostname}`;
79
+ }
80
+ return parsed.domain || hostname;
81
+ }
82
+ }
83
+ function safeParseDate(input) {
84
+ if (!input || typeof input !== "string") return void 0;
85
+ const d = new Date(input);
86
+ return Number.isNaN(d.getTime()) ? void 0 : d;
87
+ }
88
+ function normalizeKey(input) {
89
+ return input.toLowerCase().replace(/\s+/g, "_");
90
+ }
91
+ function buildVariantOptionsMap(optionNames, variants) {
92
+ const keys = optionNames.map(normalizeKey);
93
+ const map = {};
94
+ for (const v of variants) {
95
+ const parts = [];
96
+ if (keys[0] && v.option1)
97
+ parts.push(`${keys[0]}#${normalizeKey(v.option1)}`);
98
+ if (keys[1] && v.option2)
99
+ parts.push(`${keys[1]}#${normalizeKey(v.option2)}`);
100
+ if (keys[2] && v.option3)
101
+ parts.push(`${keys[2]}#${normalizeKey(v.option3)}`);
102
+ if (parts.length > 0) {
103
+ if (parts.length > 1) parts.sort();
104
+ const key = parts.join("##");
105
+ const id = v.id.toString();
106
+ if (map[key] === void 0) {
107
+ map[key] = id;
108
+ }
109
+ }
110
+ }
111
+ return map;
112
+ }
113
+ function buildVariantKey(obj) {
114
+ const parts = [];
115
+ for (const [name, value] of Object.entries(obj)) {
116
+ if (value) {
117
+ parts.push(`${normalizeKey(name)}#${normalizeKey(value)}`);
118
+ }
119
+ }
120
+ if (parts.length === 0) return "";
121
+ parts.sort((a, b) => a.localeCompare(b));
122
+ return parts.join("##");
123
+ }
124
+ function formatPrice(amountInCents, currency) {
125
+ try {
126
+ return new Intl.NumberFormat(void 0, {
127
+ style: "currency",
128
+ currency
129
+ }).format((amountInCents || 0) / 100);
130
+ } catch {
131
+ const val = (amountInCents || 0) / 100;
132
+ return `${val} ${currency}`;
133
+ }
134
+ }
135
+
136
+ export {
137
+ extractDomainWithoutSuffix,
138
+ generateStoreSlug,
139
+ genProductSlug,
140
+ calculateDiscount,
141
+ sanitizeDomain,
142
+ safeParseDate,
143
+ normalizeKey,
144
+ buildVariantOptionsMap,
145
+ buildVariantKey,
146
+ formatPrice
147
+ };