@kasuvia/sdk 0.0.8 → 0.0.9

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 (176) hide show
  1. package/README.md +42 -15
  2. package/dist/AddressAutocomplete-CjcqPElD.d.mts +46 -0
  3. package/dist/AddressAutocomplete-CvJHuRkf.d.ts +46 -0
  4. package/dist/components/index.d.mts +497 -0
  5. package/dist/components/index.d.ts +497 -0
  6. package/dist/components/index.js +4217 -0
  7. package/dist/components/index.js.map +1 -0
  8. package/dist/components/index.mjs +4194 -0
  9. package/dist/components/index.mjs.map +1 -0
  10. package/dist/config/server.d.mts +26 -0
  11. package/dist/config/server.d.ts +26 -0
  12. package/dist/config/server.js +38 -0
  13. package/dist/config/server.js.map +1 -0
  14. package/dist/config/server.mjs +13 -0
  15. package/dist/config/server.mjs.map +1 -0
  16. package/dist/hooks/index.d.mts +393 -160
  17. package/dist/hooks/index.d.ts +393 -160
  18. package/dist/hooks/index.js +2792 -2137
  19. package/dist/hooks/index.js.map +1 -1
  20. package/dist/hooks/index.mjs +2734 -2121
  21. package/dist/hooks/index.mjs.map +1 -1
  22. package/dist/index-CNW_LINy.d.mts +578 -0
  23. package/dist/index-CNW_LINy.d.ts +578 -0
  24. package/dist/{index-C1chUA3P.d.mts → index-F7N6UItS.d.mts} +1 -1
  25. package/dist/{index-C1chUA3P.d.ts → index-F7N6UItS.d.ts} +1 -1
  26. package/dist/index-NkI10J_E.d.mts +40 -0
  27. package/dist/index-NkI10J_E.d.ts +40 -0
  28. package/dist/index.d.mts +20437 -103
  29. package/dist/index.d.ts +20437 -103
  30. package/dist/index.js +2111 -1802
  31. package/dist/index.js.map +1 -1
  32. package/dist/index.mjs +2006 -1786
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/media.service-Cx7dQjzX.d.mts +1393 -0
  35. package/dist/media.service-mrBFJ2UQ.d.ts +1393 -0
  36. package/dist/services/index.d.mts +908 -2
  37. package/dist/services/index.d.ts +908 -2
  38. package/dist/services/index.js +1492 -1686
  39. package/dist/services/index.js.map +1 -1
  40. package/dist/services/index.mjs +1431 -1670
  41. package/dist/services/index.mjs.map +1 -1
  42. package/package.json +46 -6
  43. package/src/components/bespoke/BespokeAvailabilityBadge.tsx +32 -0
  44. package/src/components/bespoke/BespokeProgressTracker.tsx +58 -0
  45. package/src/components/bespoke/index.ts +3 -0
  46. package/src/components/booking/SlotPicker.tsx +97 -0
  47. package/src/components/booking/index.ts +1 -0
  48. package/src/components/cart/CartItem.tsx +81 -0
  49. package/src/components/cart/CartSidebar.tsx +115 -0
  50. package/src/components/cart/CartTypeMismatchBanner.tsx +55 -0
  51. package/src/components/cart/index.ts +3 -0
  52. package/src/components/index.ts +8 -0
  53. package/src/components/orders/DeliverableReviewer.tsx +139 -0
  54. package/src/components/orders/index.ts +1 -0
  55. package/src/components/product/ProductCard.tsx +350 -0
  56. package/src/components/product/ProductGrid.tsx +84 -0
  57. package/src/components/product/index.ts +2 -0
  58. package/src/components/shared/AddressAutocomplete.tsx +212 -0
  59. package/src/components/shared/AnalyticsScripts.tsx +56 -0
  60. package/src/components/shared/CookieConsentBanner.tsx +98 -0
  61. package/src/components/shared/ImageEditorModal.tsx +1066 -0
  62. package/src/components/shared/ImageViewerModal.tsx +598 -0
  63. package/src/components/shared/LucideReactIcon.tsx +52 -0
  64. package/src/components/shared/MapPinDrop.tsx +101 -0
  65. package/src/components/shared/MultiSelectPicker.tsx +193 -0
  66. package/src/components/shared/OfflineBanner.tsx +46 -0
  67. package/src/components/shared/SearchableInput.tsx +153 -0
  68. package/src/components/shared/SearchablePopover.tsx +166 -0
  69. package/src/components/shared/SmartImage.tsx +311 -0
  70. package/src/components/shared/TruncatedValue.tsx +27 -0
  71. package/src/components/shared/UnsavedChangesGuard.tsx +138 -0
  72. package/src/components/shared/index.ts +15 -0
  73. package/src/components/skeletons/CardSkeleton.tsx +26 -0
  74. package/src/components/skeletons/CartItemSkeleton.tsx +26 -0
  75. package/src/components/skeletons/OrderItemSkeleton.tsx +26 -0
  76. package/src/components/skeletons/ProductCardSkeleton.tsx +26 -0
  77. package/src/components/skeletons/TableSkeleton.tsx +42 -0
  78. package/src/components/skeletons/index.ts +5 -0
  79. package/src/config/index.ts +22 -4
  80. package/src/config/server.ts +45 -0
  81. package/src/context/sdk-context.tsx +5 -2
  82. package/src/generated/auth-openapi.ts +2935 -0
  83. package/src/generated/cart-openapi.ts +652 -0
  84. package/src/generated/cms-openapi.ts +1668 -0
  85. package/src/generated/crm-openapi.ts +986 -0
  86. package/src/generated/graphql.ts +126 -0
  87. package/src/generated/index.ts +11 -0
  88. package/src/generated/media-openapi.ts +613 -0
  89. package/src/generated/notification-openapi.ts +862 -0
  90. package/src/generated/payment-openapi.ts +2713 -0
  91. package/src/generated/store-openapi.ts +9510 -0
  92. package/src/graphql/operations/collections.graphql +32 -0
  93. package/src/graphql/operations/products.graphql +197 -0
  94. package/src/graphql/operations/wishlist.graphql +25 -0
  95. package/src/hooks/index.ts +9 -2
  96. package/src/hooks/use-auth.ts +163 -69
  97. package/src/hooks/use-bespoke-availability.ts +39 -22
  98. package/src/hooks/use-business-config.ts +51 -0
  99. package/src/hooks/use-cart.ts +310 -238
  100. package/src/hooks/use-cms.ts +107 -127
  101. package/src/hooks/use-collections.ts +35 -55
  102. package/src/hooks/use-consultations.ts +9 -136
  103. package/src/hooks/use-countries.ts +19 -28
  104. package/src/hooks/use-currencies.ts +8 -20
  105. package/src/hooks/use-customer-address.ts +64 -0
  106. package/src/hooks/use-feedback.ts +34 -0
  107. package/src/hooks/use-languages.ts +26 -39
  108. package/src/hooks/use-media-upload.ts +23 -0
  109. package/src/hooks/use-notification-stream.ts +88 -0
  110. package/src/hooks/use-notifications.ts +163 -0
  111. package/src/hooks/use-order-stream.ts +29 -29
  112. package/src/hooks/use-orders.ts +91 -73
  113. package/src/hooks/use-payment.ts +116 -84
  114. package/src/hooks/use-products.ts +65 -269
  115. package/src/hooks/use-push-notifications.ts +122 -0
  116. package/src/hooks/use-reviews.ts +175 -0
  117. package/src/hooks/use-service-booking.ts +109 -132
  118. package/src/hooks/use-states.ts +16 -30
  119. package/src/hooks/use-wishlist.ts +44 -69
  120. package/src/index.ts +15 -11
  121. package/src/services/auth.service.ts +422 -132
  122. package/src/services/bespoke.service.ts +69 -10
  123. package/src/services/cart.service.ts +155 -186
  124. package/src/services/cms.service.ts +41 -50
  125. package/src/services/collection.service.ts +25 -63
  126. package/src/services/config.service.ts +10 -21
  127. package/src/services/consultation.service.ts +36 -93
  128. package/src/services/feedback.service.ts +76 -0
  129. package/src/services/index.ts +7 -1
  130. package/src/services/legal.service.ts +62 -0
  131. package/src/services/logistics.service.ts +235 -53
  132. package/src/services/media.service.ts +115 -0
  133. package/src/services/notifications.service.ts +204 -20
  134. package/src/services/order.service.ts +498 -127
  135. package/src/services/payment.service.ts +149 -100
  136. package/src/services/product.service.ts +157 -814
  137. package/src/services/review.service.ts +211 -0
  138. package/src/services/service-booking.service.ts +198 -99
  139. package/src/services/wishlist.service.ts +27 -74
  140. package/src/types/auth/index.ts +161 -18
  141. package/src/types/business/index.ts +9 -1
  142. package/src/types/cms/index.ts +91 -6
  143. package/src/types/cms/legal.ts +18 -0
  144. package/src/types/consultations/index.ts +60 -68
  145. package/src/types/index.ts +5 -3
  146. package/src/types/media/index.ts +35 -3
  147. package/src/types/payments/index.ts +80 -16
  148. package/src/types/reviews/index.ts +116 -0
  149. package/src/types/services/index.ts +113 -38
  150. package/src/types/store/checkout.ts +53 -0
  151. package/src/types/store/index.ts +273 -52
  152. package/src/types/store/invoice.ts +31 -0
  153. package/src/types/store/shipping.ts +84 -3
  154. package/src/types/ui/index.ts +1 -1
  155. package/src/utils/checkout.ts +19 -3
  156. package/src/utils/cookieConsent.ts +34 -0
  157. package/src/utils/format.ts +408 -0
  158. package/src/utils/geolocation.ts +134 -0
  159. package/src/utils/graphql-url.ts +47 -0
  160. package/src/utils/guest-session.ts +59 -0
  161. package/src/utils/http-client.ts +147 -0
  162. package/src/utils/index.ts +11 -4
  163. package/src/utils/status-colors.ts +79 -0
  164. package/src/utils/token-refresh.ts +115 -0
  165. package/src/utils/validators.ts +13 -0
  166. package/src/utils/youtube.ts +18 -0
  167. package/dist/index-C5YO9gTl.d.mts +0 -507
  168. package/dist/index-C5YO9gTl.d.ts +0 -507
  169. package/dist/index-CefG7_xq.d.mts +0 -793
  170. package/dist/index-DeHCf3zI.d.ts +0 -793
  171. package/src/graphql/collections.graphql.ts +0 -48
  172. package/src/graphql/index.ts +0 -7
  173. package/src/graphql/products.graphql.ts +0 -860
  174. package/src/graphql/wishlist.graphql.ts +0 -34
  175. package/src/hooks/use-consultation-stream.ts +0 -142
  176. package/src/types/bespoke/index.ts +0 -39
