@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.
- package/README.md +42 -15
- package/dist/AddressAutocomplete-CjcqPElD.d.mts +46 -0
- package/dist/AddressAutocomplete-CvJHuRkf.d.ts +46 -0
- package/dist/components/index.d.mts +497 -0
- package/dist/components/index.d.ts +497 -0
- package/dist/components/index.js +4217 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/index.mjs +4194 -0
- package/dist/components/index.mjs.map +1 -0
- package/dist/config/server.d.mts +26 -0
- package/dist/config/server.d.ts +26 -0
- package/dist/config/server.js +38 -0
- package/dist/config/server.js.map +1 -0
- package/dist/config/server.mjs +13 -0
- package/dist/config/server.mjs.map +1 -0
- package/dist/hooks/index.d.mts +393 -160
- package/dist/hooks/index.d.ts +393 -160
- package/dist/hooks/index.js +2792 -2137
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/index.mjs +2734 -2121
- package/dist/hooks/index.mjs.map +1 -1
- package/dist/index-CNW_LINy.d.mts +578 -0
- package/dist/index-CNW_LINy.d.ts +578 -0
- package/dist/{index-C1chUA3P.d.mts → index-F7N6UItS.d.mts} +1 -1
- package/dist/{index-C1chUA3P.d.ts → index-F7N6UItS.d.ts} +1 -1
- package/dist/index-NkI10J_E.d.mts +40 -0
- package/dist/index-NkI10J_E.d.ts +40 -0
- package/dist/index.d.mts +20437 -103
- package/dist/index.d.ts +20437 -103
- package/dist/index.js +2111 -1802
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2006 -1786
- package/dist/index.mjs.map +1 -1
- package/dist/media.service-Cx7dQjzX.d.mts +1393 -0
- package/dist/media.service-mrBFJ2UQ.d.ts +1393 -0
- package/dist/services/index.d.mts +908 -2
- package/dist/services/index.d.ts +908 -2
- package/dist/services/index.js +1492 -1686
- package/dist/services/index.js.map +1 -1
- package/dist/services/index.mjs +1431 -1670
- package/dist/services/index.mjs.map +1 -1
- package/package.json +46 -6
- package/src/components/bespoke/BespokeAvailabilityBadge.tsx +32 -0
- package/src/components/bespoke/BespokeProgressTracker.tsx +58 -0
- package/src/components/bespoke/index.ts +3 -0
- package/src/components/booking/SlotPicker.tsx +97 -0
- package/src/components/booking/index.ts +1 -0
- package/src/components/cart/CartItem.tsx +81 -0
- package/src/components/cart/CartSidebar.tsx +115 -0
- package/src/components/cart/CartTypeMismatchBanner.tsx +55 -0
- package/src/components/cart/index.ts +3 -0
- package/src/components/index.ts +8 -0
- package/src/components/orders/DeliverableReviewer.tsx +139 -0
- package/src/components/orders/index.ts +1 -0
- package/src/components/product/ProductCard.tsx +350 -0
- package/src/components/product/ProductGrid.tsx +84 -0
- package/src/components/product/index.ts +2 -0
- package/src/components/shared/AddressAutocomplete.tsx +212 -0
- package/src/components/shared/AnalyticsScripts.tsx +56 -0
- package/src/components/shared/CookieConsentBanner.tsx +98 -0
- package/src/components/shared/ImageEditorModal.tsx +1066 -0
- package/src/components/shared/ImageViewerModal.tsx +598 -0
- package/src/components/shared/LucideReactIcon.tsx +52 -0
- package/src/components/shared/MapPinDrop.tsx +101 -0
- package/src/components/shared/MultiSelectPicker.tsx +193 -0
- package/src/components/shared/OfflineBanner.tsx +46 -0
- package/src/components/shared/SearchableInput.tsx +153 -0
- package/src/components/shared/SearchablePopover.tsx +166 -0
- package/src/components/shared/SmartImage.tsx +311 -0
- package/src/components/shared/TruncatedValue.tsx +27 -0
- package/src/components/shared/UnsavedChangesGuard.tsx +138 -0
- package/src/components/shared/index.ts +15 -0
- package/src/components/skeletons/CardSkeleton.tsx +26 -0
- package/src/components/skeletons/CartItemSkeleton.tsx +26 -0
- package/src/components/skeletons/OrderItemSkeleton.tsx +26 -0
- package/src/components/skeletons/ProductCardSkeleton.tsx +26 -0
- package/src/components/skeletons/TableSkeleton.tsx +42 -0
- package/src/components/skeletons/index.ts +5 -0
- package/src/config/index.ts +22 -4
- package/src/config/server.ts +45 -0
- package/src/context/sdk-context.tsx +5 -2
- package/src/generated/auth-openapi.ts +2935 -0
- package/src/generated/cart-openapi.ts +652 -0
- package/src/generated/cms-openapi.ts +1668 -0
- package/src/generated/crm-openapi.ts +986 -0
- package/src/generated/graphql.ts +126 -0
- package/src/generated/index.ts +11 -0
- package/src/generated/media-openapi.ts +613 -0
- package/src/generated/notification-openapi.ts +862 -0
- package/src/generated/payment-openapi.ts +2713 -0
- package/src/generated/store-openapi.ts +9510 -0
- package/src/graphql/operations/collections.graphql +32 -0
- package/src/graphql/operations/products.graphql +197 -0
- package/src/graphql/operations/wishlist.graphql +25 -0
- package/src/hooks/index.ts +9 -2
- package/src/hooks/use-auth.ts +163 -69
- package/src/hooks/use-bespoke-availability.ts +39 -22
- package/src/hooks/use-business-config.ts +51 -0
- package/src/hooks/use-cart.ts +310 -238
- package/src/hooks/use-cms.ts +107 -127
- package/src/hooks/use-collections.ts +35 -55
- package/src/hooks/use-consultations.ts +9 -136
- package/src/hooks/use-countries.ts +19 -28
- package/src/hooks/use-currencies.ts +8 -20
- package/src/hooks/use-customer-address.ts +64 -0
- package/src/hooks/use-feedback.ts +34 -0
- package/src/hooks/use-languages.ts +26 -39
- package/src/hooks/use-media-upload.ts +23 -0
- package/src/hooks/use-notification-stream.ts +88 -0
- package/src/hooks/use-notifications.ts +163 -0
- package/src/hooks/use-order-stream.ts +29 -29
- package/src/hooks/use-orders.ts +91 -73
- package/src/hooks/use-payment.ts +116 -84
- package/src/hooks/use-products.ts +65 -269
- package/src/hooks/use-push-notifications.ts +122 -0
- package/src/hooks/use-reviews.ts +175 -0
- package/src/hooks/use-service-booking.ts +109 -132
- package/src/hooks/use-states.ts +16 -30
- package/src/hooks/use-wishlist.ts +44 -69
- package/src/index.ts +15 -11
- package/src/services/auth.service.ts +422 -132
- package/src/services/bespoke.service.ts +69 -10
- package/src/services/cart.service.ts +155 -186
- package/src/services/cms.service.ts +41 -50
- package/src/services/collection.service.ts +25 -63
- package/src/services/config.service.ts +10 -21
- package/src/services/consultation.service.ts +36 -93
- package/src/services/feedback.service.ts +76 -0
- package/src/services/index.ts +7 -1
- package/src/services/legal.service.ts +62 -0
- package/src/services/logistics.service.ts +235 -53
- package/src/services/media.service.ts +115 -0
- package/src/services/notifications.service.ts +204 -20
- package/src/services/order.service.ts +498 -127
- package/src/services/payment.service.ts +149 -100
- package/src/services/product.service.ts +157 -814
- package/src/services/review.service.ts +211 -0
- package/src/services/service-booking.service.ts +198 -99
- package/src/services/wishlist.service.ts +27 -74
- package/src/types/auth/index.ts +161 -18
- package/src/types/business/index.ts +9 -1
- package/src/types/cms/index.ts +91 -6
- package/src/types/cms/legal.ts +18 -0
- package/src/types/consultations/index.ts +60 -68
- package/src/types/index.ts +5 -3
- package/src/types/media/index.ts +35 -3
- package/src/types/payments/index.ts +80 -16
- package/src/types/reviews/index.ts +116 -0
- package/src/types/services/index.ts +113 -38
- package/src/types/store/checkout.ts +53 -0
- package/src/types/store/index.ts +273 -52
- package/src/types/store/invoice.ts +31 -0
- package/src/types/store/shipping.ts +84 -3
- package/src/types/ui/index.ts +1 -1
- package/src/utils/checkout.ts +19 -3
- package/src/utils/cookieConsent.ts +34 -0
- package/src/utils/format.ts +408 -0
- package/src/utils/geolocation.ts +134 -0
- package/src/utils/graphql-url.ts +47 -0
- package/src/utils/guest-session.ts +59 -0
- package/src/utils/http-client.ts +147 -0
- package/src/utils/index.ts +11 -4
- package/src/utils/status-colors.ts +79 -0
- package/src/utils/token-refresh.ts +115 -0
- package/src/utils/validators.ts +13 -0
- package/src/utils/youtube.ts +18 -0
- package/dist/index-C5YO9gTl.d.mts +0 -507
- package/dist/index-C5YO9gTl.d.ts +0 -507
- package/dist/index-CefG7_xq.d.mts +0 -793
- package/dist/index-DeHCf3zI.d.ts +0 -793
- package/src/graphql/collections.graphql.ts +0 -48
- package/src/graphql/index.ts +0 -7
- package/src/graphql/products.graphql.ts +0 -860
- package/src/graphql/wishlist.graphql.ts +0 -34
- package/src/hooks/use-consultation-stream.ts +0 -142
- package/src/types/bespoke/index.ts +0 -39
package/src/utils/checkout.ts
CHANGED
|
@@ -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
|
-
|
|
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?:
|
|
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 ===
|
|
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
|
+
}
|