@@ -1,12 +1,24 @@
1
1
  import { buildFullName } from "./customer";
2
2
  import type { CheckoutCustomer, DeliveryAddress, Order, ShippingLocation } from "../types";
3
+ import { ProductTypeEnum, type ProductType, type CartItem } from "../types/store";
3
4
 
4
5
  export function buildDeliveryAddress(customer: CheckoutCustomer, location: ShippingLocation): DeliveryAddress {
6
+ if (!customer.first_name || !customer.last_name || !customer.phone) {
7
+ throw new Error("Customer first_name, last_name, and phone are required for delivery address");
8
+ }
9
+ if (!location.street || !location.city || !location.state || !location.country) {
10
+ throw new Error("Location street, city, state, and country are required for delivery address");
11
+ }
5
12
  return {
6
13
  first_name: customer.first_name,
7
14
  last_name: customer.last_name,
8
15
  phone: customer.phone,
9
- ...location,
16
+ street: location.street,
17
+ city: location.city,
18
+ state: location.state,
19
+ lga: location.lga,
20
+ country: location.country,
21
+ postal: location.postal,
10
22
  };
11
23
  }
12
24
 
@@ -20,20 +32,24 @@ export function enrichCompletedOrder(
20
32
  customer: CheckoutCustomer;
21
33
  location?: ShippingLocation;
22
34
  bespokeBrief?: string;
23
- productType?: "physical" | "digital" | "bespoke" | null;
35
+ productType?: ProductType | null;
24
36
  },
25
37
  ): Order {
26
38
  const customerName = getCheckoutCustomerName(options.customer);
27
39
 
28
40
  return {
29
41
  ...order,
42
+ customer_name: order.customer_name || customerName,
43
+ customer_email: order.customer_email || options.customer.email,
30
44
  customerName: order.customerName || customerName,
31
45
  customerEmail: order.customerEmail || options.customer.email,
32
46
  shippingAddress:
33
47
  order.shippingAddress ??
34
- (options.productType === "physical" && options.location
48
+ (options.productType === ProductTypeEnum.PHYSICAL && options.location
35
49
  ? buildDeliveryAddress(options.customer, options.location)
36
50
  : undefined),
51
+ bespoke_brief: order.bespoke_brief ?? options.bespokeBrief,
37
52
  bespokeBrief: order.bespokeBrief ?? options.bespokeBrief,
38
53
  };
39
54
  }
55
+
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Cookie Consent Utilities
3
+ *
4
+ * Cookie consent state — read/written to localStorage only (per-browser, never
5
+ * sent to any server). "accepted" | "rejected" | null (no choice made yet).
6
+ *
7
+ * localStorage is intentionally used here: this is a GDPR/NDPR UI preference
8
+ * flag, not business data. It is never sent to the backend. The banner simply
9
+ * reappears on the next visit if localStorage is unavailable (private browsing).
10
+ */
11
+
12
+ export type CookieConsentValue = "accepted" | "rejected" | null;
13
+
14
+ const STORAGE_KEY = "kasuvia_cookie_consent";
15
+ export const COOKIE_CONSENT_CHANGED_EVENT = "kasuvia:cookie-consent-changed";
16
+
17
+ export function getCookieConsent(): CookieConsentValue {
18
+ try {
19
+ const value = window.localStorage.getItem(STORAGE_KEY);
20
+ return value === "accepted" || value === "rejected" ? value : null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ export function setCookieConsent(value: "accepted" | "rejected"): void {
27
+ try {
28
+ window.localStorage.setItem(STORAGE_KEY, value);
29
+ window.dispatchEvent(new Event(COOKIE_CONSENT_CHANGED_EVENT));
30
+ } catch {
31
+ // localStorage unavailable (private browsing, blocked site data) — the banner
32
+ // will just reappear next visit rather than crash; nothing else depends on this.
33
+ }
34
+ }
@@ -0,0 +1,408 @@
1
+ /**
2
+ * format.ts
3
+ * Comprehensive number formatting utility with multiple format styles.
4
+ * Migrated from kasuvia-storefront to kasuvia-sdk for shared use.
5
+ */
6
+
7
+ export type FormatStyle =
8
+ | "compact" // 1000000 → "1M"
9
+ | "compact-lower" // 1000000 → "1m"
10
+ | "compact-full" // 1000000 → "1 Million"
11
+ | "compact-full-lower" // 1000000 → "1 million"
12
+ | "currency" // 1000000 → "$1,000,000.00"
13
+ | "currency-compact" // 1000000 → "$1M"
14
+ | "decimal" // 1000000 → "1,000,000"
15
+ | "decimal-dot" // 1000000 → "1.000.000" (European style)
16
+ | "decimal-space" // 1000000 → "1 000 000" (French/SI style)
17
+ | "plain" // 1000000 → "1000000"
18
+ | "scientific" // 1000000 → "1.00e+6"
19
+ | "ordinal" // 1 → "1st", 2 → "2nd", etc.
20
+ | "percent" // 0.75 → "75%"
21
+ | "bytes" // 1048576 → "1.00 MB"
22
+ | "roman"; // 14 → "XIV"
23
+
24
+ export interface FormatOptions {
25
+ style?: FormatStyle;
26
+ /** Number of decimal places (where applicable). Default: auto */
27
+ decimals?: number;
28
+ /** Currency symbol for "currency" styles. Default: "$" */
29
+ currencySymbol?: string;
30
+ /** Locale for Intl-based formatting. Default: "en-US" */
31
+ locale?: string;
32
+ /** If true, always show + sign for positive numbers */
33
+ showSign?: boolean;
34
+ }
35
+
36
+ // ─── Thresholds ───────────────────────────────────────────────────────────────
37
+
38
+ const COMPACT_THRESHOLDS = [
39
+ { value: 1e12, suffixUpper: "T", suffixLower: "t", full: "Trillion" },
40
+ { value: 1e9, suffixUpper: "B", suffixLower: "b", full: "Billion" },
41
+ { value: 1e6, suffixUpper: "M", suffixLower: "m", full: "Million" },
42
+ { value: 1e3, suffixUpper: "K", suffixLower: "k", full: "Thousand" },
43
+ ] as const;
44
+
45
+ const BYTE_THRESHOLDS = [
46
+ { value: 1024 ** 5, label: "PB" },
47
+ { value: 1024 ** 4, label: "TB" },
48
+ { value: 1024 ** 3, label: "GB" },
49
+ { value: 1024 ** 2, label: "MB" },
50
+ { value: 1024 ** 1, label: "KB" },
51
+ ] as const;
52
+
53
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
54
+
55
+ function trimDecimals(value: number, decimals?: number): string {
56
+ if (decimals !== undefined) return value.toFixed(decimals);
57
+ // Auto: trim unnecessary trailing zeros
58
+ const fixed = value.toFixed(10);
59
+ return parseFloat(fixed).toString();
60
+ }
61
+
62
+ function autoDecimals(value: number, defaultDecimals: number, decimals?: number): string {
63
+ return (decimals !== undefined ? value.toFixed(decimals) : value.toFixed(defaultDecimals))
64
+ .replace(/\.?0+$/, "");
65
+ }
66
+
67
+ function applySign(str: string, num: number, showSign?: boolean): string {
68
+ if (showSign && num > 0) return `+${str}`;
69
+ return str;
70
+ }
71
+
72
+ // ─── Ordinal ──────────────────────────────────────────────────────────────────
73
+
74
+ function toOrdinal(n: number): string {
75
+ const abs = Math.abs(Math.round(n));
76
+ const mod100 = abs % 100;
77
+ const mod10 = abs % 10;
78
+ if (mod100 >= 11 && mod100 <= 13) return `${abs}th`;
79
+ if (mod10 === 1) return `${abs}st`;
80
+ if (mod10 === 2) return `${abs}nd`;
81
+ if (mod10 === 3) return `${abs}rd`;
82
+ return `${abs}th`;
83
+ }
84
+
85
+ // ─── Roman Numerals ───────────────────────────────────────────────────────────
86
+
87
+ function toRoman(n: number): string {
88
+ if (n <= 0 || n > 3999 || !Number.isInteger(n)) {
89
+ throw new RangeError("Roman numerals only support integers between 1 and 3999.");
90
+ }
91
+ const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
92
+ const syms = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"];
93
+ let result = "";
94
+ let remaining = n;
95
+ for (let i = 0; i < vals.length; i++) {
96
+ while (remaining >= vals[i]) {
97
+ result += syms[i];
98
+ remaining -= vals[i];
99
+ }
100
+ }
101
+ return result;
102
+ }
103
+
104
+ // ─── Compact ─────────────────────────────────────────────────────────────────
105
+
106
+ function formatCompact(
107
+ n: number,
108
+ suffixKey: "suffixUpper" | "suffixLower" | "full",
109
+ lowerFull: boolean,
110
+ decimals?: number,
111
+ ): string {
112
+ const abs = Math.abs(n);
113
+ for (const tier of COMPACT_THRESHOLDS) {
114
+ if (abs >= tier.value) {
115
+ const divided = n / tier.value;
116
+ const suffix = suffixKey === "full"
117
+ ? (lowerFull ? tier.full.toLowerCase() : tier.full)
118
+ : tier[suffixKey];
119
+ const spacer = suffixKey === "full" ? " " : "";
120
+ return `${autoDecimals(divided, 2, decimals)}${spacer}${suffix}`;
121
+ }
122
+ }
123
+ return trimDecimals(n, decimals ?? 0);
124
+ }
125
+
126
+ // ─── Decimal ─────────────────────────────────────────────────────────────────
127
+
128
+ function formatDecimal(n: number, separator: string, decimalChar = ".", decimals?: number): string {
129
+ const fixed = decimals !== undefined ? Math.abs(n).toFixed(decimals) : Math.abs(n).toFixed(0);
130
+ const [intPart, decPart] = fixed.split(".");
131
+ const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, separator);
132
+ const sign = n < 0 ? "-" : "";
133
+ return decPart !== undefined ? `${sign}${grouped}${decimalChar}${decPart}` : `${sign}${grouped}`;
134
+ }
135
+
136
+ // ─── Bytes ────────────────────────────────────────────────────────────────────
137
+
138
+ function formatBytes(n: number, decimals = 2): string {
139
+ if (n === 0) return "0 B";
140
+ const abs = Math.abs(n);
141
+ for (const tier of BYTE_THRESHOLDS) {
142
+ if (abs >= tier.value) {
143
+ return `${(n / tier.value).toFixed(decimals)} ${tier.label}`;
144
+ }
145
+ }
146
+ return `${n.toFixed(0)} B`;
147
+ }
148
+
149
+ // ─── Main Export ──────────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * Format a number in a variety of styles.
153
+ *
154
+ * @example
155
+ * formatNumber(1_000_000) // "1M"
156
+ * formatNumber(1_000_000, { style: "compact-lower" }) // "1m"
157
+ * formatNumber(1_000_000, { style: "compact-full" }) // "1 Million"
158
+ * formatNumber(1_000_000, { style: "currency" }) // "$1,000,000.00"
159
+ * formatNumber(1_000_000, { style: "decimal" }) // "1,000,000"
160
+ * formatNumber(1_234_567.89, { style: "decimal-dot" }) // "1.234.567"
161
+ * formatNumber(0.754, { style: "percent" }) // "75.4%"
162
+ * formatNumber(1_048_576, { style: "bytes" }) // "1.00 MB"
163
+ * formatNumber(14, { style: "roman" }) // "XIV"
164
+ */
165
+ export function formatNumber(value: number, options: FormatOptions = {}): string {
166
+ const {
167
+ style = "compact",
168
+ decimals,
169
+ currencySymbol = "$",
170
+ locale = "en-US",
171
+ showSign,
172
+ } = options;
173
+
174
+ if (!isFinite(value)) return String(value); // Handle Infinity / NaN
175
+
176
+ let result: string;
177
+
178
+ switch (style) {
179
+ case "compact":
180
+ result = formatCompact(value, "suffixUpper", false, decimals);
181
+ break;
182
+
183
+ case "compact-lower":
184
+ result = formatCompact(value, "suffixLower", false, decimals);
185
+ break;
186
+
187
+ case "compact-full":
188
+ result = formatCompact(value, "full", false, decimals);
189
+ break;
190
+
191
+ case "compact-full-lower":
192
+ result = formatCompact(value, "full", true, decimals);
193
+ break;
194
+
195
+ case "currency": {
196
+ const d = decimals ?? 2;
197
+ const formatted = formatDecimal(value, ",", ".", d);
198
+ result = `${currencySymbol}${formatted}`;
199
+ break;
200
+ }
201
+
202
+ case "currency-compact": {
203
+ const compact = formatCompact(Math.abs(value), "suffixUpper", false, decimals);
204
+ const sign = value < 0 ? "-" : "";
205
+ result = `${sign}${currencySymbol}${compact}`;
206
+ break;
207
+ }
208
+
209
+ case "decimal":
210
+ result = formatDecimal(value, ",", ".", decimals);
211
+ break;
212
+
213
+ case "decimal-dot":
214
+ result = formatDecimal(value, ".", ",", decimals);
215
+ break;
216
+
217
+ case "decimal-space":
218
+ result = formatDecimal(value, "\u00A0", ".", decimals); // non-breaking space
219
+ break;
220
+
221
+ case "plain":
222
+ result = decimals !== undefined ? value.toFixed(decimals) : String(value);
223
+ break;
224
+
225
+ case "scientific": {
226
+ const d = decimals ?? 2;
227
+ result = value.toExponential(d);
228
+ break;
229
+ }
230
+
231
+ case "ordinal":
232
+ return toOrdinal(value); // sign not applicable
233
+
234
+ case "percent": {
235
+ const d = decimals ?? 1;
236
+ result = `${(value * 100).toFixed(d).replace(/\.?0+$/, "")}%`;
237
+ break;
238
+ }
239
+
240
+ case "bytes":
241
+ result = formatBytes(value, decimals);
242
+ break;
243
+
244
+ case "roman":
245
+ return toRoman(Math.round(value)); // sign not applicable
246
+
247
+ default:
248
+ result = String(value);
249
+ }
250
+
251
+ return applySign(result, value, showSign);
252
+ }
253
+
254
+ // ─── Convenience wrappers ─────────────────────────────────────────────────────
255
+
256
+ /** 1000000 → "1M" */
257
+ export const compact = (n: number, decimals?: number) =>
258
+ formatNumber(n, { style: "compact", decimals });
259
+
260
+ /** 1000000 → "1m" */
261
+ export const compactLower = (n: number, decimals?: number) =>
262
+ formatNumber(n, { style: "compact-lower", decimals });
263
+
264
+ /** 1000000 → "1 Million" */
265
+ export const compactFull = (n: number, decimals?: number) =>
266
+ formatNumber(n, { style: "compact-full", decimals });
267
+
268
+ /** 1000000 → "$1,000,000.00" */
269
+ export const currencyFormat = (n: number, symbol = "$", decimals = 2) =>
270
+ formatNumber(n, { style: "currency", currencySymbol: symbol, decimals });
271
+
272
+ /** 1000000 → "$1M" */
273
+ export const currencyCompact = (n: number, symbol = "$") =>
274
+ formatNumber(n, { style: "currency-compact", currencySymbol: symbol });
275
+
276
+ /** 1000000 → "1,000,000" */
277
+ export const decimal = (n: number, decimals?: number) =>
278
+ formatNumber(n, { style: "decimal", decimals });
279
+
280
+ /** 0.75 → "75%" */
281
+ export const percent = (n: number, decimals?: number) =>
282
+ formatNumber(n, { style: "percent", decimals });
283
+
284
+ /** 1048576 → "1.00 MB" */
285
+ export const bytes = (n: number, decimals?: number) =>
286
+ formatNumber(n, { style: "bytes", decimals });
287
+
288
+ /** 14 → "XIV" */
289
+ export const roman = (n: number) => formatNumber(n, { style: "roman" });
290
+
291
+ /** 1 → "1st", 2 → "2nd" */
292
+ export const ordinal = (n: number) => formatNumber(n, { style: "ordinal" });
293
+
294
+ // ─── Currency Formatting (Multi-Currency Support) ─────────────────────────────
295
+
296
+ /**
297
+ * NOTE: Currency and language data should be fetched from KASUVIA_PAYMENT backend API
298
+ * using the useSupportedLanguages hook. Do not hardcode currency values here.
299
+ * Use the formatPrice, getCurrencySymbol, and formatCompactPrice functions with
300
+ * currency/locale parameters from the API response.
301
+ */
302
+
303
+ /**
304
+ * Format a bare "HH:mm" / "HH:mm:ss" time string (no date component) as a
305
+ * human-readable 12-hour time, e.g. "14:30:00" -> "2:30 PM".
306
+ *
307
+ * Owner-flagged cleanup (2026-09-20): call sites needing this used to build
308
+ * a `new Date(\`1970-01-01T${time}\`)` just to borrow Date's own formatting
309
+ * — a hardcoded, arbitrary epoch date standing in for a value that was
310
+ * never actually a date. Pure string/number parsing instead — no Date
311
+ * object, no dependency on the JS engine's date-string parsing behavior for
312
+ * a value with no real date component at all.
313
+ */
314
+ export function formatTimeString(time: string): string {
315
+ const [hoursStr, minutesStr] = time.split(":");
316
+ const hours = parseInt(hoursStr, 10);
317
+ if (isNaN(hours) || minutesStr === undefined) return time;
318
+ const period = hours >= 12 ? "PM" : "AM";
319
+ const displayHours = hours % 12 || 12;
320
+ return `${displayHours}:${minutesStr} ${period}`;
321
+ }
322
+
323
+ /**
324
+ * Format a price amount with currency symbol and proper decimal places.
325
+ * Uses Intl.NumberFormat for accurate currency formatting.
326
+ *
327
+ * REAL BUG FIXED (2026-09-20): a missing/omitted `currency` used to
328
+ * silently default to "USD" — displaying a real price with a fabricated,
329
+ * likely WRONG currency (most Kasuvia businesses price in NGN or another
330
+ * non-USD currency) instead of flagging that the caller never passed the
331
+ * real one. Confirmed this is the actual reachable `formatPrice` (exported
332
+ * via utils/index.ts + src/index.ts) that ~26 call sites across
333
+ * kasuvia-storefront call — most of them WITHOUT a currency argument at
334
+ * all, meaning every one of those was silently mislabeling real prices as
335
+ * USD. Prefer `useAppContext().formatPrice` in storefront components — it
336
+ * reads the actual business currency from real config; this stateless
337
+ * function should only be used where a real currency is already in hand.
338
+ *
339
+ * @param amount - The amount to format (null/undefined returns '')
340
+ * @param currency - ISO 4217 currency code (e.g., USD, NGN, EUR). Required —
341
+ * there is no default; a missing value returns an explicit "N/A" marker
342
+ * rather than guessing.
343
+ * @param locale - Locale string for formatting (default: en-US)
344
+ * @returns Formatted currency string
345
+ */
346
+ export function formatPrice(
347
+ amount: number | null | undefined,
348
+ currency: string | null | undefined,
349
+ locale: string = "en-US",
350
+ ): string {
351
+ if (amount == null || isNaN(amount)) return "";
352
+ if (!currency) return `N/A ${amount.toLocaleString()}`;
353
+ const code = currency.toUpperCase();
354
+ try {
355
+ return new Intl.NumberFormat(locale, {
356
+ style: "currency",
357
+ currency: code,
358
+ minimumFractionDigits: 0,
359
+ maximumFractionDigits: 2,
360
+ }).format(amount);
361
+ } catch {
362
+ return `${code} ${amount.toLocaleString()}`;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Get the currency symbol for a given currency code.
368
+ *
369
+ * REAL BUG FIXED (2026-09-20): a missing `currency` used to silently
370
+ * default to "USD" (same class of bug as formatPrice above) — required now,
371
+ * no default.
372
+ *
373
+ * @param currency - ISO 4217 currency code
374
+ * @param locale - Locale string (default: en-US)
375
+ * @returns Currency symbol (e.g., $, ₦, €)
376
+ */
377
+ export function getCurrencySymbol(currency: string, locale: string = "en-US"): string {
378
+ const formatter = new Intl.NumberFormat(locale, {
379
+ style: "currency",
380
+ currency: currency,
381
+ });
382
+ const parts = formatter.formatToParts(0);
383
+ const symbolPart = parts.find(part => part.type === "currency");
384
+ return symbolPart?.value || currency;
385
+ }
386
+
387
+ /**
388
+ * Format a price in compact notation (e.g., $1M, ₦250K).
389
+ *
390
+ * REAL BUG FIXED (2026-09-20): a missing `currency` used to silently
391
+ * default to "USD" (same class of bug as formatPrice above) — required now,
392
+ * no default; a missing value returns an explicit "N/A" marker.
393
+ *
394
+ * @param amount - The amount to format
395
+ * @param currency - ISO 4217 currency code (e.g., USD, NGN, EUR)
396
+ * @param locale - Locale string for formatting (default: en-US)
397
+ * @returns Formatted compact currency string
398
+ */
399
+ export function formatCompactPrice(
400
+ amount: number,
401
+ currency: string | null | undefined,
402
+ locale: string = "en-US",
403
+ ): string {
404
+ if (!currency) return `N/A ${formatNumber(amount, { style: "compact", decimals: 0 })}`;
405
+ const symbol = getCurrencySymbol(currency, locale);
406
+ const compactStr = formatNumber(amount, { style: "compact", decimals: 0 });
407
+ return `${symbol}${compactStr}`;
408
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Geolocation Helpers
3
+ *
4
+ * Helper functions for handling geolocation data including:
5
+ * - Map pin-drop coordinate handling
6
+ * - Plus Code input handling
7
+ * - Reverse geocoding integration via SDK logistics service
8
+ * - Applying an AddressAutocomplete suggestion onto ShippingLocation state
9
+ */
10
+
11
+ import type { SdkConfig } from "../config";
12
+ import { reverseGeocode, decodePlusCode } from "../services/logistics.service";
13
+ import type { CoordinateSource, ShippingLocation } from "../types";
14
+ import type { AddressSuggestion } from "../components/shared/AddressAutocomplete";
15
+ import { logger } from "./logger";
16
+
17
+ type AddressUpdater = (updater: ShippingLocation | ((prev: ShippingLocation) => ShippingLocation)) => void;
18
+
19
+ export async function handlePinDrop(
20
+ coords: { latitude: number; longitude: number },
21
+ setAddress: AddressUpdater,
22
+ setIsReverseGeocoding?: (loading: boolean) => void,
23
+ config?: SdkConfig
24
+ ): Promise<void> {
25
+ setAddress((prev: ShippingLocation) => ({
26
+ ...prev,
27
+ latitude: coords.latitude,
28
+ longitude: coords.longitude,
29
+ coordinate_source: "pin_drop" as CoordinateSource,
30
+ }));
31
+
32
+ if (setIsReverseGeocoding) {
33
+ setIsReverseGeocoding(true);
34
+ try {
35
+ if (config) {
36
+ const data = await reverseGeocode(config, {
37
+ latitude: coords.latitude,
38
+ longitude: coords.longitude,
39
+ });
40
+
41
+ setAddress((prev: ShippingLocation) => ({
42
+ ...prev,
43
+ city: data.city || prev.city,
44
+ state: data.state || prev.state,
45
+ country: data.country_code || prev.country,
46
+ }));
47
+ } else {
48
+ logger.warn("[GeolocationHelper] SdkConfig is required to perform reverse geocoding.");
49
+ }
50
+ } catch (error) {
51
+ logger.error("[GeolocationHelper] Reverse geocoding failed:", error);
52
+ } finally {
53
+ setIsReverseGeocoding(false);
54
+ }
55
+ }
56
+ }
57
+
58
+ export async function handlePlusCodeChange(
59
+ plusCode: string,
60
+ setAddress: AddressUpdater,
61
+ config?: SdkConfig
62
+ ): Promise<void> {
63
+ setAddress((prev: ShippingLocation) => ({
64
+ ...prev,
65
+ plus_code: plusCode,
66
+ }));
67
+
68
+ if (plusCode) {
69
+ setAddress((prev: ShippingLocation) => ({
70
+ ...prev,
71
+ plus_code: plusCode,
72
+ coordinate_source: "plus_code" as CoordinateSource,
73
+ }));
74
+
75
+ try {
76
+ if (config) {
77
+ const data = await decodePlusCode(config, { plus_code: plusCode });
78
+ setAddress((prev: ShippingLocation) => ({
79
+ ...prev,
80
+ latitude: data.latitude,
81
+ longitude: data.longitude,
82
+ }));
83
+ } else {
84
+ logger.warn("[GeolocationHelper] SdkConfig is required to decode Plus Code.");
85
+ }
86
+ } catch (error) {
87
+ logger.error("[GeolocationHelper] Plus Code decoding failed:", error);
88
+ }
89
+ } else {
90
+ setAddress((prev: ShippingLocation) => ({
91
+ ...prev,
92
+ plus_code: "",
93
+ coordinate_source: undefined,
94
+ }));
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Applies a picked AddressAutocomplete suggestion onto ShippingLocation
100
+ * state in one place, so a caller wiring the shared component doesn't have
101
+ * to hand-map its fields itself. Only overwrites a field the suggestion
102
+ * actually returned a value for - never blanks out something the customer
103
+ * had already filled in manually that this particular result happens to
104
+ * lack (e.g. a suggestion with no postal_code shouldn't erase one already
105
+ * typed). `coordinate_source` is set to "manual" (the closest of the
106
+ * backend's 3 fixed values - `pin_drop`/`plus_code`/`manual`, strictly
107
+ * validated server-side, see kasuvia-store-management's
108
+ * schemas/logistics.py) only when the suggestion actually carried real
109
+ * coordinates - an autocomplete pick is a distinct real source from either
110
+ * of the two interactive geolocation tools, and there is no 4th backend
111
+ * value for it.
112
+ */
113
+ export function applyAddressSuggestion(
114
+ suggestion: AddressSuggestion,
115
+ setAddress: AddressUpdater
116
+ ): void {
117
+ setAddress((prev: ShippingLocation) => ({
118
+ ...prev,
119
+ street: suggestion.line1 || prev.street,
120
+ city: suggestion.city || prev.city,
121
+ state: suggestion.state || prev.state,
122
+ postal: suggestion.postal_code || prev.postal,
123
+ country: suggestion.country || prev.country,
124
+ latitude: suggestion.latitude ?? prev.latitude,
125
+ longitude: suggestion.longitude ?? prev.longitude,
126
+ coordinate_source:
127
+ suggestion.latitude != null && suggestion.longitude != null
128
+ ? ("manual" as CoordinateSource)
129
+ : prev.coordinate_source,
130
+ }));
131
+ }
132
+
133
+
134
+
@@ -0,0 +1,47 @@
1
+ import { GraphQLClient } from 'graphql-request'
2
+ import type { SdkConfig } from '../config'
3
+
4
+ /**
5
+ * REAL BUG FIXED (2026-09-21), live-reproduced in the browser console:
6
+ * `graphql-request`'s `GraphQLClient` constructs a `new URL(endpoint)`
7
+ * internally when issuing a request. Client-side SDK config deliberately
8
+ * uses a same-origin relative path (`/api/proxy/store`) so the real backend
9
+ * host is never exposed to the browser — but the native `URL` constructor
10
+ * throws `TypeError: Failed to construct 'URL': Invalid URL` for a relative
11
+ * string with no base, which is exactly what broke every client-side
12
+ * GraphQL call (products, collections, wishlist): `Failed to fetch
13
+ * products`/`Failed to fetch collections` in ProductsListing.tsx.
14
+ *
15
+ * Server-side config already uses an absolute real-backend URL, so this is
16
+ * a no-op there — only the browser case (a relative path, `window` defined)
17
+ * needs the current origin prepended.
18
+ */
19
+ export function resolveGraphQLEndpoint(url: string): string {
20
+ if (typeof window === 'undefined') return url
21
+ try {
22
+ // Absolute already (real backend URL, e.g. in a server-rendered client bundle edge case) — leave as-is.
23
+ new URL(url)
24
+ return url
25
+ } catch {
26
+ return `${window.location.origin}${url}`
27
+ }
28
+ }
29
+
30
+ /**
31
+ * REAL DUPLICATION FIXED (2026-09-22): this exact function (same headers,
32
+ * same endpoint resolution) was hand-copied identically into
33
+ * product.service.ts, collection.service.ts, and wishlist.service.ts — the
34
+ * SDK's three GraphQL-backed services. One shared implementation now.
35
+ */
36
+ export function createGraphQLClient(config: SdkConfig, accessToken?: string): GraphQLClient {
37
+ const headers: Record<string, string> = {
38
+ 'Content-Type': 'application/json',
39
+ 'Accept': 'application/json',
40
+ 'X-Storefront-Key': config.storefrontApiKey,
41
+ 'X-Business-ID': config.businessId,
42
+ }
43
+ if (accessToken) {
44
+ headers['Authorization'] = `Bearer ${accessToken}`
45
+ }
46
+ return new GraphQLClient(resolveGraphQLEndpoint(`${config.backendUrls.store}/graphql`), { headers })
47
+ }