@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
|
@@ -0,0 +1,4194 @@
|
|
|
1
|
+
// src/components/skeletons/TableSkeleton.tsx
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
function TableSkeleton({
|
|
4
|
+
rows = 5,
|
|
5
|
+
columns = 4,
|
|
6
|
+
className = "w-full border-collapse",
|
|
7
|
+
rowClassName = "border-b"
|
|
8
|
+
}) {
|
|
9
|
+
return /* @__PURE__ */ jsx("div", { className: "w-full overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className, children: [
|
|
10
|
+
/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", { className: rowClassName, children: Array.from({ length: columns }).map((_, c) => /* @__PURE__ */ jsx("th", { className: "p-3 text-left", children: /* @__PURE__ */ jsx("div", { className: "h-4 bg-muted rounded animate-pulse w-20" }) }, c)) }) }),
|
|
11
|
+
/* @__PURE__ */ jsx("tbody", { children: Array.from({ length: rows }).map((_, r) => /* @__PURE__ */ jsx("tr", { className: rowClassName, children: Array.from({ length: columns }).map((_2, c) => /* @__PURE__ */ jsx("td", { className: "p-3", children: /* @__PURE__ */ jsx("div", { className: "h-4 bg-muted rounded animate-pulse w-full max-w-[120px]" }) }, c)) }, r)) })
|
|
12
|
+
] }) });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/components/skeletons/CardSkeleton.tsx
|
|
16
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
17
|
+
function CardSkeleton({
|
|
18
|
+
count = 4,
|
|
19
|
+
containerClassName = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6",
|
|
20
|
+
cardClassName = "rounded-xl border p-4 space-y-3 animate-pulse bg-card"
|
|
21
|
+
}) {
|
|
22
|
+
return /* @__PURE__ */ jsx2("div", { className: containerClassName, children: Array.from({ length: count }).map((_, i) => /* @__PURE__ */ jsxs2("div", { className: cardClassName, children: [
|
|
23
|
+
/* @__PURE__ */ jsx2("div", { className: "w-full aspect-square bg-muted rounded-lg" }),
|
|
24
|
+
/* @__PURE__ */ jsx2("div", { className: "h-4 bg-muted rounded w-3/4" }),
|
|
25
|
+
/* @__PURE__ */ jsx2("div", { className: "h-4 bg-muted rounded w-1/2" }),
|
|
26
|
+
/* @__PURE__ */ jsx2("div", { className: "h-8 bg-muted rounded w-full mt-2" })
|
|
27
|
+
] }, i)) });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/components/skeletons/CartItemSkeleton.tsx
|
|
31
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
32
|
+
function CartItemSkeleton({
|
|
33
|
+
count = 3,
|
|
34
|
+
className = "space-y-4"
|
|
35
|
+
}) {
|
|
36
|
+
return /* @__PURE__ */ jsx3("div", { className, children: Array.from({ length: count }).map((_, i) => /* @__PURE__ */ jsxs3("div", { className: "flex items-center gap-4 p-3 border-b animate-pulse", children: [
|
|
37
|
+
/* @__PURE__ */ jsx3("div", { className: "w-16 h-16 bg-muted rounded-md shrink-0" }),
|
|
38
|
+
/* @__PURE__ */ jsxs3("div", { className: "flex-1 space-y-2", children: [
|
|
39
|
+
/* @__PURE__ */ jsx3("div", { className: "h-4 bg-muted rounded w-2/3" }),
|
|
40
|
+
/* @__PURE__ */ jsx3("div", { className: "h-3 bg-muted rounded w-1/3" })
|
|
41
|
+
] }),
|
|
42
|
+
/* @__PURE__ */ jsx3("div", { className: "h-6 w-16 bg-muted rounded shrink-0" })
|
|
43
|
+
] }, i)) });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/components/skeletons/OrderItemSkeleton.tsx
|
|
47
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
48
|
+
function OrderItemSkeleton({
|
|
49
|
+
count = 3,
|
|
50
|
+
className = "space-y-4"
|
|
51
|
+
}) {
|
|
52
|
+
return /* @__PURE__ */ jsx4("div", { className, children: Array.from({ length: count }).map((_, i) => /* @__PURE__ */ jsxs4("div", { className: "p-4 border rounded-lg animate-pulse space-y-3", children: [
|
|
53
|
+
/* @__PURE__ */ jsxs4("div", { className: "flex justify-between items-center", children: [
|
|
54
|
+
/* @__PURE__ */ jsx4("div", { className: "h-4 bg-muted rounded w-1/3" }),
|
|
55
|
+
/* @__PURE__ */ jsx4("div", { className: "h-5 w-20 bg-muted rounded-full" })
|
|
56
|
+
] }),
|
|
57
|
+
/* @__PURE__ */ jsx4("div", { className: "h-3 bg-muted rounded w-1/4" }),
|
|
58
|
+
/* @__PURE__ */ jsx4("div", { className: "h-4 bg-muted rounded w-1/2" })
|
|
59
|
+
] }, i)) });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/components/skeletons/ProductCardSkeleton.tsx
|
|
63
|
+
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
64
|
+
function ProductCardSkeleton({
|
|
65
|
+
className = "flex flex-col h-full rounded-xl border bg-card overflow-hidden shadow-sm animate-pulse",
|
|
66
|
+
imageClassName = "w-full aspect-square bg-muted"
|
|
67
|
+
}) {
|
|
68
|
+
return /* @__PURE__ */ jsxs5("div", { className, children: [
|
|
69
|
+
/* @__PURE__ */ jsx5("div", { className: imageClassName }),
|
|
70
|
+
/* @__PURE__ */ jsxs5("div", { className: "p-4 flex flex-col flex-1 space-y-3", children: [
|
|
71
|
+
/* @__PURE__ */ jsx5("div", { className: "h-5 bg-muted rounded w-3/4" }),
|
|
72
|
+
/* @__PURE__ */ jsx5("div", { className: "h-3 bg-muted rounded w-full" }),
|
|
73
|
+
/* @__PURE__ */ jsx5("div", { className: "h-3 bg-muted rounded w-2/3" }),
|
|
74
|
+
/* @__PURE__ */ jsx5("div", { className: "flex items-center justify-between pt-2 mt-auto", children: /* @__PURE__ */ jsx5("div", { className: "h-6 bg-muted rounded w-20" }) }),
|
|
75
|
+
/* @__PURE__ */ jsx5("div", { className: "w-full h-10 bg-muted rounded-lg mt-auto" })
|
|
76
|
+
] })
|
|
77
|
+
] });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/components/cart/CartItem.tsx
|
|
81
|
+
import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
82
|
+
function CartItem({
|
|
83
|
+
item,
|
|
84
|
+
onUpdateQty,
|
|
85
|
+
onRemove,
|
|
86
|
+
formatPriceFn,
|
|
87
|
+
className = "flex items-center gap-4 py-3 border-b border-gray-100 dark:border-gray-800",
|
|
88
|
+
imageClassName = "w-16 h-16 object-cover rounded-md bg-gray-100 dark:bg-gray-800 shrink-0",
|
|
89
|
+
titleClassName = "text-sm font-medium text-gray-900 dark:text-gray-100 line-clamp-1",
|
|
90
|
+
priceClassName = "text-sm font-semibold text-gray-900 dark:text-gray-100",
|
|
91
|
+
quantityGroupClassName = "flex items-center border rounded-md border-gray-200 dark:border-gray-700",
|
|
92
|
+
buttonClassName = "px-2 py-1 text-xs hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50",
|
|
93
|
+
removeButtonClassName = "text-xs text-red-600 hover:text-red-700 dark:text-red-400 transition-colors ml-auto"
|
|
94
|
+
}) {
|
|
95
|
+
return /* @__PURE__ */ jsxs6("div", { className, children: [
|
|
96
|
+
item.image_url ? /* @__PURE__ */ jsx6("img", { src: item.image_url, alt: item.product_name, className: imageClassName }) : /* @__PURE__ */ jsx6("div", { className: imageClassName }),
|
|
97
|
+
/* @__PURE__ */ jsxs6("div", { className: "flex-1 min-w-0 space-y-1", children: [
|
|
98
|
+
/* @__PURE__ */ jsx6("h4", { className: titleClassName, children: item.product_name }),
|
|
99
|
+
item.variant_attributes && /* @__PURE__ */ jsx6("p", { className: "text-xs text-gray-500 dark:text-gray-400", children: typeof item.variant_attributes === "string" ? item.variant_attributes : Object.entries(item.variant_attributes).map(([k, v]) => `${k}: ${v}`).join(", ") }),
|
|
100
|
+
/* @__PURE__ */ jsx6("div", { className: priceClassName, children: formatPriceFn ? formatPriceFn(item.unit_price * item.quantity) : `${item.currency || ""}${item.unit_price * item.quantity}` })
|
|
101
|
+
] }),
|
|
102
|
+
/* @__PURE__ */ jsxs6("div", { className: "flex flex-col items-end gap-2 shrink-0", children: [
|
|
103
|
+
/* @__PURE__ */ jsxs6("div", { className: quantityGroupClassName, children: [
|
|
104
|
+
/* @__PURE__ */ jsx6(
|
|
105
|
+
"button",
|
|
106
|
+
{
|
|
107
|
+
type: "button",
|
|
108
|
+
onClick: () => onUpdateQty(item.id, Math.max(1, item.quantity - 1)),
|
|
109
|
+
disabled: item.quantity <= 1,
|
|
110
|
+
className: buttonClassName,
|
|
111
|
+
children: "-"
|
|
112
|
+
}
|
|
113
|
+
),
|
|
114
|
+
/* @__PURE__ */ jsx6("span", { className: "px-2 text-xs font-medium", children: item.quantity }),
|
|
115
|
+
/* @__PURE__ */ jsx6(
|
|
116
|
+
"button",
|
|
117
|
+
{
|
|
118
|
+
type: "button",
|
|
119
|
+
onClick: () => onUpdateQty(item.id, item.quantity + 1),
|
|
120
|
+
className: buttonClassName,
|
|
121
|
+
children: "+"
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
] }),
|
|
125
|
+
/* @__PURE__ */ jsx6(
|
|
126
|
+
"button",
|
|
127
|
+
{
|
|
128
|
+
type: "button",
|
|
129
|
+
onClick: () => onRemove(item.id),
|
|
130
|
+
className: removeButtonClassName,
|
|
131
|
+
children: "Remove"
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
] })
|
|
135
|
+
] });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/hooks/use-cart.ts
|
|
139
|
+
import React2, { useContext as useContext2, useState } from "react";
|
|
140
|
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
141
|
+
|
|
142
|
+
// src/utils/token-refresh.ts
|
|
143
|
+
var GLOBAL_KEY = "__kasuviaSdkTokenRefreshRegistry__";
|
|
144
|
+
function getRegistry() {
|
|
145
|
+
const g = globalThis;
|
|
146
|
+
if (!g[GLOBAL_KEY]) {
|
|
147
|
+
g[GLOBAL_KEY] = { handler: null, inFlight: null };
|
|
148
|
+
}
|
|
149
|
+
return g[GLOBAL_KEY];
|
|
150
|
+
}
|
|
151
|
+
async function refreshAccessToken() {
|
|
152
|
+
const registry = getRegistry();
|
|
153
|
+
if (!registry.handler) return null;
|
|
154
|
+
if (registry.inFlight) return registry.inFlight;
|
|
155
|
+
registry.inFlight = registry.handler().finally(() => {
|
|
156
|
+
registry.inFlight = null;
|
|
157
|
+
});
|
|
158
|
+
return registry.inFlight;
|
|
159
|
+
}
|
|
160
|
+
function hasRefreshHandler() {
|
|
161
|
+
return getRegistry().handler !== null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/utils/http-client.ts
|
|
165
|
+
var ApiError = class extends Error {
|
|
166
|
+
constructor(message, status, body) {
|
|
167
|
+
super(message);
|
|
168
|
+
this.name = "ApiError";
|
|
169
|
+
this.status = status;
|
|
170
|
+
this.body = body;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
function extractApiErrorMessage(body, fallback) {
|
|
174
|
+
if (typeof body !== "object" || body === null) return fallback;
|
|
175
|
+
const record = body;
|
|
176
|
+
if (typeof record.detail === "string") return record.detail;
|
|
177
|
+
if (Array.isArray(record.detail) && record.detail.length > 0) {
|
|
178
|
+
const first = record.detail[0];
|
|
179
|
+
if (first && typeof first.msg === "string") {
|
|
180
|
+
const loc = first.loc;
|
|
181
|
+
const field = Array.isArray(loc) ? loc[loc.length - 1] : void 0;
|
|
182
|
+
return typeof field === "string" || typeof field === "number" ? `${field}: ${first.msg}` : first.msg;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const firstStringOrArrayItem = (value) => {
|
|
186
|
+
if (typeof value === "string") return value;
|
|
187
|
+
if (Array.isArray(value) && typeof value[0] === "string") return value[0];
|
|
188
|
+
return void 0;
|
|
189
|
+
};
|
|
190
|
+
const nonFieldMessage = firstStringOrArrayItem(record.non_field_errors);
|
|
191
|
+
if (nonFieldMessage) return nonFieldMessage;
|
|
192
|
+
for (const [key, value] of Object.entries(record)) {
|
|
193
|
+
if (key === "detail" || key === "non_field_errors") continue;
|
|
194
|
+
const message = firstStringOrArrayItem(value);
|
|
195
|
+
if (message) return message;
|
|
196
|
+
}
|
|
197
|
+
return fallback;
|
|
198
|
+
}
|
|
199
|
+
function storefrontHeaders(config) {
|
|
200
|
+
return {
|
|
201
|
+
"X-Storefront-Key": config.storefrontApiKey,
|
|
202
|
+
"X-Business-ID": config.businessId
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function authHeaders(config, accessToken) {
|
|
206
|
+
return {
|
|
207
|
+
...storefrontHeaders(config),
|
|
208
|
+
Authorization: `Bearer ${accessToken}`
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
var JSON_HEADERS = { "Content-Type": "application/json" };
|
|
212
|
+
async function apiRequest(url, options = {}) {
|
|
213
|
+
const { fallbackErrorMessage = "Request failed", ...init } = options;
|
|
214
|
+
const headers = new Headers(init.headers);
|
|
215
|
+
const hadBearerToken = headers.get("Authorization")?.startsWith("Bearer ") ?? false;
|
|
216
|
+
const response = await fetch(url, { ...init, headers });
|
|
217
|
+
if (response.status === 401 && hadBearerToken && hasRefreshHandler()) {
|
|
218
|
+
const freshToken = await refreshAccessToken();
|
|
219
|
+
if (freshToken) {
|
|
220
|
+
const retryHeaders = new Headers(headers);
|
|
221
|
+
retryHeaders.set("Authorization", `Bearer ${freshToken}`);
|
|
222
|
+
const retryResponse = await fetch(url, { ...init, headers: retryHeaders });
|
|
223
|
+
if (retryResponse.ok) {
|
|
224
|
+
const retryText = await retryResponse.text();
|
|
225
|
+
return retryText ? JSON.parse(retryText) : void 0;
|
|
226
|
+
}
|
|
227
|
+
const retryBody = await retryResponse.json().catch(() => ({}));
|
|
228
|
+
throw new ApiError(extractApiErrorMessage(retryBody, fallbackErrorMessage), retryResponse.status, retryBody);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (!response.ok) {
|
|
232
|
+
const body = await response.json().catch(() => ({}));
|
|
233
|
+
throw new ApiError(extractApiErrorMessage(body, fallbackErrorMessage), response.status, body);
|
|
234
|
+
}
|
|
235
|
+
const text = await response.text();
|
|
236
|
+
return text ? JSON.parse(text) : void 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/services/cart.service.ts
|
|
240
|
+
function parseItems(items) {
|
|
241
|
+
return items.map((item) => ({
|
|
242
|
+
...item,
|
|
243
|
+
variant_attributes: item.variant_attributes && typeof item.variant_attributes === "string" ? JSON.parse(item.variant_attributes) : item.variant_attributes
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
function guestHeaders(config, guestSessionId, extra) {
|
|
247
|
+
return { ...extra, ...storefrontHeaders(config), "X-Guest-Session": guestSessionId };
|
|
248
|
+
}
|
|
249
|
+
async function getGuestCart(config, guestSessionId) {
|
|
250
|
+
if (!guestSessionId) throw new Error("Guest session ID is required to fetch guest cart");
|
|
251
|
+
const data = await apiRequest(`${config.backendUrls.cart}/cart/`, {
|
|
252
|
+
headers: guestHeaders(config, guestSessionId),
|
|
253
|
+
fallbackErrorMessage: "Failed to fetch guest cart"
|
|
254
|
+
});
|
|
255
|
+
return { ...data, items: parseItems(data.items || []) };
|
|
256
|
+
}
|
|
257
|
+
async function addToGuestCart(config, guestSessionId, item) {
|
|
258
|
+
if (!guestSessionId) throw new Error("Guest session ID is required to add to guest cart");
|
|
259
|
+
const payload = {
|
|
260
|
+
product_id: item.product_id,
|
|
261
|
+
variant_id: item.variant_id ?? void 0,
|
|
262
|
+
quantity: item.quantity,
|
|
263
|
+
custom_measurements: item.custom_measurements ?? void 0
|
|
264
|
+
};
|
|
265
|
+
const data = await apiRequest(`${config.backendUrls.cart}/cart/`, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: guestHeaders(config, guestSessionId, JSON_HEADERS),
|
|
268
|
+
body: JSON.stringify(payload),
|
|
269
|
+
fallbackErrorMessage: "Failed to add to guest cart"
|
|
270
|
+
});
|
|
271
|
+
return { ...data, items: parseItems(data.items || []) };
|
|
272
|
+
}
|
|
273
|
+
async function updateGuestCartItemQuantity(config, guestSessionId, itemId, quantity) {
|
|
274
|
+
if (!guestSessionId) throw new Error("Guest session ID is required to update guest cart item");
|
|
275
|
+
const data = await apiRequest(`${config.backendUrls.cart}/cart/${itemId}`, {
|
|
276
|
+
method: "PATCH",
|
|
277
|
+
headers: guestHeaders(config, guestSessionId, JSON_HEADERS),
|
|
278
|
+
body: JSON.stringify({ quantity }),
|
|
279
|
+
fallbackErrorMessage: "Failed to update guest cart item"
|
|
280
|
+
});
|
|
281
|
+
return { ...data, items: parseItems(data.items || []) };
|
|
282
|
+
}
|
|
283
|
+
async function removeFromGuestCart(config, guestSessionId, itemId) {
|
|
284
|
+
if (!guestSessionId) throw new Error("Guest session ID is required to remove from guest cart");
|
|
285
|
+
await apiRequest(`${config.backendUrls.cart}/cart/${itemId}`, {
|
|
286
|
+
method: "DELETE",
|
|
287
|
+
headers: guestHeaders(config, guestSessionId),
|
|
288
|
+
fallbackErrorMessage: "Failed to remove item from guest cart"
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
async function clearGuestCart(config, guestSessionId) {
|
|
292
|
+
if (!guestSessionId) throw new Error("Guest session ID is required to clear guest cart");
|
|
293
|
+
await apiRequest(`${config.backendUrls.cart}/cart/clear`, {
|
|
294
|
+
method: "DELETE",
|
|
295
|
+
headers: guestHeaders(config, guestSessionId),
|
|
296
|
+
fallbackErrorMessage: "Failed to clear guest cart"
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
async function getCart(config, accessToken) {
|
|
300
|
+
if (!accessToken) {
|
|
301
|
+
throw new Error("Access token is required to fetch authenticated cart");
|
|
302
|
+
}
|
|
303
|
+
const data = await apiRequest(`${config.backendUrls.cart}/cart/`, {
|
|
304
|
+
headers: authHeaders(config, accessToken),
|
|
305
|
+
fallbackErrorMessage: "Failed to fetch cart"
|
|
306
|
+
});
|
|
307
|
+
return { ...data, items: parseItems(data.items || []) };
|
|
308
|
+
}
|
|
309
|
+
async function addToCart(config, accessToken, item) {
|
|
310
|
+
const payload = {
|
|
311
|
+
product_id: item.product_id,
|
|
312
|
+
variant_id: item.variant_id ?? void 0,
|
|
313
|
+
quantity: item.quantity,
|
|
314
|
+
custom_measurements: item.custom_measurements ?? void 0
|
|
315
|
+
};
|
|
316
|
+
const data = await apiRequest(`${config.backendUrls.cart}/cart/`, {
|
|
317
|
+
method: "POST",
|
|
318
|
+
headers: { ...JSON_HEADERS, ...authHeaders(config, accessToken) },
|
|
319
|
+
body: JSON.stringify(payload),
|
|
320
|
+
fallbackErrorMessage: "Failed to add to cart"
|
|
321
|
+
});
|
|
322
|
+
return { ...data, items: parseItems(data.items || []) };
|
|
323
|
+
}
|
|
324
|
+
async function updateCartItem(config, accessToken, itemId, quantity) {
|
|
325
|
+
const data = await apiRequest(`${config.backendUrls.cart}/cart/${itemId}`, {
|
|
326
|
+
method: "PATCH",
|
|
327
|
+
headers: { ...JSON_HEADERS, ...authHeaders(config, accessToken) },
|
|
328
|
+
body: JSON.stringify({ quantity }),
|
|
329
|
+
fallbackErrorMessage: "Failed to update cart item"
|
|
330
|
+
});
|
|
331
|
+
return { ...data, items: parseItems(data.items || []) };
|
|
332
|
+
}
|
|
333
|
+
async function removeFromCart(config, accessToken, itemId) {
|
|
334
|
+
await apiRequest(`${config.backendUrls.cart}/cart/${itemId}`, {
|
|
335
|
+
method: "DELETE",
|
|
336
|
+
headers: authHeaders(config, accessToken),
|
|
337
|
+
fallbackErrorMessage: "Failed to remove cart item"
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
async function clearCart(config, accessToken) {
|
|
341
|
+
await apiRequest(`${config.backendUrls.cart}/cart/clear`, {
|
|
342
|
+
method: "DELETE",
|
|
343
|
+
headers: authHeaders(config, accessToken),
|
|
344
|
+
fallbackErrorMessage: "Failed to clear cart"
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
async function mergeGuestCart(config, accessToken, guestSessionId, guestItems) {
|
|
348
|
+
const payloadItems = guestItems.map((item) => ({
|
|
349
|
+
product_id: item.product_id,
|
|
350
|
+
variant_id: item.variant_id ?? void 0,
|
|
351
|
+
quantity: item.quantity,
|
|
352
|
+
custom_measurements: item.custom_measurements ?? void 0
|
|
353
|
+
}));
|
|
354
|
+
const headers = { ...JSON_HEADERS, ...authHeaders(config, accessToken) };
|
|
355
|
+
if (guestSessionId) {
|
|
356
|
+
headers["X-Guest-Session"] = guestSessionId;
|
|
357
|
+
}
|
|
358
|
+
const data = await apiRequest(`${config.backendUrls.cart}/cart/merge`, {
|
|
359
|
+
method: "POST",
|
|
360
|
+
headers,
|
|
361
|
+
body: JSON.stringify({ items: payloadItems }),
|
|
362
|
+
fallbackErrorMessage: "Failed to merge guest cart"
|
|
363
|
+
});
|
|
364
|
+
return { ...data, items: parseItems(data.items || []) };
|
|
365
|
+
}
|
|
366
|
+
async function mergeGuestCartIfPresent(config, accessToken, guestSessionId) {
|
|
367
|
+
const guestCart = await getGuestCart(config, guestSessionId);
|
|
368
|
+
if (!guestCart.items.length) return;
|
|
369
|
+
await mergeGuestCart(config, accessToken, guestSessionId, guestCart.items);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/utils/logger.ts
|
|
373
|
+
var isDevelopment = true;
|
|
374
|
+
var logger = {
|
|
375
|
+
log: (...args) => {
|
|
376
|
+
if (isDevelopment) {
|
|
377
|
+
console.log(...args);
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
warn: (...args) => {
|
|
381
|
+
if (isDevelopment) {
|
|
382
|
+
console.warn(...args);
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
/**
|
|
386
|
+
* Logs a warning to the console in development only.
|
|
387
|
+
* Alias for warn() for Python logging compatibility.
|
|
388
|
+
* In production, this is a no-op.
|
|
389
|
+
*/
|
|
390
|
+
warning: (...args) => {
|
|
391
|
+
if (isDevelopment) {
|
|
392
|
+
console.warn(...args);
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
error: (...args) => {
|
|
396
|
+
if (isDevelopment) {
|
|
397
|
+
console.error(...args);
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
info: (...args) => {
|
|
401
|
+
if (isDevelopment) {
|
|
402
|
+
console.info(...args);
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
createScope: (scope) => ({
|
|
406
|
+
log: (...args) => {
|
|
407
|
+
if (isDevelopment) {
|
|
408
|
+
console.log(`[${scope}]`, ...args);
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
warn: (...args) => {
|
|
412
|
+
if (isDevelopment) {
|
|
413
|
+
console.warn(`[${scope}]`, ...args);
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
warning: (...args) => {
|
|
417
|
+
if (isDevelopment) {
|
|
418
|
+
console.warn(`[${scope}]`, ...args);
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
error: (...args) => {
|
|
422
|
+
if (isDevelopment) {
|
|
423
|
+
console.error(`[${scope}]`, ...args);
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
info: (...args) => {
|
|
427
|
+
if (isDevelopment) {
|
|
428
|
+
console.info(`[${scope}]`, ...args);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// src/context/sdk-context.tsx
|
|
435
|
+
import { createContext, useContext } from "react";
|
|
436
|
+
import { jsx as jsx7 } from "react/jsx-runtime";
|
|
437
|
+
var SdkContext = createContext(null);
|
|
438
|
+
function useSdkContext() {
|
|
439
|
+
return useContext(SdkContext);
|
|
440
|
+
}
|
|
441
|
+
function resolveHookArgs(explicitConfig, explicitToken, ctx) {
|
|
442
|
+
const config = explicitConfig ?? ctx?.config;
|
|
443
|
+
if (!config) {
|
|
444
|
+
throw new Error(
|
|
445
|
+
"[kasuvia-sdk] No SdkConfig found. Either pass config explicitly or wrap your app with <SdkProvider config={...}>."
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
const accessToken = explicitToken !== void 0 ? explicitToken : ctx?.accessToken ?? null;
|
|
449
|
+
return { config, accessToken };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/hooks/use-cart.ts
|
|
453
|
+
var CartCtx = React2.createContext ? React2.createContext(null) : null;
|
|
454
|
+
function useCart(config, accessToken, guestSessionId) {
|
|
455
|
+
const ctx = useSdkContext();
|
|
456
|
+
const resolved = resolveHookArgs(config, accessToken, ctx);
|
|
457
|
+
const resolvedConfig = resolved.config;
|
|
458
|
+
const resolvedToken = resolved.accessToken;
|
|
459
|
+
guestSessionId = guestSessionId !== void 0 ? guestSessionId : ctx?.guestSessionId ?? null;
|
|
460
|
+
const queryClient = useQueryClient();
|
|
461
|
+
const context = CartCtx ? useContext2(CartCtx) : null;
|
|
462
|
+
const isOpen = context ? context.isOpen : false;
|
|
463
|
+
const setOpen = context ? context.setOpen : () => {
|
|
464
|
+
};
|
|
465
|
+
const productTypeMismatch = context ? context.productTypeMismatch : null;
|
|
466
|
+
const setProductTypeMismatch = context ? context.setProductTypeMismatch : () => {
|
|
467
|
+
};
|
|
468
|
+
const cartQueryKey = ["cart", resolvedToken ?? guestSessionId ?? "anon"];
|
|
469
|
+
const cartQuery = useQuery({
|
|
470
|
+
queryKey: cartQueryKey,
|
|
471
|
+
queryFn: async () => {
|
|
472
|
+
if (resolvedToken) {
|
|
473
|
+
return getCart(resolvedConfig, resolvedToken);
|
|
474
|
+
}
|
|
475
|
+
if (guestSessionId) {
|
|
476
|
+
return getGuestCart(resolvedConfig, guestSessionId);
|
|
477
|
+
}
|
|
478
|
+
return { items: [], subtotal: 0, total: 0, item_count: 0, product_type: void 0 };
|
|
479
|
+
},
|
|
480
|
+
enabled: !!(resolvedToken || guestSessionId)
|
|
481
|
+
});
|
|
482
|
+
const addToCartMutation = useMutation({
|
|
483
|
+
mutationFn: async (item) => {
|
|
484
|
+
if (resolvedToken) {
|
|
485
|
+
return addToCart(resolvedConfig, resolvedToken, item);
|
|
486
|
+
}
|
|
487
|
+
if (guestSessionId) {
|
|
488
|
+
return addToGuestCart(resolvedConfig, guestSessionId, item);
|
|
489
|
+
}
|
|
490
|
+
throw new Error("Cannot add to cart: no access token or guest session ID provided.");
|
|
491
|
+
},
|
|
492
|
+
onSuccess: () => {
|
|
493
|
+
setProductTypeMismatch(null);
|
|
494
|
+
queryClient.invalidateQueries({ queryKey: ["cart"] });
|
|
495
|
+
},
|
|
496
|
+
// REAL BUG FIXED (2026-09-24), live-reported ("mixed products between
|
|
497
|
+
// physical to digital"): this read `error.status`/`error.data` - but
|
|
498
|
+
// apiRequest throws a real `ApiError` (utils/http-client.ts), whose
|
|
499
|
+
// fields are `.status`/`.body`/`.message`, not `.data` - `err.data` was
|
|
500
|
+
// always `undefined`, so this branch could never fire even when the
|
|
501
|
+
// backend genuinely sent a 409. Separately, kasuvia-cart-management's
|
|
502
|
+
// error body is a huma ErrorModel (`{detail: "plain string"}`), never a
|
|
503
|
+
// nested `{detail: {code, ...}}` object - this service's own
|
|
504
|
+
// TranslateServiceError doc comment already documents that convention
|
|
505
|
+
// (a bracketed "[CODE] ..." prefix in the message string, matching
|
|
506
|
+
// every other cart error). Parses that same convention here instead of
|
|
507
|
+
// a response shape the backend never produces.
|
|
508
|
+
onError: (error) => {
|
|
509
|
+
const err = error;
|
|
510
|
+
if (err.status !== 409 || !err.message?.startsWith("[CART_PRODUCT_TYPE_MISMATCH]")) return;
|
|
511
|
+
const match = err.message.match(/contains (\w+) items\. Add (\w+) items/i);
|
|
512
|
+
setProductTypeMismatch({
|
|
513
|
+
message: err.message.replace(/^\[CART_PRODUCT_TYPE_MISMATCH\]\s*/, ""),
|
|
514
|
+
existing_type: match?.[1] ?? "unknown",
|
|
515
|
+
new_type: match?.[2] ?? "unknown"
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
function recomputeCartTotals(previous, items2) {
|
|
520
|
+
const subtotal2 = items2.reduce((sum, i) => sum + i.unit_price * i.quantity, 0);
|
|
521
|
+
const item_count = items2.reduce((sum, i) => sum + i.quantity, 0);
|
|
522
|
+
return { ...previous, items: items2, subtotal: subtotal2, total: subtotal2, item_count };
|
|
523
|
+
}
|
|
524
|
+
const updateCartItemMutation = useMutation({
|
|
525
|
+
mutationFn: async ({ itemId, quantity }) => {
|
|
526
|
+
if (resolvedToken) {
|
|
527
|
+
return updateCartItem(resolvedConfig, resolvedToken, itemId, quantity);
|
|
528
|
+
}
|
|
529
|
+
if (guestSessionId) {
|
|
530
|
+
return updateGuestCartItemQuantity(resolvedConfig, guestSessionId, itemId, quantity);
|
|
531
|
+
}
|
|
532
|
+
throw new Error("Cannot update cart: no access token or guest session ID provided.");
|
|
533
|
+
},
|
|
534
|
+
onMutate: async ({ itemId, quantity }) => {
|
|
535
|
+
await queryClient.cancelQueries({ queryKey: cartQueryKey });
|
|
536
|
+
const previous = queryClient.getQueryData(cartQueryKey);
|
|
537
|
+
if (previous) {
|
|
538
|
+
const items2 = quantity > 0 ? previous.items.map((i) => i.id === itemId ? { ...i, quantity } : i) : previous.items.filter((i) => i.id !== itemId);
|
|
539
|
+
queryClient.setQueryData(cartQueryKey, recomputeCartTotals(previous, items2));
|
|
540
|
+
}
|
|
541
|
+
return { previous };
|
|
542
|
+
},
|
|
543
|
+
onError: (error, _variables, context2) => {
|
|
544
|
+
if (context2?.previous) {
|
|
545
|
+
queryClient.setQueryData(cartQueryKey, context2.previous);
|
|
546
|
+
}
|
|
547
|
+
logger.error("[useCart] Failed to update item quantity, rolled back:", error);
|
|
548
|
+
},
|
|
549
|
+
onSettled: () => {
|
|
550
|
+
queryClient.invalidateQueries({ queryKey: ["cart"] });
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
const removeFromCartMutation = useMutation({
|
|
554
|
+
mutationFn: async (itemId) => {
|
|
555
|
+
if (resolvedToken) {
|
|
556
|
+
return removeFromCart(resolvedConfig, resolvedToken, itemId);
|
|
557
|
+
}
|
|
558
|
+
if (guestSessionId) {
|
|
559
|
+
return removeFromGuestCart(resolvedConfig, guestSessionId, itemId);
|
|
560
|
+
}
|
|
561
|
+
throw new Error("Cannot remove from cart: no access token or guest session ID provided.");
|
|
562
|
+
},
|
|
563
|
+
onMutate: async (itemId) => {
|
|
564
|
+
await queryClient.cancelQueries({ queryKey: cartQueryKey });
|
|
565
|
+
const previous = queryClient.getQueryData(cartQueryKey);
|
|
566
|
+
if (previous) {
|
|
567
|
+
const items2 = previous.items.filter((i) => i.id !== itemId);
|
|
568
|
+
queryClient.setQueryData(cartQueryKey, recomputeCartTotals(previous, items2));
|
|
569
|
+
}
|
|
570
|
+
return { previous };
|
|
571
|
+
},
|
|
572
|
+
onError: (error, _itemId, context2) => {
|
|
573
|
+
if (context2?.previous) {
|
|
574
|
+
queryClient.setQueryData(cartQueryKey, context2.previous);
|
|
575
|
+
}
|
|
576
|
+
logger.error("[useCart] Failed to remove item, rolled back:", error);
|
|
577
|
+
},
|
|
578
|
+
onSettled: () => {
|
|
579
|
+
queryClient.invalidateQueries({ queryKey: ["cart"] });
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
const clearCartMutation = useMutation({
|
|
583
|
+
mutationFn: async () => {
|
|
584
|
+
if (resolvedToken) {
|
|
585
|
+
return clearCart(resolvedConfig, resolvedToken);
|
|
586
|
+
}
|
|
587
|
+
if (guestSessionId) {
|
|
588
|
+
return clearGuestCart(resolvedConfig, guestSessionId);
|
|
589
|
+
}
|
|
590
|
+
throw new Error("Cannot clear cart: no access token or guest session ID provided.");
|
|
591
|
+
},
|
|
592
|
+
onSuccess: () => {
|
|
593
|
+
queryClient.invalidateQueries({ queryKey: ["cart"] });
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
const mergeGuestCartMutation = useMutation({
|
|
597
|
+
mutationFn: (sessionId) => {
|
|
598
|
+
if (!resolvedToken) {
|
|
599
|
+
logger.warn("[useCart] mergeGuestCart called without an access token \u2014 skipping merge.");
|
|
600
|
+
return Promise.resolve();
|
|
601
|
+
}
|
|
602
|
+
return mergeGuestCartIfPresent(resolvedConfig, resolvedToken, sessionId);
|
|
603
|
+
},
|
|
604
|
+
onSuccess: () => {
|
|
605
|
+
queryClient.invalidateQueries({ queryKey: ["cart"] });
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
const addItem = (product, variantId, quantity = 1, customMeasurements) => {
|
|
609
|
+
return addToCartMutation.mutate({
|
|
610
|
+
product_id: product.id,
|
|
611
|
+
variant_id: variantId,
|
|
612
|
+
quantity,
|
|
613
|
+
custom_measurements: customMeasurements ? JSON.stringify(customMeasurements) : void 0,
|
|
614
|
+
// Below are accepted by the mutation signature for UI convenience but stripped before the fetch:
|
|
615
|
+
product_slug: product.slug || "",
|
|
616
|
+
product_name: product.name,
|
|
617
|
+
image_url: product.images[0] || void 0
|
|
618
|
+
});
|
|
619
|
+
};
|
|
620
|
+
const items = cartQuery.data?.items ?? [];
|
|
621
|
+
const subtotal = cartQuery.data?.subtotal ?? 0;
|
|
622
|
+
const count = items.reduce((total, item) => total + item.quantity, 0);
|
|
623
|
+
const updateQty = (itemId, quantity) => {
|
|
624
|
+
return updateCartItemMutation.mutateAsync({ itemId, quantity });
|
|
625
|
+
};
|
|
626
|
+
const removeItem = (itemId) => {
|
|
627
|
+
return removeFromCartMutation.mutateAsync(itemId);
|
|
628
|
+
};
|
|
629
|
+
const handleClearCartAndAdd = () => {
|
|
630
|
+
clearCartMutation.mutate();
|
|
631
|
+
setProductTypeMismatch(null);
|
|
632
|
+
};
|
|
633
|
+
const handleKeepCurrentCart = () => {
|
|
634
|
+
setProductTypeMismatch(null);
|
|
635
|
+
};
|
|
636
|
+
return {
|
|
637
|
+
cart: cartQuery.data,
|
|
638
|
+
items,
|
|
639
|
+
count,
|
|
640
|
+
isOpen,
|
|
641
|
+
setOpen,
|
|
642
|
+
isLoading: cartQuery.isLoading,
|
|
643
|
+
error: cartQuery.error,
|
|
644
|
+
addToCart: addToCartMutation.mutate,
|
|
645
|
+
updateCartItem: updateCartItemMutation.mutate,
|
|
646
|
+
removeFromCart: removeFromCartMutation.mutate,
|
|
647
|
+
clearCart: clearCartMutation.mutate,
|
|
648
|
+
clear: clearCartMutation.mutate,
|
|
649
|
+
mergeGuestCart: mergeGuestCartMutation.mutate,
|
|
650
|
+
addItem,
|
|
651
|
+
updateQty,
|
|
652
|
+
removeItem,
|
|
653
|
+
subtotal,
|
|
654
|
+
productTypeMismatch,
|
|
655
|
+
handleClearCartAndAdd,
|
|
656
|
+
handleKeepCurrentCart
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// src/components/cart/CartTypeMismatchBanner.tsx
|
|
661
|
+
import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
662
|
+
function CartTypeMismatchBanner({
|
|
663
|
+
mismatch,
|
|
664
|
+
onClearAndAdd,
|
|
665
|
+
onKeepCurrent,
|
|
666
|
+
className = "p-4 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded-lg space-y-3",
|
|
667
|
+
titleClassName = "text-sm font-semibold text-amber-800 dark:text-amber-200",
|
|
668
|
+
messageClassName = "text-xs text-amber-700 dark:text-amber-300",
|
|
669
|
+
buttonGroupClassName = "flex items-center gap-2 pt-1",
|
|
670
|
+
confirmButtonClassName = "px-3 py-1.5 text-xs font-medium bg-amber-600 hover:bg-amber-700 text-white rounded transition-colors",
|
|
671
|
+
cancelButtonClassName = "px-3 py-1.5 text-xs font-medium border border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200 rounded hover:bg-amber-100 dark:hover:bg-amber-900/50 transition-colors"
|
|
672
|
+
}) {
|
|
673
|
+
return /* @__PURE__ */ jsxs7("div", { className, children: [
|
|
674
|
+
/* @__PURE__ */ jsx8("div", { className: titleClassName, children: "Cart Product Type Conflict" }),
|
|
675
|
+
/* @__PURE__ */ jsx8("p", { className: messageClassName, children: mismatch.message || `Your cart currently contains ${mismatch.existing_type} items. You cannot mix ${mismatch.existing_type} and ${mismatch.new_type} items in the same order.` }),
|
|
676
|
+
/* @__PURE__ */ jsxs7("div", { className: buttonGroupClassName, children: [
|
|
677
|
+
/* @__PURE__ */ jsx8(
|
|
678
|
+
"button",
|
|
679
|
+
{
|
|
680
|
+
type: "button",
|
|
681
|
+
onClick: onClearAndAdd,
|
|
682
|
+
className: confirmButtonClassName,
|
|
683
|
+
children: "Clear Cart & Add New Item"
|
|
684
|
+
}
|
|
685
|
+
),
|
|
686
|
+
/* @__PURE__ */ jsx8(
|
|
687
|
+
"button",
|
|
688
|
+
{
|
|
689
|
+
type: "button",
|
|
690
|
+
onClick: onKeepCurrent,
|
|
691
|
+
className: cancelButtonClassName,
|
|
692
|
+
children: "Keep Current Cart"
|
|
693
|
+
}
|
|
694
|
+
)
|
|
695
|
+
] })
|
|
696
|
+
] });
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// src/utils/format.ts
|
|
700
|
+
var BYTE_THRESHOLDS = [
|
|
701
|
+
{ value: 1024 ** 5, label: "PB" },
|
|
702
|
+
{ value: 1024 ** 4, label: "TB" },
|
|
703
|
+
{ value: 1024 ** 3, label: "GB" },
|
|
704
|
+
{ value: 1024 ** 2, label: "MB" },
|
|
705
|
+
{ value: 1024 ** 1, label: "KB" }
|
|
706
|
+
];
|
|
707
|
+
function formatTimeString(time) {
|
|
708
|
+
const [hoursStr, minutesStr] = time.split(":");
|
|
709
|
+
const hours = parseInt(hoursStr, 10);
|
|
710
|
+
if (isNaN(hours) || minutesStr === void 0) return time;
|
|
711
|
+
const period = hours >= 12 ? "PM" : "AM";
|
|
712
|
+
const displayHours = hours % 12 || 12;
|
|
713
|
+
return `${displayHours}:${minutesStr} ${period}`;
|
|
714
|
+
}
|
|
715
|
+
function formatPrice(amount, currency, locale = "en-US") {
|
|
716
|
+
if (amount == null || isNaN(amount)) return "";
|
|
717
|
+
if (!currency) return `N/A ${amount.toLocaleString()}`;
|
|
718
|
+
const code = currency.toUpperCase();
|
|
719
|
+
try {
|
|
720
|
+
return new Intl.NumberFormat(locale, {
|
|
721
|
+
style: "currency",
|
|
722
|
+
currency: code,
|
|
723
|
+
minimumFractionDigits: 0,
|
|
724
|
+
maximumFractionDigits: 2
|
|
725
|
+
}).format(amount);
|
|
726
|
+
} catch {
|
|
727
|
+
return `${code} ${amount.toLocaleString()}`;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// src/components/cart/CartSidebar.tsx
|
|
732
|
+
import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
|
|
733
|
+
function CartSidebar({
|
|
734
|
+
onCheckout,
|
|
735
|
+
onClose,
|
|
736
|
+
formatPriceFn,
|
|
737
|
+
className = "flex flex-col h-full w-full max-w-md bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-800 shadow-xl",
|
|
738
|
+
headerClassName = "flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800",
|
|
739
|
+
titleClassName = "text-lg font-semibold text-gray-900 dark:text-gray-100",
|
|
740
|
+
bodyClassName = "flex-1 overflow-y-auto p-4 space-y-4",
|
|
741
|
+
footerClassName = "p-4 border-t border-gray-200 dark:border-gray-800 space-y-3 bg-gray-50/50 dark:bg-gray-900/50",
|
|
742
|
+
checkoutButtonClassName = "w-full py-3 px-4 bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg transition-colors text-center block disabled:opacity-50"
|
|
743
|
+
}) {
|
|
744
|
+
const {
|
|
745
|
+
items,
|
|
746
|
+
count,
|
|
747
|
+
subtotal,
|
|
748
|
+
isLoading,
|
|
749
|
+
updateQty,
|
|
750
|
+
removeItem,
|
|
751
|
+
productTypeMismatch,
|
|
752
|
+
handleClearCartAndAdd,
|
|
753
|
+
handleKeepCurrentCart,
|
|
754
|
+
setOpen
|
|
755
|
+
} = useCart();
|
|
756
|
+
const handleClose = () => {
|
|
757
|
+
setOpen(false);
|
|
758
|
+
if (onClose) onClose();
|
|
759
|
+
};
|
|
760
|
+
return /* @__PURE__ */ jsxs8("div", { className, children: [
|
|
761
|
+
/* @__PURE__ */ jsxs8("div", { className: headerClassName, children: [
|
|
762
|
+
/* @__PURE__ */ jsxs8("h3", { className: titleClassName, children: [
|
|
763
|
+
"Shopping Cart (",
|
|
764
|
+
count,
|
|
765
|
+
")"
|
|
766
|
+
] }),
|
|
767
|
+
/* @__PURE__ */ jsx9(
|
|
768
|
+
"button",
|
|
769
|
+
{
|
|
770
|
+
type: "button",
|
|
771
|
+
onClick: handleClose,
|
|
772
|
+
className: "p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200",
|
|
773
|
+
"aria-label": "Close cart",
|
|
774
|
+
children: "\u2715"
|
|
775
|
+
}
|
|
776
|
+
)
|
|
777
|
+
] }),
|
|
778
|
+
/* @__PURE__ */ jsxs8("div", { className: bodyClassName, children: [
|
|
779
|
+
productTypeMismatch && /* @__PURE__ */ jsx9(
|
|
780
|
+
CartTypeMismatchBanner,
|
|
781
|
+
{
|
|
782
|
+
mismatch: productTypeMismatch,
|
|
783
|
+
onClearAndAdd: handleClearCartAndAdd,
|
|
784
|
+
onKeepCurrent: handleKeepCurrentCart
|
|
785
|
+
}
|
|
786
|
+
),
|
|
787
|
+
isLoading ? /* @__PURE__ */ jsx9(CartItemSkeleton, { count: 3 }) : items.length === 0 ? /* @__PURE__ */ jsx9("div", { className: "text-center py-12 text-gray-500 dark:text-gray-400", children: "Your cart is empty" }) : items.map((item) => /* @__PURE__ */ jsx9(
|
|
788
|
+
CartItem,
|
|
789
|
+
{
|
|
790
|
+
item,
|
|
791
|
+
onUpdateQty: updateQty,
|
|
792
|
+
onRemove: removeItem,
|
|
793
|
+
formatPriceFn
|
|
794
|
+
},
|
|
795
|
+
item.id
|
|
796
|
+
))
|
|
797
|
+
] }),
|
|
798
|
+
items.length > 0 && /* @__PURE__ */ jsxs8("div", { className: footerClassName, children: [
|
|
799
|
+
/* @__PURE__ */ jsxs8("div", { className: "flex justify-between items-center text-sm font-semibold text-gray-900 dark:text-gray-100", children: [
|
|
800
|
+
/* @__PURE__ */ jsx9("span", { children: "Subtotal" }),
|
|
801
|
+
/* @__PURE__ */ jsx9("span", { children: formatPriceFn ? formatPriceFn(subtotal) : formatPrice(subtotal, void 0) })
|
|
802
|
+
] }),
|
|
803
|
+
/* @__PURE__ */ jsx9(
|
|
804
|
+
"button",
|
|
805
|
+
{
|
|
806
|
+
type: "button",
|
|
807
|
+
onClick: onCheckout,
|
|
808
|
+
disabled: items.length === 0,
|
|
809
|
+
className: checkoutButtonClassName,
|
|
810
|
+
children: "Proceed to Checkout"
|
|
811
|
+
}
|
|
812
|
+
)
|
|
813
|
+
] })
|
|
814
|
+
] });
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// src/components/product/ProductCard.tsx
|
|
818
|
+
import { useState as useState3, useRef, useId } from "react";
|
|
819
|
+
|
|
820
|
+
// src/components/shared/SmartImage.tsx
|
|
821
|
+
import { useState as useState2, useEffect, forwardRef } from "react";
|
|
822
|
+
import { motion } from "framer-motion";
|
|
823
|
+
import Image2 from "next/image";
|
|
824
|
+
import { ImageIcon } from "lucide-react";
|
|
825
|
+
import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
|
|
826
|
+
var isValidSrc = (url) => {
|
|
827
|
+
if (!url || typeof url !== "string") return false;
|
|
828
|
+
const s = url.trim();
|
|
829
|
+
if (s === "" || s === "null" || s === "undefined") return false;
|
|
830
|
+
return s.startsWith("/") || s.startsWith("http://") || s.startsWith("https://") || s.startsWith("data:image/") || s.startsWith("blob:");
|
|
831
|
+
};
|
|
832
|
+
function useImageState(src) {
|
|
833
|
+
const [isMounted, setIsMounted] = useState2(false);
|
|
834
|
+
const [isLoading, setIsLoading] = useState2(true);
|
|
835
|
+
const [hasError, setHasError] = useState2(false);
|
|
836
|
+
useEffect(() => {
|
|
837
|
+
setIsMounted(true);
|
|
838
|
+
}, []);
|
|
839
|
+
useEffect(() => {
|
|
840
|
+
setIsLoading(true);
|
|
841
|
+
setHasError(false);
|
|
842
|
+
}, [src]);
|
|
843
|
+
const handleLoad = () => setIsLoading(false);
|
|
844
|
+
const handleError = () => {
|
|
845
|
+
setIsLoading(false);
|
|
846
|
+
setHasError(true);
|
|
847
|
+
};
|
|
848
|
+
return { isMounted, isLoading, hasError, handleLoad, handleError };
|
|
849
|
+
}
|
|
850
|
+
function ImagePlaceholder({ className, failed }) {
|
|
851
|
+
return /* @__PURE__ */ jsxs9(
|
|
852
|
+
"div",
|
|
853
|
+
{
|
|
854
|
+
className: `relative overflow-hidden flex flex-col items-center justify-center gap-1 bg-muted/40 ${className}`,
|
|
855
|
+
children: [
|
|
856
|
+
/* @__PURE__ */ jsx10(ImageIcon, { className: "w-4 h-4 text-muted-foreground/50 shrink-0" }),
|
|
857
|
+
/* @__PURE__ */ jsx10("span", { className: "text-[10px] text-muted-foreground/70 text-center leading-tight px-1", children: failed ? "Failed to load" : "No image" })
|
|
858
|
+
]
|
|
859
|
+
}
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
function ImageShimmer() {
|
|
863
|
+
return /* @__PURE__ */ jsx10("div", { className: "absolute inset-0 z-10 bg-muted/60 overflow-hidden", children: /* @__PURE__ */ jsx10("div", { className: "absolute inset-0 -translate-x-full animate-shimmer bg-linear-to-r from-transparent via-white/10 to-transparent" }) });
|
|
864
|
+
}
|
|
865
|
+
var SmartImage = forwardRef(function SmartImage2({
|
|
866
|
+
src,
|
|
867
|
+
alt,
|
|
868
|
+
className = "",
|
|
869
|
+
imageClassName = "",
|
|
870
|
+
priority = false,
|
|
871
|
+
isRelative = true,
|
|
872
|
+
sizes = "100vw",
|
|
873
|
+
objectFit = "cover",
|
|
874
|
+
fallbackSrc,
|
|
875
|
+
suppressHydrationWarning = false,
|
|
876
|
+
style,
|
|
877
|
+
draggable
|
|
878
|
+
}, ref) {
|
|
879
|
+
const { isMounted, isLoading, hasError, handleLoad, handleError } = useImageState(src);
|
|
880
|
+
const resolvedSrc = isValidSrc(src) ? src.trim() : null;
|
|
881
|
+
const fallback = isValidSrc(fallbackSrc) ? fallbackSrc.trim() : null;
|
|
882
|
+
if (!isMounted || !resolvedSrc && !fallback) {
|
|
883
|
+
return /* @__PURE__ */ jsx10(ImagePlaceholder, { className });
|
|
884
|
+
}
|
|
885
|
+
if (hasError) {
|
|
886
|
+
if (fallback) {
|
|
887
|
+
return /* @__PURE__ */ jsx10(
|
|
888
|
+
"div",
|
|
889
|
+
{
|
|
890
|
+
className: `${isRelative ? "relative" : ""} overflow-hidden ${className}`,
|
|
891
|
+
style,
|
|
892
|
+
children: /* @__PURE__ */ jsx10(
|
|
893
|
+
Image2,
|
|
894
|
+
{
|
|
895
|
+
ref,
|
|
896
|
+
src: fallback,
|
|
897
|
+
alt,
|
|
898
|
+
fill: true,
|
|
899
|
+
unoptimized: true,
|
|
900
|
+
sizes,
|
|
901
|
+
className: `object-${objectFit} ${imageClassName}`,
|
|
902
|
+
draggable
|
|
903
|
+
}
|
|
904
|
+
)
|
|
905
|
+
}
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
return /* @__PURE__ */ jsx10(ImagePlaceholder, { className, failed: true });
|
|
909
|
+
}
|
|
910
|
+
return /* @__PURE__ */ jsxs9(
|
|
911
|
+
"div",
|
|
912
|
+
{
|
|
913
|
+
className: `${isRelative ? "relative" : ""} overflow-hidden ${className}`,
|
|
914
|
+
style,
|
|
915
|
+
children: [
|
|
916
|
+
isLoading && /* @__PURE__ */ jsx10(ImageShimmer, {}),
|
|
917
|
+
/* @__PURE__ */ jsx10(
|
|
918
|
+
Image2,
|
|
919
|
+
{
|
|
920
|
+
ref,
|
|
921
|
+
src: resolvedSrc,
|
|
922
|
+
alt,
|
|
923
|
+
fill: true,
|
|
924
|
+
priority,
|
|
925
|
+
sizes,
|
|
926
|
+
unoptimized: true,
|
|
927
|
+
suppressHydrationWarning,
|
|
928
|
+
className: `object-${objectFit} ${imageClassName} transition-opacity duration-300 ${isLoading ? "opacity-0" : "opacity-100"}`,
|
|
929
|
+
onLoad: handleLoad,
|
|
930
|
+
onError: handleError,
|
|
931
|
+
draggable
|
|
932
|
+
}
|
|
933
|
+
)
|
|
934
|
+
]
|
|
935
|
+
}
|
|
936
|
+
);
|
|
937
|
+
});
|
|
938
|
+
var SmartMotionImage = forwardRef(function SmartMotionImage2({
|
|
939
|
+
src,
|
|
940
|
+
alt,
|
|
941
|
+
className = "",
|
|
942
|
+
imageClassName = "",
|
|
943
|
+
priority = false,
|
|
944
|
+
isRelative = true,
|
|
945
|
+
sizes = "100vw",
|
|
946
|
+
objectFit = "cover",
|
|
947
|
+
fallbackSrc,
|
|
948
|
+
style,
|
|
949
|
+
draggable,
|
|
950
|
+
...motionProps
|
|
951
|
+
}, ref) {
|
|
952
|
+
const { isMounted, isLoading, hasError, handleLoad, handleError } = useImageState(src);
|
|
953
|
+
const resolvedSrc = isValidSrc(src) ? src.trim() : null;
|
|
954
|
+
const fallback = isValidSrc(fallbackSrc) ? fallbackSrc.trim() : null;
|
|
955
|
+
if (!isMounted || !resolvedSrc && !fallback) {
|
|
956
|
+
return /* @__PURE__ */ jsx10(ImagePlaceholder, { className });
|
|
957
|
+
}
|
|
958
|
+
if (hasError) {
|
|
959
|
+
if (fallback) {
|
|
960
|
+
return /* @__PURE__ */ jsx10(
|
|
961
|
+
motion.div,
|
|
962
|
+
{
|
|
963
|
+
ref,
|
|
964
|
+
className: `${isRelative ? "relative" : ""} overflow-hidden ${className}`,
|
|
965
|
+
style,
|
|
966
|
+
...motionProps,
|
|
967
|
+
children: /* @__PURE__ */ jsx10(
|
|
968
|
+
Image2,
|
|
969
|
+
{
|
|
970
|
+
src: fallback,
|
|
971
|
+
alt,
|
|
972
|
+
fill: true,
|
|
973
|
+
unoptimized: true,
|
|
974
|
+
sizes,
|
|
975
|
+
className: `object-${objectFit} ${imageClassName}`,
|
|
976
|
+
draggable
|
|
977
|
+
}
|
|
978
|
+
)
|
|
979
|
+
}
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
return /* @__PURE__ */ jsx10(ImagePlaceholder, { className, failed: true });
|
|
983
|
+
}
|
|
984
|
+
return /* @__PURE__ */ jsxs9(
|
|
985
|
+
motion.div,
|
|
986
|
+
{
|
|
987
|
+
ref,
|
|
988
|
+
className: `${isRelative ? "relative" : ""} overflow-hidden ${className}`,
|
|
989
|
+
style,
|
|
990
|
+
...motionProps,
|
|
991
|
+
children: [
|
|
992
|
+
isLoading && /* @__PURE__ */ jsx10(ImageShimmer, {}),
|
|
993
|
+
/* @__PURE__ */ jsx10(
|
|
994
|
+
Image2,
|
|
995
|
+
{
|
|
996
|
+
src: resolvedSrc,
|
|
997
|
+
alt,
|
|
998
|
+
fill: true,
|
|
999
|
+
priority,
|
|
1000
|
+
sizes,
|
|
1001
|
+
unoptimized: true,
|
|
1002
|
+
className: `object-${objectFit} ${imageClassName} transition-opacity duration-300 ${isLoading ? "opacity-0" : "opacity-100"}`,
|
|
1003
|
+
onLoad: handleLoad,
|
|
1004
|
+
onError: handleError,
|
|
1005
|
+
draggable
|
|
1006
|
+
}
|
|
1007
|
+
)
|
|
1008
|
+
]
|
|
1009
|
+
}
|
|
1010
|
+
);
|
|
1011
|
+
});
|
|
1012
|
+
var SmartImage_default = SmartImage;
|
|
1013
|
+
|
|
1014
|
+
// src/components/product/ProductCard.tsx
|
|
1015
|
+
import { Fragment, jsx as jsx11, jsxs as jsxs10 } from "react/jsx-runtime";
|
|
1016
|
+
function StarIcon({ half, gradientId }) {
|
|
1017
|
+
return /* @__PURE__ */ jsx11("svg", { viewBox: "0 0 20 20", className: "w-3.5 h-3.5", "aria-hidden": "true", children: half ? /* @__PURE__ */ jsxs10(Fragment, { children: [
|
|
1018
|
+
/* @__PURE__ */ jsx11("defs", { children: /* @__PURE__ */ jsxs10("linearGradient", { id: gradientId, children: [
|
|
1019
|
+
/* @__PURE__ */ jsx11("stop", { offset: "50%", stopColor: "currentColor" }),
|
|
1020
|
+
/* @__PURE__ */ jsx11("stop", { offset: "50%", stopColor: "transparent", stopOpacity: "1" })
|
|
1021
|
+
] }) }),
|
|
1022
|
+
/* @__PURE__ */ jsx11(
|
|
1023
|
+
"path",
|
|
1024
|
+
{
|
|
1025
|
+
fill: `url(#${gradientId})`,
|
|
1026
|
+
stroke: "currentColor",
|
|
1027
|
+
strokeWidth: "1",
|
|
1028
|
+
d: "M10 1.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79L10 14.9l-5.2 2.73.99-5.8-4.21-4.09 5.82-.85L10 1.5z"
|
|
1029
|
+
}
|
|
1030
|
+
)
|
|
1031
|
+
] }) : /* @__PURE__ */ jsx11(
|
|
1032
|
+
"path",
|
|
1033
|
+
{
|
|
1034
|
+
fill: "currentColor",
|
|
1035
|
+
d: "M10 1.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79L10 14.9l-5.2 2.73.99-5.8-4.21-4.09 5.82-.85L10 1.5z"
|
|
1036
|
+
}
|
|
1037
|
+
) });
|
|
1038
|
+
}
|
|
1039
|
+
function StarRow({ rating, idPrefix }) {
|
|
1040
|
+
const stars = [];
|
|
1041
|
+
for (let i = 1; i <= 5; i++) {
|
|
1042
|
+
const diff = rating - (i - 1);
|
|
1043
|
+
if (diff >= 0.75) stars.push(/* @__PURE__ */ jsx11(StarIcon, { gradientId: `${idPrefix}-star-${i}` }, i));
|
|
1044
|
+
else if (diff >= 0.25) stars.push(/* @__PURE__ */ jsx11(StarIcon, { half: true, gradientId: `${idPrefix}-star-${i}` }, i));
|
|
1045
|
+
else stars.push(/* @__PURE__ */ jsx11("svg", { viewBox: "0 0 20 20", className: "w-3.5 h-3.5 text-muted-foreground/30", "aria-hidden": "true", children: /* @__PURE__ */ jsx11("path", { fill: "currentColor", d: "M10 1.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79L10 14.9l-5.2 2.73.99-5.8-4.21-4.09 5.82-.85L10 1.5z" }) }, i));
|
|
1046
|
+
}
|
|
1047
|
+
return /* @__PURE__ */ jsx11(Fragment, { children: stars });
|
|
1048
|
+
}
|
|
1049
|
+
function ProductCard({
|
|
1050
|
+
product,
|
|
1051
|
+
onSelectProduct,
|
|
1052
|
+
onWishlistToggle,
|
|
1053
|
+
isWishlistSaved = false,
|
|
1054
|
+
showWishlistButton = false,
|
|
1055
|
+
priority = false,
|
|
1056
|
+
className = "flex flex-col h-full rounded-xl border bg-card overflow-hidden shadow-sm hover:shadow-md transition-shadow group",
|
|
1057
|
+
imageContainerClassName = "w-full aspect-square",
|
|
1058
|
+
imageClassName = "transition-transform duration-500 group-hover:scale-105",
|
|
1059
|
+
titleClassName = "text-base font-semibold text-foreground line-clamp-1 cursor-pointer hover:underline",
|
|
1060
|
+
priceClassName = "text-lg font-bold text-foreground",
|
|
1061
|
+
buttonClassName = "w-full py-2.5 px-4 bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-medium rounded-lg transition-colors text-center block mt-auto",
|
|
1062
|
+
enableImageCarousel = true,
|
|
1063
|
+
showCarouselDots = true,
|
|
1064
|
+
showCarouselArrows = true,
|
|
1065
|
+
activeDotClassName = "bg-primary w-4 h-1.5 rounded-full transition-all",
|
|
1066
|
+
dotClassName = "bg-white/70 hover:bg-white w-1.5 h-1.5 rounded-full transition-all",
|
|
1067
|
+
formatPriceFn,
|
|
1068
|
+
badgeSlot,
|
|
1069
|
+
showRating = true,
|
|
1070
|
+
ratingClassName = "flex items-center gap-1 text-amber-500",
|
|
1071
|
+
footerSlot
|
|
1072
|
+
}) {
|
|
1073
|
+
const { addItem } = useCart();
|
|
1074
|
+
const ratingIdPrefix = useId();
|
|
1075
|
+
const [currentImageIndex, setCurrentImageIndex] = useState3(0);
|
|
1076
|
+
const touchStartX = useRef(null);
|
|
1077
|
+
const touchEndX = useRef(null);
|
|
1078
|
+
const productType = product.product_type;
|
|
1079
|
+
const serviceType = product.service_type || "appointment";
|
|
1080
|
+
const isConsultationRequired = product.service_requires_consultation;
|
|
1081
|
+
const images = product.images && product.images.length > 0 ? product.images : [];
|
|
1082
|
+
const hasMultipleImages = images.length > 1 && enableImageCarousel;
|
|
1083
|
+
const hasRating = showRating && product.average_rating != null && product.rating_count > 0;
|
|
1084
|
+
const handleCTA = () => {
|
|
1085
|
+
if (productType === "physical" || productType === "digital") {
|
|
1086
|
+
addItem(product);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
onSelectProduct?.(product);
|
|
1090
|
+
};
|
|
1091
|
+
const getCtaText = () => {
|
|
1092
|
+
if (productType === "physical" || productType === "digital") return "Add to Cart";
|
|
1093
|
+
if (productType === "bespoke") return "Start Commission";
|
|
1094
|
+
if (productType === "service") {
|
|
1095
|
+
if (isConsultationRequired || serviceType === "project") return "Get a Quote";
|
|
1096
|
+
if (serviceType === "appointment") return "Book Appointment";
|
|
1097
|
+
return "View Details";
|
|
1098
|
+
}
|
|
1099
|
+
return "View Details";
|
|
1100
|
+
};
|
|
1101
|
+
const handleNextImage = (e) => {
|
|
1102
|
+
e?.stopPropagation();
|
|
1103
|
+
if (!hasMultipleImages) return;
|
|
1104
|
+
setCurrentImageIndex((prev) => (prev + 1) % images.length);
|
|
1105
|
+
};
|
|
1106
|
+
const handlePrevImage = (e) => {
|
|
1107
|
+
e?.stopPropagation();
|
|
1108
|
+
if (!hasMultipleImages) return;
|
|
1109
|
+
setCurrentImageIndex((prev) => (prev - 1 + images.length) % images.length);
|
|
1110
|
+
};
|
|
1111
|
+
const handleTouchStart = (e) => {
|
|
1112
|
+
if (!hasMultipleImages) return;
|
|
1113
|
+
touchStartX.current = e.touches[0].clientX;
|
|
1114
|
+
};
|
|
1115
|
+
const handleTouchMove = (e) => {
|
|
1116
|
+
if (!hasMultipleImages) return;
|
|
1117
|
+
touchEndX.current = e.touches[0].clientX;
|
|
1118
|
+
};
|
|
1119
|
+
const handleTouchEnd = () => {
|
|
1120
|
+
if (!hasMultipleImages || touchStartX.current === null || touchEndX.current === null) return;
|
|
1121
|
+
const distance = touchStartX.current - touchEndX.current;
|
|
1122
|
+
const isSwipeLeft = distance > 40;
|
|
1123
|
+
const isSwipeRight = distance < -40;
|
|
1124
|
+
if (isSwipeLeft) handleNextImage();
|
|
1125
|
+
else if (isSwipeRight) handlePrevImage();
|
|
1126
|
+
touchStartX.current = null;
|
|
1127
|
+
touchEndX.current = null;
|
|
1128
|
+
};
|
|
1129
|
+
return /* @__PURE__ */ jsxs10("div", { className, children: [
|
|
1130
|
+
/* @__PURE__ */ jsxs10(
|
|
1131
|
+
"div",
|
|
1132
|
+
{
|
|
1133
|
+
className: "relative overflow-hidden cursor-pointer group/image",
|
|
1134
|
+
onClick: () => onSelectProduct?.(product),
|
|
1135
|
+
onTouchStart: handleTouchStart,
|
|
1136
|
+
onTouchMove: handleTouchMove,
|
|
1137
|
+
onTouchEnd: handleTouchEnd,
|
|
1138
|
+
children: [
|
|
1139
|
+
images.length > 0 ? /* @__PURE__ */ jsx11(
|
|
1140
|
+
SmartImage,
|
|
1141
|
+
{
|
|
1142
|
+
src: images[currentImageIndex],
|
|
1143
|
+
alt: `${product.name} - image ${currentImageIndex + 1}`,
|
|
1144
|
+
className: imageContainerClassName,
|
|
1145
|
+
imageClassName,
|
|
1146
|
+
priority,
|
|
1147
|
+
sizes: "(min-width: 1024px) 25vw, 50vw"
|
|
1148
|
+
}
|
|
1149
|
+
) : /* @__PURE__ */ jsx11("div", { className: `${imageContainerClassName} flex items-center justify-center text-muted-foreground text-sm`, children: "No Image" }),
|
|
1150
|
+
badgeSlot ? /* @__PURE__ */ jsx11("div", { className: "absolute top-2 left-2 z-10 flex flex-col gap-1", children: badgeSlot }) : /* @__PURE__ */ jsx11("span", { className: "absolute top-2 right-2 px-2 py-0.5 text-xs font-semibold rounded bg-black/60 text-white capitalize z-10", children: productType }),
|
|
1151
|
+
showWishlistButton && /* @__PURE__ */ jsx11(
|
|
1152
|
+
"button",
|
|
1153
|
+
{
|
|
1154
|
+
type: "button",
|
|
1155
|
+
"aria-label": isWishlistSaved ? "Remove from wishlist" : "Save to wishlist",
|
|
1156
|
+
onClick: (e) => {
|
|
1157
|
+
e.stopPropagation();
|
|
1158
|
+
onWishlistToggle?.(product, e);
|
|
1159
|
+
},
|
|
1160
|
+
className: "absolute top-2 right-2 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-card/80 backdrop-blur shadow-sm hover:bg-card transition-colors",
|
|
1161
|
+
children: /* @__PURE__ */ jsx11(
|
|
1162
|
+
"svg",
|
|
1163
|
+
{
|
|
1164
|
+
className: `w-4 h-4 ${isWishlistSaved ? "fill-red-500 text-red-500" : "text-muted-foreground"}`,
|
|
1165
|
+
viewBox: "0 0 24 24",
|
|
1166
|
+
stroke: "currentColor",
|
|
1167
|
+
strokeWidth: "2",
|
|
1168
|
+
children: /* @__PURE__ */ jsx11("path", { d: "M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" })
|
|
1169
|
+
}
|
|
1170
|
+
)
|
|
1171
|
+
}
|
|
1172
|
+
),
|
|
1173
|
+
hasMultipleImages && /* @__PURE__ */ jsxs10(Fragment, { children: [
|
|
1174
|
+
showCarouselArrows && /* @__PURE__ */ jsxs10(Fragment, { children: [
|
|
1175
|
+
/* @__PURE__ */ jsx11(
|
|
1176
|
+
"button",
|
|
1177
|
+
{
|
|
1178
|
+
type: "button",
|
|
1179
|
+
"aria-label": "Previous image",
|
|
1180
|
+
onClick: handlePrevImage,
|
|
1181
|
+
className: "absolute left-2 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-black/50 hover:bg-black/75 text-white flex items-center justify-center opacity-0 group-hover/image:opacity-100 transition-opacity z-10",
|
|
1182
|
+
children: "\u2039"
|
|
1183
|
+
}
|
|
1184
|
+
),
|
|
1185
|
+
/* @__PURE__ */ jsx11(
|
|
1186
|
+
"button",
|
|
1187
|
+
{
|
|
1188
|
+
type: "button",
|
|
1189
|
+
"aria-label": "Next image",
|
|
1190
|
+
onClick: handleNextImage,
|
|
1191
|
+
className: "absolute right-2 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-black/50 hover:bg-black/75 text-white flex items-center justify-center opacity-0 group-hover/image:opacity-100 transition-opacity z-10",
|
|
1192
|
+
children: "\u203A"
|
|
1193
|
+
}
|
|
1194
|
+
)
|
|
1195
|
+
] }),
|
|
1196
|
+
showCarouselDots && /* @__PURE__ */ jsx11("div", { className: "absolute bottom-2 left-1/2 -translate-x-1/2 flex items-center gap-1 z-10 px-2 py-1 rounded-full bg-black/30 backdrop-blur-xs", children: images.map((_, idx) => /* @__PURE__ */ jsx11(
|
|
1197
|
+
"button",
|
|
1198
|
+
{
|
|
1199
|
+
type: "button",
|
|
1200
|
+
"aria-label": `Go to image ${idx + 1}`,
|
|
1201
|
+
onClick: (e) => {
|
|
1202
|
+
e.stopPropagation();
|
|
1203
|
+
setCurrentImageIndex(idx);
|
|
1204
|
+
},
|
|
1205
|
+
className: idx === currentImageIndex ? activeDotClassName : dotClassName
|
|
1206
|
+
},
|
|
1207
|
+
idx
|
|
1208
|
+
)) })
|
|
1209
|
+
] })
|
|
1210
|
+
]
|
|
1211
|
+
}
|
|
1212
|
+
),
|
|
1213
|
+
/* @__PURE__ */ jsxs10("div", { className: "p-4 flex flex-col flex-1 space-y-3", children: [
|
|
1214
|
+
/* @__PURE__ */ jsx11("h3", { className: titleClassName, onClick: () => onSelectProduct?.(product), children: product.name }),
|
|
1215
|
+
product.description && /* @__PURE__ */ jsx11("p", { className: "text-xs text-muted-foreground line-clamp-2", children: product.description }),
|
|
1216
|
+
hasRating && /* @__PURE__ */ jsxs10("div", { className: ratingClassName, children: [
|
|
1217
|
+
/* @__PURE__ */ jsx11(StarRow, { rating: product.average_rating, idPrefix: ratingIdPrefix }),
|
|
1218
|
+
/* @__PURE__ */ jsxs10("span", { className: "text-xs text-muted-foreground font-medium", children: [
|
|
1219
|
+
product.average_rating.toFixed(1),
|
|
1220
|
+
" (",
|
|
1221
|
+
product.rating_count,
|
|
1222
|
+
")"
|
|
1223
|
+
] })
|
|
1224
|
+
] }),
|
|
1225
|
+
/* @__PURE__ */ jsx11("div", { className: "flex items-center justify-between pt-2 mt-auto", children: /* @__PURE__ */ jsx11("div", { className: priceClassName, children: formatPriceFn ? formatPriceFn(product.sale_price ?? product.base_price) : formatPrice(product.sale_price ?? product.base_price, product.currency) }) }),
|
|
1226
|
+
footerSlot,
|
|
1227
|
+
/* @__PURE__ */ jsx11(
|
|
1228
|
+
"button",
|
|
1229
|
+
{
|
|
1230
|
+
type: "button",
|
|
1231
|
+
onClick: handleCTA,
|
|
1232
|
+
className: buttonClassName,
|
|
1233
|
+
children: getCtaText()
|
|
1234
|
+
}
|
|
1235
|
+
)
|
|
1236
|
+
] })
|
|
1237
|
+
] });
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// src/components/product/ProductGrid.tsx
|
|
1241
|
+
import { useQuery as useQuery2 } from "@tanstack/react-query";
|
|
1242
|
+
|
|
1243
|
+
// src/utils/graphql-url.ts
|
|
1244
|
+
import { GraphQLClient } from "graphql-request";
|
|
1245
|
+
function resolveGraphQLEndpoint(url) {
|
|
1246
|
+
if (typeof window === "undefined") return url;
|
|
1247
|
+
try {
|
|
1248
|
+
new URL(url);
|
|
1249
|
+
return url;
|
|
1250
|
+
} catch {
|
|
1251
|
+
return `${window.location.origin}${url}`;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
function createGraphQLClient(config, accessToken) {
|
|
1255
|
+
const headers = {
|
|
1256
|
+
"Content-Type": "application/json",
|
|
1257
|
+
"Accept": "application/json",
|
|
1258
|
+
"X-Storefront-Key": config.storefrontApiKey,
|
|
1259
|
+
"X-Business-ID": config.businessId
|
|
1260
|
+
};
|
|
1261
|
+
if (accessToken) {
|
|
1262
|
+
headers["Authorization"] = `Bearer ${accessToken}`;
|
|
1263
|
+
}
|
|
1264
|
+
return new GraphQLClient(resolveGraphQLEndpoint(`${config.backendUrls.store}/graphql`), { headers });
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// src/generated/graphql.ts
|
|
1268
|
+
var GetStorefrontProductsDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "GetStorefrontProducts" }, "variableDefinitions": [{ "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "page" } }, "type": { "kind": "NonNullType", "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "Int" } } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "pageSize" } }, "type": { "kind": "NonNullType", "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "Int" } } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "collectionSlug" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "search" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "productType" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "storefront_products" }, "arguments": [{ "kind": "Argument", "name": { "kind": "Name", "value": "page" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "page" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "pageSize" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "pageSize" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "collectionSlug" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "collectionSlug" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "search" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "search" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "productType" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "productType" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "items" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "FragmentSpread", "name": { "kind": "Name", "value": "ProductFields" } }] } }, { "kind": "Field", "name": { "kind": "Name", "value": "total" } }, { "kind": "Field", "name": { "kind": "Name", "value": "page" } }, { "kind": "Field", "name": { "kind": "Name", "value": "page_size" } }, { "kind": "Field", "name": { "kind": "Name", "value": "total_pages" } }] } }] } }, { "kind": "FragmentDefinition", "name": { "kind": "Name", "value": "ProductFields" }, "typeCondition": { "kind": "NamedType", "name": { "kind": "Name", "value": "Product" } }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "business_id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "name" } }, { "kind": "Field", "name": { "kind": "Name", "value": "slug" } }, { "kind": "Field", "name": { "kind": "Name", "value": "description" } }, { "kind": "Field", "name": { "kind": "Name", "value": "brand" } }, { "kind": "Field", "name": { "kind": "Name", "value": "product_type" } }, { "kind": "Field", "name": { "kind": "Name", "value": "requires_shipping" } }, { "kind": "Field", "name": { "kind": "Name", "value": "is_consumable" } }, { "kind": "Field", "name": { "kind": "Name", "value": "is_bespoke" } }, { "kind": "Field", "name": { "kind": "Name", "value": "is_physical" } }, { "kind": "Field", "name": { "kind": "Name", "value": "is_non_returnable" } }, { "kind": "Field", "name": { "kind": "Name", "value": "deposit_percentage" } }, { "kind": "Field", "name": { "kind": "Name", "value": "base_price" } }, { "kind": "Field", "name": { "kind": "Name", "value": "currency" } }, { "kind": "Field", "name": { "kind": "Name", "value": "sale_price" } }, { "kind": "Field", "name": { "kind": "Name", "value": "sale_start" } }, { "kind": "Field", "name": { "kind": "Name", "value": "sale_end" } }, { "kind": "Field", "name": { "kind": "Name", "value": "images" } }, { "kind": "Field", "name": { "kind": "Name", "value": "tags" } }, { "kind": "Field", "name": { "kind": "Name", "value": "custom_fields" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "label" } }, { "kind": "Field", "name": { "kind": "Name", "value": "unit" } }, { "kind": "Field", "name": { "kind": "Name", "value": "required" } }, { "kind": "Field", "name": { "kind": "Name", "value": "field_type" } }, { "kind": "Field", "name": { "kind": "Name", "value": "options" } }] } }, { "kind": "Field", "name": { "kind": "Name", "value": "is_published" } }, { "kind": "Field", "name": { "kind": "Name", "value": "is_featured" } }, { "kind": "Field", "name": { "kind": "Name", "value": "is_popular" } }, { "kind": "Field", "name": { "kind": "Name", "value": "is_new" } }, { "kind": "Field", "name": { "kind": "Name", "value": "is_temporary" } }, { "kind": "Field", "name": { "kind": "Name", "value": "average_rating" } }, { "kind": "Field", "name": { "kind": "Name", "value": "rating_count" } }, { "kind": "Field", "name": { "kind": "Name", "value": "source_channel" } }, { "kind": "Field", "name": { "kind": "Name", "value": "source_channel_ref_id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "service_type" } }, { "kind": "Field", "name": { "kind": "Name", "value": "service_duration_minutes" } }, { "kind": "Field", "name": { "kind": "Name", "value": "service_location_type" } }, { "kind": "Field", "name": { "kind": "Name", "value": "service_coverage_radius_km" } }, { "kind": "Field", "name": { "kind": "Name", "value": "service_requires_consultation" } }, { "kind": "Field", "name": { "kind": "Name", "value": "service_max_capacity_per_slot" } }, { "kind": "Field", "name": { "kind": "Name", "value": "service_buffer_minutes" } }, { "kind": "Field", "name": { "kind": "Name", "value": "service_is_recurring" } }, { "kind": "Field", "name": { "kind": "Name", "value": "service_recurring_interval" } }, { "kind": "Field", "name": { "kind": "Name", "value": "service_project_based" } }, { "kind": "Field", "name": { "kind": "Name", "value": "digital_files" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "file_key" } }, { "kind": "Field", "name": { "kind": "Name", "value": "display_name" } }, { "kind": "Field", "name": { "kind": "Name", "value": "sort_order" } }] } }, { "kind": "Field", "name": { "kind": "Name", "value": "download_limit" } }, { "kind": "Field", "name": { "kind": "Name", "value": "download_expires_at" } }, { "kind": "Field", "name": { "kind": "Name", "value": "weight_kg" } }, { "kind": "Field", "name": { "kind": "Name", "value": "length_cm" } }, { "kind": "Field", "name": { "kind": "Name", "value": "width_cm" } }, { "kind": "Field", "name": { "kind": "Name", "value": "height_cm" } }, { "kind": "Field", "name": { "kind": "Name", "value": "created_at" } }, { "kind": "Field", "name": { "kind": "Name", "value": "updated_at" } }, { "kind": "Field", "name": { "kind": "Name", "value": "variants" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "product_id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "sku" } }, { "kind": "Field", "name": { "kind": "Name", "value": "attributes" } }, { "kind": "Field", "name": { "kind": "Name", "value": "price" } }, { "kind": "Field", "name": { "kind": "Name", "value": "images" } }, { "kind": "Field", "name": { "kind": "Name", "value": "weight_kg" } }, { "kind": "Field", "name": { "kind": "Name", "value": "is_active" } }, { "kind": "Field", "name": { "kind": "Name", "value": "inventory" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "product_id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "variant_id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "quantity" } }, { "kind": "Field", "name": { "kind": "Name", "value": "reserved" } }, { "kind": "Field", "name": { "kind": "Name", "value": "reorder_point" } }, { "kind": "Field", "name": { "kind": "Name", "value": "reorder_quantity" } }, { "kind": "Field", "name": { "kind": "Name", "value": "track_quantity" } }, { "kind": "Field", "name": { "kind": "Name", "value": "allow_backorder" } }, { "kind": "Field", "name": { "kind": "Name", "value": "available" } }, { "kind": "Field", "name": { "kind": "Name", "value": "is_in_stock" } }, { "kind": "Field", "name": { "kind": "Name", "value": "is_low_stock" } }, { "kind": "Field", "name": { "kind": "Name", "value": "updated_at" } }] } }, { "kind": "Field", "name": { "kind": "Name", "value": "created_at" } }, { "kind": "Field", "name": { "kind": "Name", "value": "updated_at" } }] } }, { "kind": "Field", "name": { "kind": "Name", "value": "collections" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "business_id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "name" } }, { "kind": "Field", "name": { "kind": "Name", "value": "slug" } }, { "kind": "Field", "name": { "kind": "Name", "value": "description" } }, { "kind": "Field", "name": { "kind": "Name", "value": "image_url" } }, { "kind": "Field", "name": { "kind": "Name", "value": "collection_type" } }, { "kind": "Field", "name": { "kind": "Name", "value": "rules" } }, { "kind": "Field", "name": { "kind": "Name", "value": "status" } }, { "kind": "Field", "name": { "kind": "Name", "value": "sort_order" } }, { "kind": "Field", "name": { "kind": "Name", "value": "created_at" } }, { "kind": "Field", "name": { "kind": "Name", "value": "updated_at" } }] } }] } }] };
|
|
1269
|
+
|
|
1270
|
+
// src/services/product.service.ts
|
|
1271
|
+
function mapProductType(raw) {
|
|
1272
|
+
switch (raw) {
|
|
1273
|
+
case "PHYSICAL":
|
|
1274
|
+
return "physical" /* PHYSICAL */;
|
|
1275
|
+
case "DIGITAL":
|
|
1276
|
+
return "digital" /* DIGITAL */;
|
|
1277
|
+
case "SERVICE":
|
|
1278
|
+
return "service" /* SERVICE */;
|
|
1279
|
+
case "BESPOKE":
|
|
1280
|
+
return "bespoke" /* BESPOKE */;
|
|
1281
|
+
default: {
|
|
1282
|
+
const _exhaustive = raw;
|
|
1283
|
+
throw new Error(`Unhandled GraphQL ProductType member: ${_exhaustive}`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
function mapCustomFieldType(raw) {
|
|
1288
|
+
if (raw === "text" || raw === "choice") return raw;
|
|
1289
|
+
throw new Error(`Unrecognised custom field field_type from backend: "${raw}"`);
|
|
1290
|
+
}
|
|
1291
|
+
function mapProduct(raw) {
|
|
1292
|
+
return {
|
|
1293
|
+
...raw,
|
|
1294
|
+
product_type: mapProductType(raw.product_type),
|
|
1295
|
+
custom_fields: raw.custom_fields.map((f) => ({
|
|
1296
|
+
...f,
|
|
1297
|
+
unit: f.unit ?? void 0,
|
|
1298
|
+
field_type: mapCustomFieldType(f.field_type)
|
|
1299
|
+
})),
|
|
1300
|
+
description: raw.description ?? void 0,
|
|
1301
|
+
brand: raw.brand ?? void 0,
|
|
1302
|
+
is_physical: raw.is_physical ?? void 0,
|
|
1303
|
+
sale_price: raw.sale_price ?? void 0,
|
|
1304
|
+
sale_start: raw.sale_start ?? void 0,
|
|
1305
|
+
sale_end: raw.sale_end ?? void 0,
|
|
1306
|
+
source_channel_ref_id: raw.source_channel_ref_id ?? void 0,
|
|
1307
|
+
download_limit: raw.download_limit ?? void 0,
|
|
1308
|
+
download_expires_at: raw.download_expires_at ?? void 0,
|
|
1309
|
+
service_type: raw.service_type ?? void 0,
|
|
1310
|
+
service_duration_minutes: raw.service_duration_minutes ?? void 0,
|
|
1311
|
+
service_location_type: raw.service_location_type ?? void 0,
|
|
1312
|
+
service_coverage_radius_km: raw.service_coverage_radius_km ?? void 0,
|
|
1313
|
+
service_max_capacity_per_slot: raw.service_max_capacity_per_slot ?? void 0,
|
|
1314
|
+
service_recurring_interval: raw.service_recurring_interval ?? void 0,
|
|
1315
|
+
length_cm: raw.length_cm ?? void 0,
|
|
1316
|
+
width_cm: raw.width_cm ?? void 0,
|
|
1317
|
+
height_cm: raw.height_cm ?? void 0,
|
|
1318
|
+
average_rating: raw.average_rating ?? void 0,
|
|
1319
|
+
created_at: raw.created_at,
|
|
1320
|
+
updated_at: raw.updated_at,
|
|
1321
|
+
variants: raw.variants.map((v) => ({
|
|
1322
|
+
...v,
|
|
1323
|
+
sku: v.sku ?? void 0,
|
|
1324
|
+
attributes: v.attributes ? JSON.parse(v.attributes) : {},
|
|
1325
|
+
price: v.price ?? void 0,
|
|
1326
|
+
weight_kg: v.weight_kg ?? void 0,
|
|
1327
|
+
inventory: v.inventory ? {
|
|
1328
|
+
...v.inventory,
|
|
1329
|
+
variant_id: v.inventory.variant_id ?? void 0,
|
|
1330
|
+
updated_at: v.inventory.updated_at
|
|
1331
|
+
} : void 0,
|
|
1332
|
+
created_at: v.created_at,
|
|
1333
|
+
updated_at: v.updated_at
|
|
1334
|
+
})),
|
|
1335
|
+
collections: raw.collections.map((c) => ({
|
|
1336
|
+
...c,
|
|
1337
|
+
business_id: c.business_id ?? void 0,
|
|
1338
|
+
description: c.description ?? void 0,
|
|
1339
|
+
image_url: c.image_url ?? void 0,
|
|
1340
|
+
rules: c.rules ?? void 0,
|
|
1341
|
+
created_at: c.created_at,
|
|
1342
|
+
updated_at: c.updated_at
|
|
1343
|
+
}))
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
function mapProductConnection(connection) {
|
|
1347
|
+
return { ...connection, items: connection.items.map(mapProduct) };
|
|
1348
|
+
}
|
|
1349
|
+
async function getStorefrontProducts(config, params) {
|
|
1350
|
+
const client = createGraphQLClient(config);
|
|
1351
|
+
const data = await client.request(GetStorefrontProductsDocument, {
|
|
1352
|
+
page: params?.page || 1,
|
|
1353
|
+
pageSize: params?.pageSize || 20,
|
|
1354
|
+
collectionSlug: params?.collectionSlug,
|
|
1355
|
+
search: params?.search,
|
|
1356
|
+
productType: params?.productType
|
|
1357
|
+
});
|
|
1358
|
+
return mapProductConnection(data.storefront_products);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// src/utils/cache.ts
|
|
1362
|
+
var CACHE_TIERS = {
|
|
1363
|
+
none: { staleTime: 0, gcTime: 0 },
|
|
1364
|
+
infinity: { staleTime: Infinity, gcTime: 0 },
|
|
1365
|
+
low: { staleTime: 1e3 * 30, gcTime: 1e3 * 60 },
|
|
1366
|
+
// 30s / 1m
|
|
1367
|
+
medium: { staleTime: 1e3 * 60 * 5, gcTime: 1e3 * 60 * 10 },
|
|
1368
|
+
// 5m / 10m
|
|
1369
|
+
high: { staleTime: 1e3 * 60 * 30, gcTime: 1e3 * 60 * 60 }
|
|
1370
|
+
// 30m / 1h
|
|
1371
|
+
};
|
|
1372
|
+
var isDevelopment2 = true;
|
|
1373
|
+
var getCacheTier = (tier) => {
|
|
1374
|
+
if (isDevelopment2) return CACHE_TIERS.none;
|
|
1375
|
+
return CACHE_TIERS[tier];
|
|
1376
|
+
};
|
|
1377
|
+
|
|
1378
|
+
// src/components/product/ProductGrid.tsx
|
|
1379
|
+
import { jsx as jsx12 } from "react/jsx-runtime";
|
|
1380
|
+
function ProductGrid({
|
|
1381
|
+
params,
|
|
1382
|
+
onSelectProduct,
|
|
1383
|
+
onWishlistToggle,
|
|
1384
|
+
isWishlistSavedFn,
|
|
1385
|
+
showWishlistButton = false,
|
|
1386
|
+
formatPriceFn,
|
|
1387
|
+
className = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6",
|
|
1388
|
+
cardClassName
|
|
1389
|
+
}) {
|
|
1390
|
+
const ctx = useSdkContext();
|
|
1391
|
+
const { config } = resolveHookArgs(void 0, void 0, ctx);
|
|
1392
|
+
const { data, isLoading, error } = useQuery2({
|
|
1393
|
+
queryKey: ["products", "grid", params],
|
|
1394
|
+
queryFn: () => getStorefrontProducts(config, params),
|
|
1395
|
+
...getCacheTier("medium")
|
|
1396
|
+
});
|
|
1397
|
+
if (isLoading) {
|
|
1398
|
+
return /* @__PURE__ */ jsx12(CardSkeleton, { count: 8, containerClassName: className });
|
|
1399
|
+
}
|
|
1400
|
+
if (error) {
|
|
1401
|
+
return /* @__PURE__ */ jsx12("div", { className: "text-center py-12 text-destructive", children: "Failed to load products. Please try again." });
|
|
1402
|
+
}
|
|
1403
|
+
const products = data?.items || [];
|
|
1404
|
+
if (products.length === 0) {
|
|
1405
|
+
return /* @__PURE__ */ jsx12("div", { className: "text-center py-12 text-muted-foreground", children: "No products found." });
|
|
1406
|
+
}
|
|
1407
|
+
return /* @__PURE__ */ jsx12("div", { className, children: products.map((product) => /* @__PURE__ */ jsx12(
|
|
1408
|
+
ProductCard,
|
|
1409
|
+
{
|
|
1410
|
+
product,
|
|
1411
|
+
onSelectProduct,
|
|
1412
|
+
onWishlistToggle,
|
|
1413
|
+
isWishlistSaved: isWishlistSavedFn ? isWishlistSavedFn(product) : false,
|
|
1414
|
+
showWishlistButton,
|
|
1415
|
+
formatPriceFn,
|
|
1416
|
+
className: cardClassName
|
|
1417
|
+
},
|
|
1418
|
+
product.id
|
|
1419
|
+
)) });
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// src/components/booking/SlotPicker.tsx
|
|
1423
|
+
import { useState as useState4 } from "react";
|
|
1424
|
+
|
|
1425
|
+
// src/hooks/use-service-booking.ts
|
|
1426
|
+
import { useQuery as useQuery3, useMutation as useMutation2, useQueryClient as useQueryClient2 } from "@tanstack/react-query";
|
|
1427
|
+
|
|
1428
|
+
// src/services/service-booking.service.ts
|
|
1429
|
+
async function getAvailableSlots(config, productId, date, params) {
|
|
1430
|
+
const query = new URLSearchParams({ date });
|
|
1431
|
+
if (params?.staffId) query.set("staff_id", params.staffId);
|
|
1432
|
+
if (params?.locationId) query.set("location_id", params.locationId);
|
|
1433
|
+
if (params?.customerLatitude !== void 0) query.set("customer_latitude", String(params.customerLatitude));
|
|
1434
|
+
if (params?.customerLongitude !== void 0) query.set("customer_longitude", String(params.customerLongitude));
|
|
1435
|
+
return apiRequest(
|
|
1436
|
+
`${config.backendUrls.store}/catalog/products/${productId}/available-slots?${query.toString()}`,
|
|
1437
|
+
{
|
|
1438
|
+
headers: storefrontHeaders(config),
|
|
1439
|
+
fallbackErrorMessage: "Failed to get available slots"
|
|
1440
|
+
}
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// src/hooks/use-service-booking.ts
|
|
1445
|
+
function useAvailableSlots(config, productId, date, params) {
|
|
1446
|
+
const ctx = useSdkContext();
|
|
1447
|
+
const { config: cfg } = resolveHookArgs(config, void 0, ctx);
|
|
1448
|
+
return useQuery3({
|
|
1449
|
+
queryKey: ["available-slots", productId, date, params?.staffId, params?.locationId],
|
|
1450
|
+
queryFn: () => getAvailableSlots(cfg, productId, date, params),
|
|
1451
|
+
enabled: !!productId && !!date
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// src/components/booking/SlotPicker.tsx
|
|
1456
|
+
import { jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
|
|
1457
|
+
function SlotPicker({
|
|
1458
|
+
productId,
|
|
1459
|
+
date,
|
|
1460
|
+
staffId,
|
|
1461
|
+
locationId,
|
|
1462
|
+
onSelectSlot,
|
|
1463
|
+
className = "space-y-4",
|
|
1464
|
+
slotButtonClassName = "p-3 border rounded-lg text-sm text-center hover:border-primary focus:outline-none transition-colors"
|
|
1465
|
+
}) {
|
|
1466
|
+
const { data, isLoading, error } = useAvailableSlots(void 0, productId, date, { staffId, locationId });
|
|
1467
|
+
const [selectedTime, setSelectedTime] = useState4(null);
|
|
1468
|
+
if (isLoading) {
|
|
1469
|
+
return /* @__PURE__ */ jsx13("div", { className: "grid grid-cols-2 sm:grid-cols-3 gap-3 animate-pulse", children: Array.from({ length: 6 }).map((_, i) => /* @__PURE__ */ jsx13("div", { className: "h-12 bg-muted rounded-lg" }, i)) });
|
|
1470
|
+
}
|
|
1471
|
+
if (error || !data) {
|
|
1472
|
+
return /* @__PURE__ */ jsx13("div", { className: "text-xs text-destructive", children: "Failed to load available time slots. Please select a different date." });
|
|
1473
|
+
}
|
|
1474
|
+
if (!data.serviceable) {
|
|
1475
|
+
return /* @__PURE__ */ jsx13("div", { className: "text-xs text-muted-foreground py-4 text-center", children: data.reason || "This service isn't available for booking right now." });
|
|
1476
|
+
}
|
|
1477
|
+
if (data.available_slots.length === 0) {
|
|
1478
|
+
return /* @__PURE__ */ jsx13("div", { className: "text-xs text-muted-foreground py-4 text-center", children: "No time slots available on this date." });
|
|
1479
|
+
}
|
|
1480
|
+
return /* @__PURE__ */ jsxs11("div", { className, children: [
|
|
1481
|
+
/* @__PURE__ */ jsx13("h4", { className: "text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: "Available Time Slots" }),
|
|
1482
|
+
/* @__PURE__ */ jsx13("div", { className: "grid grid-cols-2 sm:grid-cols-3 gap-3", children: data.available_slots.map((time) => {
|
|
1483
|
+
const isSelected = selectedTime === time;
|
|
1484
|
+
return /* @__PURE__ */ jsx13(
|
|
1485
|
+
"button",
|
|
1486
|
+
{
|
|
1487
|
+
type: "button",
|
|
1488
|
+
onClick: () => {
|
|
1489
|
+
setSelectedTime(time);
|
|
1490
|
+
onSelectSlot(time);
|
|
1491
|
+
},
|
|
1492
|
+
className: `${slotButtonClassName} ${isSelected ? "border-primary bg-primary/10 text-primary font-semibold" : "border-border text-foreground"}`,
|
|
1493
|
+
children: formatTimeString(time)
|
|
1494
|
+
},
|
|
1495
|
+
time
|
|
1496
|
+
);
|
|
1497
|
+
}) })
|
|
1498
|
+
] });
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// src/hooks/use-bespoke-availability.ts
|
|
1502
|
+
import { useQuery as useQuery4, useMutation as useMutation3, useQueryClient as useQueryClient3 } from "@tanstack/react-query";
|
|
1503
|
+
|
|
1504
|
+
// src/services/bespoke.service.ts
|
|
1505
|
+
async function checkAvailability(config, product_id) {
|
|
1506
|
+
return apiRequest(`${config.backendUrls.store}/bespoke/capacity/availability/${product_id}`, {
|
|
1507
|
+
headers: storefrontHeaders(config),
|
|
1508
|
+
fallbackErrorMessage: "Failed to check bespoke availability"
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
async function approveBespokeDeliverable(config, accessToken, orderId) {
|
|
1512
|
+
return apiRequest(`${config.backendUrls.store}/orders/${orderId}/bespoke/approve`, {
|
|
1513
|
+
method: "POST",
|
|
1514
|
+
headers: { ...JSON_HEADERS, ...authHeaders(config, accessToken) },
|
|
1515
|
+
body: JSON.stringify({}),
|
|
1516
|
+
fallbackErrorMessage: "Failed to approve deliverable"
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
async function requestBespokeRevision(config, accessToken, orderId, notes) {
|
|
1520
|
+
return apiRequest(`${config.backendUrls.store}/orders/${orderId}/bespoke/request-revision`, {
|
|
1521
|
+
method: "POST",
|
|
1522
|
+
headers: { ...JSON_HEADERS, ...authHeaders(config, accessToken) },
|
|
1523
|
+
body: JSON.stringify({ notes }),
|
|
1524
|
+
fallbackErrorMessage: "Failed to request revision"
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// src/hooks/use-bespoke-availability.ts
|
|
1529
|
+
function useBespokeAvailability(product_id, config) {
|
|
1530
|
+
const ctx = useSdkContext();
|
|
1531
|
+
const { config: cfg } = resolveHookArgs(config, void 0, ctx);
|
|
1532
|
+
return useQuery4({
|
|
1533
|
+
queryKey: ["bespoke-availability", product_id],
|
|
1534
|
+
queryFn: () => checkAvailability(cfg, product_id),
|
|
1535
|
+
enabled: !!product_id,
|
|
1536
|
+
retry: false
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
function useApproveBespokeDeliverable(config, accessToken) {
|
|
1540
|
+
const ctx = useSdkContext();
|
|
1541
|
+
const { config: cfg, accessToken: token } = resolveHookArgs(config, accessToken, ctx);
|
|
1542
|
+
const queryClient = useQueryClient3();
|
|
1543
|
+
return useMutation3({
|
|
1544
|
+
mutationFn: (orderId) => token ? approveBespokeDeliverable(cfg, token, orderId) : Promise.reject(new Error("No access token available")),
|
|
1545
|
+
onSuccess: (_data, orderId) => {
|
|
1546
|
+
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
|
|
1547
|
+
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
|
1548
|
+
}
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
function useRequestBespokeRevision(config, accessToken) {
|
|
1552
|
+
const ctx = useSdkContext();
|
|
1553
|
+
const { config: cfg, accessToken: token } = resolveHookArgs(config, accessToken, ctx);
|
|
1554
|
+
const queryClient = useQueryClient3();
|
|
1555
|
+
return useMutation3({
|
|
1556
|
+
mutationFn: ({ orderId, notes }) => token ? requestBespokeRevision(cfg, token, orderId, notes) : Promise.reject(new Error("No access token available")),
|
|
1557
|
+
onSuccess: (_data, variables) => {
|
|
1558
|
+
queryClient.invalidateQueries({ queryKey: ["order", variables.orderId] });
|
|
1559
|
+
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// src/components/bespoke/BespokeAvailabilityBadge.tsx
|
|
1565
|
+
import { jsx as jsx14, jsxs as jsxs12 } from "react/jsx-runtime";
|
|
1566
|
+
function BespokeAvailabilityBadge({ productId, className = "inline-flex items-center gap-2 px-3 py-1 text-xs rounded-full font-medium" }) {
|
|
1567
|
+
const { data: availability, isLoading } = useBespokeAvailability(productId);
|
|
1568
|
+
if (isLoading) {
|
|
1569
|
+
return /* @__PURE__ */ jsx14("div", { className: "h-5 w-28 bg-gray-200 dark:bg-gray-800 animate-pulse rounded-full" });
|
|
1570
|
+
}
|
|
1571
|
+
if (!availability) return null;
|
|
1572
|
+
if (availability.is_available) {
|
|
1573
|
+
return /* @__PURE__ */ jsxs12("span", { className: `${className} bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800`, children: [
|
|
1574
|
+
/* @__PURE__ */ jsx14("span", { className: "w-2 h-2 rounded-full bg-emerald-500 animate-ping" }),
|
|
1575
|
+
availability.available_slots,
|
|
1576
|
+
" slots available this month"
|
|
1577
|
+
] });
|
|
1578
|
+
}
|
|
1579
|
+
return /* @__PURE__ */ jsxs12("span", { className: `${className} bg-amber-100 dark:bg-amber-950 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800`, children: [
|
|
1580
|
+
"Fully booked \u2014 Next available: ",
|
|
1581
|
+
availability.next_available_date ? new Date(availability.next_available_date).toLocaleDateString() : "Next Month"
|
|
1582
|
+
] });
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// src/components/bespoke/BespokeProgressTracker.tsx
|
|
1586
|
+
import { jsx as jsx15, jsxs as jsxs13 } from "react/jsx-runtime";
|
|
1587
|
+
function BespokeProgressTracker({
|
|
1588
|
+
stage,
|
|
1589
|
+
className = "flex items-center w-full my-4",
|
|
1590
|
+
activeStepClassName = "bg-emerald-600 text-white font-bold",
|
|
1591
|
+
completedStepClassName = "bg-emerald-500 text-white",
|
|
1592
|
+
inactiveStepClassName = "border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 text-gray-500"
|
|
1593
|
+
}) {
|
|
1594
|
+
const stages = [
|
|
1595
|
+
{ key: "received", label: "Order Received" },
|
|
1596
|
+
{ key: "development", label: "In Development" },
|
|
1597
|
+
{ key: "finished", label: "Finished" },
|
|
1598
|
+
{ key: "dispatched", label: "Ready / Dispatched" }
|
|
1599
|
+
];
|
|
1600
|
+
const currentIdx = stages.findIndex((s) => s.key === stage);
|
|
1601
|
+
return /* @__PURE__ */ jsx15("div", { className, children: stages.map((s, i) => /* @__PURE__ */ jsxs13("div", { className: "flex flex-1 flex-col items-center", children: [
|
|
1602
|
+
/* @__PURE__ */ jsxs13("div", { className: "flex w-full items-center", children: [
|
|
1603
|
+
i > 0 && /* @__PURE__ */ jsx15("div", { className: `h-0.5 flex-1 ${i <= currentIdx ? "bg-emerald-500" : "bg-gray-200 dark:bg-gray-800"}` }),
|
|
1604
|
+
/* @__PURE__ */ jsx15("div", { className: `flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold ${i < currentIdx ? completedStepClassName : i === currentIdx ? activeStepClassName : inactiveStepClassName}`, children: i < currentIdx ? "\u2713" : i + 1 }),
|
|
1605
|
+
i < stages.length - 1 && /* @__PURE__ */ jsx15("div", { className: `h-0.5 flex-1 ${i < currentIdx ? "bg-emerald-500" : "bg-gray-200 dark:bg-gray-800"}` })
|
|
1606
|
+
] }),
|
|
1607
|
+
/* @__PURE__ */ jsx15("p", { className: `mt-2 text-center text-xs ${i === currentIdx ? "font-semibold text-gray-900 dark:text-gray-100" : "text-gray-500"}`, children: s.label })
|
|
1608
|
+
] }, s.key)) });
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// src/components/orders/DeliverableReviewer.tsx
|
|
1612
|
+
import { useState as useState5 } from "react";
|
|
1613
|
+
import { jsx as jsx16, jsxs as jsxs14 } from "react/jsx-runtime";
|
|
1614
|
+
function DeliverableReviewer({ order, className = "space-y-4 p-4 border rounded-xl bg-muted/50" }) {
|
|
1615
|
+
const [revisionNotes, setRevisionNotes] = useState5("");
|
|
1616
|
+
const [showRevisionForm, setShowRevisionForm] = useState5(false);
|
|
1617
|
+
const approveMutation = useApproveBespokeDeliverable();
|
|
1618
|
+
const revisionMutation = useRequestBespokeRevision();
|
|
1619
|
+
if (!order.bespoke_deliverable_submitted_at) {
|
|
1620
|
+
return /* @__PURE__ */ jsxs14("div", { className, children: [
|
|
1621
|
+
/* @__PURE__ */ jsx16("h4", { className: "text-xs font-bold uppercase tracking-wider text-foreground", children: "Deliverable" }),
|
|
1622
|
+
/* @__PURE__ */ jsx16("p", { className: "text-xs text-muted-foreground py-4 text-center", children: "The seller hasn't submitted a deliverable for review yet." })
|
|
1623
|
+
] });
|
|
1624
|
+
}
|
|
1625
|
+
const awaitingApproval = order.bespoke_status === "awaiting_approval";
|
|
1626
|
+
const handleApprove = async () => {
|
|
1627
|
+
try {
|
|
1628
|
+
await approveMutation.mutateAsync(order.id);
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
logger.error(error);
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
const handleRequestRevision = async () => {
|
|
1634
|
+
if (!revisionNotes.trim() || revisionNotes.trim().length < 10) return;
|
|
1635
|
+
try {
|
|
1636
|
+
await revisionMutation.mutateAsync({ orderId: order.id, notes: revisionNotes.trim() });
|
|
1637
|
+
setShowRevisionForm(false);
|
|
1638
|
+
setRevisionNotes("");
|
|
1639
|
+
} catch (error) {
|
|
1640
|
+
logger.error(error);
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
return /* @__PURE__ */ jsxs14("div", { className, children: [
|
|
1644
|
+
/* @__PURE__ */ jsxs14("div", { className: "flex justify-between items-center", children: [
|
|
1645
|
+
/* @__PURE__ */ jsx16("h4", { className: "text-xs font-bold uppercase tracking-wider text-foreground", children: "Deliverable" }),
|
|
1646
|
+
order.bespoke_status && /* @__PURE__ */ jsx16("span", { className: `px-2 py-0.5 rounded text-[10px] font-semibold uppercase ${order.bespoke_status === "confirmed" ? "bg-primary/10 text-primary" : order.bespoke_status === "awaiting_approval" ? "bg-blue-500/10 text-blue-600 dark:text-blue-400" : "bg-amber-500/10 text-amber-600 dark:text-amber-400"}`, children: order.bespoke_status.replace("_", " ") })
|
|
1647
|
+
] }),
|
|
1648
|
+
/* @__PURE__ */ jsxs14("div", { className: "p-3 bg-card border rounded-lg space-y-2", children: [
|
|
1649
|
+
order.bespoke_deliverable_url && /* @__PURE__ */ jsx16(
|
|
1650
|
+
"a",
|
|
1651
|
+
{
|
|
1652
|
+
href: order.bespoke_deliverable_url,
|
|
1653
|
+
target: "_blank",
|
|
1654
|
+
rel: "noreferrer",
|
|
1655
|
+
className: "text-xs text-primary hover:underline flex items-center gap-1",
|
|
1656
|
+
children: "\u{1F4CE} View Deliverable"
|
|
1657
|
+
}
|
|
1658
|
+
),
|
|
1659
|
+
order.bespoke_deliverable_notes && /* @__PURE__ */ jsx16("p", { className: "text-xs text-muted-foreground", children: order.bespoke_deliverable_notes }),
|
|
1660
|
+
(order.bespoke_revision_count ?? 0) > 0 && /* @__PURE__ */ jsxs14("p", { className: "text-[10px] text-muted-foreground", children: [
|
|
1661
|
+
"Revisions requested: ",
|
|
1662
|
+
order.bespoke_revision_count
|
|
1663
|
+
] }),
|
|
1664
|
+
awaitingApproval && /* @__PURE__ */ jsxs14("div", { className: "flex items-center gap-2 pt-2", children: [
|
|
1665
|
+
/* @__PURE__ */ jsx16(
|
|
1666
|
+
"button",
|
|
1667
|
+
{
|
|
1668
|
+
type: "button",
|
|
1669
|
+
onClick: handleApprove,
|
|
1670
|
+
disabled: approveMutation.isPending,
|
|
1671
|
+
className: "px-3 py-1.5 bg-primary text-primary-foreground text-xs font-medium rounded hover:bg-primary/90 disabled:opacity-50",
|
|
1672
|
+
children: approveMutation.isPending ? "Approving\u2026" : "Approve Deliverable"
|
|
1673
|
+
}
|
|
1674
|
+
),
|
|
1675
|
+
/* @__PURE__ */ jsx16(
|
|
1676
|
+
"button",
|
|
1677
|
+
{
|
|
1678
|
+
type: "button",
|
|
1679
|
+
onClick: () => setShowRevisionForm(!showRevisionForm),
|
|
1680
|
+
className: "px-3 py-1.5 border text-xs font-medium rounded hover:bg-muted",
|
|
1681
|
+
children: "Request Revision"
|
|
1682
|
+
}
|
|
1683
|
+
)
|
|
1684
|
+
] }),
|
|
1685
|
+
showRevisionForm && /* @__PURE__ */ jsxs14("div", { className: "pt-2 space-y-2", children: [
|
|
1686
|
+
/* @__PURE__ */ jsx16(
|
|
1687
|
+
"textarea",
|
|
1688
|
+
{
|
|
1689
|
+
rows: 2,
|
|
1690
|
+
value: revisionNotes,
|
|
1691
|
+
onChange: (e) => setRevisionNotes(e.target.value),
|
|
1692
|
+
placeholder: "Explain what needs to change (at least 10 characters)...",
|
|
1693
|
+
className: "w-full p-2 border rounded text-xs bg-transparent"
|
|
1694
|
+
}
|
|
1695
|
+
),
|
|
1696
|
+
/* @__PURE__ */ jsx16(
|
|
1697
|
+
"button",
|
|
1698
|
+
{
|
|
1699
|
+
type: "button",
|
|
1700
|
+
onClick: handleRequestRevision,
|
|
1701
|
+
disabled: revisionMutation.isPending || revisionNotes.trim().length < 10,
|
|
1702
|
+
className: "px-3 py-1 bg-amber-600 text-white text-xs font-medium rounded hover:bg-amber-700 disabled:opacity-50",
|
|
1703
|
+
children: revisionMutation.isPending ? "Submitting\u2026" : "Submit Revision Request"
|
|
1704
|
+
}
|
|
1705
|
+
)
|
|
1706
|
+
] })
|
|
1707
|
+
] })
|
|
1708
|
+
] });
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// src/components/shared/TruncatedValue.tsx
|
|
1712
|
+
import { jsx as jsx17 } from "react/jsx-runtime";
|
|
1713
|
+
function TruncatedValue({ value, fullValue, className = "", triggerClassName = "" }) {
|
|
1714
|
+
const tooltipText = typeof fullValue === "string" ? fullValue : typeof value === "string" ? value : void 0;
|
|
1715
|
+
return /* @__PURE__ */ jsx17(
|
|
1716
|
+
"span",
|
|
1717
|
+
{
|
|
1718
|
+
title: tooltipText,
|
|
1719
|
+
className: `truncate tabular-nums cursor-default inline-block max-w-full ${triggerClassName} ${className}`.trim(),
|
|
1720
|
+
children: value
|
|
1721
|
+
}
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// src/components/shared/AddressAutocomplete.tsx
|
|
1726
|
+
import { useEffect as useEffect2, useId as useId2, useRef as useRef2, useState as useState6 } from "react";
|
|
1727
|
+
import { MapPin, Loader2, Check } from "lucide-react";
|
|
1728
|
+
|
|
1729
|
+
// src/services/logistics.service.ts
|
|
1730
|
+
async function autocompleteAddress(config, q, country) {
|
|
1731
|
+
const params = new URLSearchParams();
|
|
1732
|
+
params.set("q", q);
|
|
1733
|
+
if (country) params.set("country", country);
|
|
1734
|
+
return apiRequest(`${config.backendUrls.store}/logistics/address/autocomplete?${params.toString()}`, {
|
|
1735
|
+
headers: storefrontHeaders(config),
|
|
1736
|
+
fallbackErrorMessage: "Failed to autocomplete address"
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// src/components/shared/AddressAutocomplete.tsx
|
|
1741
|
+
import { jsx as jsx18, jsxs as jsxs15 } from "react/jsx-runtime";
|
|
1742
|
+
function AddressAutocomplete({
|
|
1743
|
+
value,
|
|
1744
|
+
onChange,
|
|
1745
|
+
onResolve,
|
|
1746
|
+
country,
|
|
1747
|
+
disabled,
|
|
1748
|
+
placeholder = "Start typing an address\u2026",
|
|
1749
|
+
className = "",
|
|
1750
|
+
id,
|
|
1751
|
+
config,
|
|
1752
|
+
...rest
|
|
1753
|
+
}) {
|
|
1754
|
+
const ctx = useSdkContext();
|
|
1755
|
+
const generatedId = useId2();
|
|
1756
|
+
const fieldId = id ?? generatedId;
|
|
1757
|
+
const [open, setOpen] = useState6(false);
|
|
1758
|
+
const [highlight, setHighlight] = useState6(-1);
|
|
1759
|
+
const [justPicked, setJustPicked] = useState6(false);
|
|
1760
|
+
const [suggestions, setSuggestions] = useState6([]);
|
|
1761
|
+
const [isLoading, setIsLoading] = useState6(false);
|
|
1762
|
+
const wrapRef = useRef2(null);
|
|
1763
|
+
useEffect2(() => {
|
|
1764
|
+
if (!open || justPicked || disabled || !value || value.trim().length < 3) {
|
|
1765
|
+
setSuggestions([]);
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
let isMounted = true;
|
|
1769
|
+
const timer = setTimeout(async () => {
|
|
1770
|
+
setIsLoading(true);
|
|
1771
|
+
try {
|
|
1772
|
+
const { config: cfg } = resolveHookArgs(config, void 0, ctx);
|
|
1773
|
+
const { results } = await autocompleteAddress(cfg, value, country);
|
|
1774
|
+
if (isMounted) {
|
|
1775
|
+
setSuggestions(
|
|
1776
|
+
results.map((r) => ({
|
|
1777
|
+
place_id: r.place_id,
|
|
1778
|
+
display_name: r.display_name,
|
|
1779
|
+
line1: r.line1,
|
|
1780
|
+
city: r.city ?? void 0,
|
|
1781
|
+
state: r.state ?? void 0,
|
|
1782
|
+
postal_code: r.postal_code ?? void 0,
|
|
1783
|
+
country: r.country_code ?? r.country ?? void 0,
|
|
1784
|
+
latitude: r.latitude ?? void 0,
|
|
1785
|
+
longitude: r.longitude ?? void 0
|
|
1786
|
+
}))
|
|
1787
|
+
);
|
|
1788
|
+
}
|
|
1789
|
+
} catch {
|
|
1790
|
+
if (isMounted) setSuggestions([]);
|
|
1791
|
+
} finally {
|
|
1792
|
+
if (isMounted) setIsLoading(false);
|
|
1793
|
+
}
|
|
1794
|
+
}, 350);
|
|
1795
|
+
return () => {
|
|
1796
|
+
isMounted = false;
|
|
1797
|
+
clearTimeout(timer);
|
|
1798
|
+
};
|
|
1799
|
+
}, [value, country, open, justPicked, disabled, config, ctx]);
|
|
1800
|
+
useEffect2(() => {
|
|
1801
|
+
const onDocClick = (e) => {
|
|
1802
|
+
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
|
|
1803
|
+
};
|
|
1804
|
+
document.addEventListener("mousedown", onDocClick);
|
|
1805
|
+
return () => document.removeEventListener("mousedown", onDocClick);
|
|
1806
|
+
}, []);
|
|
1807
|
+
const pick = (s) => {
|
|
1808
|
+
onChange(s.line1 || value);
|
|
1809
|
+
onResolve?.(s);
|
|
1810
|
+
setJustPicked(true);
|
|
1811
|
+
setOpen(false);
|
|
1812
|
+
setHighlight(-1);
|
|
1813
|
+
};
|
|
1814
|
+
const showDropdown = open && !justPicked && (isLoading || suggestions.length > 0);
|
|
1815
|
+
return /* @__PURE__ */ jsxs15("div", { ref: wrapRef, className: "relative w-full", children: [
|
|
1816
|
+
/* @__PURE__ */ jsxs15("div", { className: "relative flex items-center", children: [
|
|
1817
|
+
/* @__PURE__ */ jsx18(MapPin, { className: "absolute left-3 w-4 h-4 text-muted-foreground pointer-events-none" }),
|
|
1818
|
+
/* @__PURE__ */ jsx18(
|
|
1819
|
+
"input",
|
|
1820
|
+
{
|
|
1821
|
+
id: fieldId,
|
|
1822
|
+
value,
|
|
1823
|
+
disabled,
|
|
1824
|
+
placeholder,
|
|
1825
|
+
autoComplete: "off",
|
|
1826
|
+
className: `w-full h-10 pl-9 pr-9 py-2 text-sm bg-background border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50 ${className}`.trim(),
|
|
1827
|
+
onChange: (e) => {
|
|
1828
|
+
onChange(e.target.value);
|
|
1829
|
+
setJustPicked(false);
|
|
1830
|
+
setOpen(true);
|
|
1831
|
+
},
|
|
1832
|
+
onFocus: () => {
|
|
1833
|
+
if (!justPicked) setOpen(true);
|
|
1834
|
+
},
|
|
1835
|
+
onKeyDown: (e) => {
|
|
1836
|
+
if (!showDropdown) return;
|
|
1837
|
+
if (e.key === "ArrowDown") {
|
|
1838
|
+
e.preventDefault();
|
|
1839
|
+
setHighlight((h) => Math.min(h + 1, suggestions.length - 1));
|
|
1840
|
+
} else if (e.key === "ArrowUp") {
|
|
1841
|
+
e.preventDefault();
|
|
1842
|
+
setHighlight((h) => Math.max(h - 1, 0));
|
|
1843
|
+
} else if (e.key === "Enter" && highlight >= 0 && suggestions[highlight]) {
|
|
1844
|
+
e.preventDefault();
|
|
1845
|
+
pick(suggestions[highlight]);
|
|
1846
|
+
} else if (e.key === "Escape") {
|
|
1847
|
+
setOpen(false);
|
|
1848
|
+
}
|
|
1849
|
+
},
|
|
1850
|
+
...rest
|
|
1851
|
+
}
|
|
1852
|
+
),
|
|
1853
|
+
isLoading ? /* @__PURE__ */ jsx18(Loader2, { className: "absolute right-3 w-4 h-4 text-muted-foreground animate-spin" }) : justPicked ? /* @__PURE__ */ jsx18(Check, { className: "absolute right-3 w-4 h-4 text-primary" }) : null
|
|
1854
|
+
] }),
|
|
1855
|
+
showDropdown && /* @__PURE__ */ jsx18("div", { className: "absolute z-50 w-full mt-1 bg-card border rounded-lg shadow-lg max-h-60 overflow-y-auto", children: isLoading && suggestions.length === 0 ? /* @__PURE__ */ jsxs15("div", { className: "flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground", children: [
|
|
1856
|
+
/* @__PURE__ */ jsx18(Loader2, { className: "w-3.5 h-3.5 animate-spin" }),
|
|
1857
|
+
" Searching addresses\u2026"
|
|
1858
|
+
] }) : suggestions.length === 0 ? /* @__PURE__ */ jsx18("div", { className: "px-3 py-3 text-sm text-muted-foreground", children: "No matches found. Enter address manually." }) : suggestions.map((s, i) => /* @__PURE__ */ jsxs15(
|
|
1859
|
+
"button",
|
|
1860
|
+
{
|
|
1861
|
+
type: "button",
|
|
1862
|
+
onMouseEnter: () => setHighlight(i),
|
|
1863
|
+
onClick: () => pick(s),
|
|
1864
|
+
className: `w-full text-left flex items-start gap-2 px-3 py-2 text-sm transition-colors ${i === highlight ? "bg-muted" : "hover:bg-muted/50"}`,
|
|
1865
|
+
children: [
|
|
1866
|
+
/* @__PURE__ */ jsx18(MapPin, { className: "w-3.5 h-3.5 mt-0.5 text-muted-foreground shrink-0" }),
|
|
1867
|
+
/* @__PURE__ */ jsxs15("span", { className: "min-w-0", children: [
|
|
1868
|
+
/* @__PURE__ */ jsx18("span", { className: "block font-medium text-foreground truncate", children: s.line1 || s.display_name }),
|
|
1869
|
+
/* @__PURE__ */ jsx18("span", { className: "block text-xs text-muted-foreground truncate", children: s.display_name })
|
|
1870
|
+
] })
|
|
1871
|
+
]
|
|
1872
|
+
},
|
|
1873
|
+
s.place_id || `${s.display_name}-${i}`
|
|
1874
|
+
)) })
|
|
1875
|
+
] });
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// src/components/shared/OfflineBanner.tsx
|
|
1879
|
+
import { useEffect as useEffect3, useState as useState7 } from "react";
|
|
1880
|
+
import { WifiOff } from "lucide-react";
|
|
1881
|
+
import { jsx as jsx19, jsxs as jsxs16 } from "react/jsx-runtime";
|
|
1882
|
+
function OfflineBanner({
|
|
1883
|
+
message = "You are currently offline. Check your network connection.",
|
|
1884
|
+
className = ""
|
|
1885
|
+
}) {
|
|
1886
|
+
const [isOffline, setIsOffline] = useState7(false);
|
|
1887
|
+
useEffect3(() => {
|
|
1888
|
+
if (typeof window === "undefined") return;
|
|
1889
|
+
const handleOnline = () => setIsOffline(false);
|
|
1890
|
+
const handleOffline = () => setIsOffline(true);
|
|
1891
|
+
setIsOffline(!navigator.onLine);
|
|
1892
|
+
window.addEventListener("online", handleOnline);
|
|
1893
|
+
window.addEventListener("offline", handleOffline);
|
|
1894
|
+
return () => {
|
|
1895
|
+
window.removeEventListener("online", handleOnline);
|
|
1896
|
+
window.removeEventListener("offline", handleOffline);
|
|
1897
|
+
};
|
|
1898
|
+
}, []);
|
|
1899
|
+
if (!isOffline) return null;
|
|
1900
|
+
return /* @__PURE__ */ jsx19(
|
|
1901
|
+
"div",
|
|
1902
|
+
{
|
|
1903
|
+
className: `bg-amber-500/10 border-b border-amber-500/20 px-4 py-2 text-amber-700 dark:text-amber-400 text-xs font-medium flex items-center justify-between animate-in slide-in-from-top-2 duration-300 ${className}`.trim(),
|
|
1904
|
+
children: /* @__PURE__ */ jsxs16("div", { className: "flex items-center gap-2", children: [
|
|
1905
|
+
/* @__PURE__ */ jsx19(WifiOff, { className: "w-4 h-4 text-amber-500 shrink-0" }),
|
|
1906
|
+
/* @__PURE__ */ jsx19("span", { children: message })
|
|
1907
|
+
] })
|
|
1908
|
+
}
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// src/components/shared/MapPinDrop.tsx
|
|
1913
|
+
import { useEffect as useEffect4, useState as useState8 } from "react";
|
|
1914
|
+
import { Loader2 as Loader22 } from "lucide-react";
|
|
1915
|
+
import { jsx as jsx20, jsxs as jsxs17 } from "react/jsx-runtime";
|
|
1916
|
+
function MapPinDrop({
|
|
1917
|
+
initialLatitude = 6.5244,
|
|
1918
|
+
initialLongitude = 3.3792,
|
|
1919
|
+
onPinDrop,
|
|
1920
|
+
value,
|
|
1921
|
+
height = "300px",
|
|
1922
|
+
disabled = false
|
|
1923
|
+
}) {
|
|
1924
|
+
const [isMounted, setIsMounted] = useState8(false);
|
|
1925
|
+
const [lat, setLat] = useState8(value?.latitude ?? initialLatitude);
|
|
1926
|
+
const [lng, setLng] = useState8(value?.longitude ?? initialLongitude);
|
|
1927
|
+
useEffect4(() => {
|
|
1928
|
+
setIsMounted(true);
|
|
1929
|
+
}, []);
|
|
1930
|
+
useEffect4(() => {
|
|
1931
|
+
if (value) {
|
|
1932
|
+
setLat(value.latitude);
|
|
1933
|
+
setLng(value.longitude);
|
|
1934
|
+
}
|
|
1935
|
+
}, [value]);
|
|
1936
|
+
if (!isMounted) {
|
|
1937
|
+
return /* @__PURE__ */ jsx20(
|
|
1938
|
+
"div",
|
|
1939
|
+
{
|
|
1940
|
+
className: "w-full rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 flex items-center justify-center",
|
|
1941
|
+
style: { height },
|
|
1942
|
+
children: /* @__PURE__ */ jsx20(Loader22, { className: "w-6 h-6 text-gray-400 animate-spin" })
|
|
1943
|
+
}
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
return /* @__PURE__ */ jsxs17("div", { className: "w-full rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900", children: [
|
|
1947
|
+
/* @__PURE__ */ jsxs17("div", { className: "p-4 flex flex-col gap-3", children: [
|
|
1948
|
+
/* @__PURE__ */ jsxs17("div", { className: "flex items-center justify-between text-xs text-gray-500", children: [
|
|
1949
|
+
/* @__PURE__ */ jsx20("span", { children: "Coordinates Selector" }),
|
|
1950
|
+
/* @__PURE__ */ jsxs17("span", { children: [
|
|
1951
|
+
lat.toFixed(6),
|
|
1952
|
+
", ",
|
|
1953
|
+
lng.toFixed(6)
|
|
1954
|
+
] })
|
|
1955
|
+
] }),
|
|
1956
|
+
!disabled && /* @__PURE__ */ jsxs17("div", { className: "grid grid-cols-2 gap-2", children: [
|
|
1957
|
+
/* @__PURE__ */ jsxs17("div", { children: [
|
|
1958
|
+
/* @__PURE__ */ jsx20("label", { className: "block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1", children: "Latitude" }),
|
|
1959
|
+
/* @__PURE__ */ jsx20(
|
|
1960
|
+
"input",
|
|
1961
|
+
{
|
|
1962
|
+
type: "number",
|
|
1963
|
+
step: "any",
|
|
1964
|
+
value: lat,
|
|
1965
|
+
onChange: (e) => {
|
|
1966
|
+
const val = parseFloat(e.target.value) || 0;
|
|
1967
|
+
setLat(val);
|
|
1968
|
+
onPinDrop?.({ latitude: val, longitude: lng });
|
|
1969
|
+
},
|
|
1970
|
+
className: "w-full h-9 px-2 text-xs border border-gray-300 dark:border-gray-700 rounded bg-transparent"
|
|
1971
|
+
}
|
|
1972
|
+
)
|
|
1973
|
+
] }),
|
|
1974
|
+
/* @__PURE__ */ jsxs17("div", { children: [
|
|
1975
|
+
/* @__PURE__ */ jsx20("label", { className: "block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1", children: "Longitude" }),
|
|
1976
|
+
/* @__PURE__ */ jsx20(
|
|
1977
|
+
"input",
|
|
1978
|
+
{
|
|
1979
|
+
type: "number",
|
|
1980
|
+
step: "any",
|
|
1981
|
+
value: lng,
|
|
1982
|
+
onChange: (e) => {
|
|
1983
|
+
const val = parseFloat(e.target.value) || 0;
|
|
1984
|
+
setLng(val);
|
|
1985
|
+
onPinDrop?.({ latitude: lat, longitude: val });
|
|
1986
|
+
},
|
|
1987
|
+
className: "w-full h-9 px-2 text-xs border border-gray-300 dark:border-gray-700 rounded bg-transparent"
|
|
1988
|
+
}
|
|
1989
|
+
)
|
|
1990
|
+
] })
|
|
1991
|
+
] })
|
|
1992
|
+
] }),
|
|
1993
|
+
/* @__PURE__ */ jsx20("div", { className: "px-3 py-2 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500", children: disabled ? "Location view only" : "Adjust coordinates to fine-tune map location" })
|
|
1994
|
+
] });
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// src/components/shared/SearchableInput.tsx
|
|
1998
|
+
import { useState as useState9, useRef as useRef3, useEffect as useEffect5 } from "react";
|
|
1999
|
+
import { Check as Check2, X } from "lucide-react";
|
|
2000
|
+
import { jsx as jsx21, jsxs as jsxs18 } from "react/jsx-runtime";
|
|
2001
|
+
function SearchableInput({
|
|
2002
|
+
items,
|
|
2003
|
+
value,
|
|
2004
|
+
onSelect,
|
|
2005
|
+
placeholder = "Search...",
|
|
2006
|
+
disabled = false,
|
|
2007
|
+
isLoading = false,
|
|
2008
|
+
className = "",
|
|
2009
|
+
onSearchChange,
|
|
2010
|
+
debounceMs = 300
|
|
2011
|
+
}) {
|
|
2012
|
+
const [isOpen, setIsOpen] = useState9(false);
|
|
2013
|
+
const [search, setSearch] = useState9("");
|
|
2014
|
+
const inputRef = useRef3(null);
|
|
2015
|
+
const dropdownRef = useRef3(null);
|
|
2016
|
+
const debounceRef = useRef3(void 0);
|
|
2017
|
+
const selectedItem = items.find((item) => item.value === value);
|
|
2018
|
+
useEffect5(() => {
|
|
2019
|
+
if (selectedItem) {
|
|
2020
|
+
setSearch(selectedItem.label);
|
|
2021
|
+
}
|
|
2022
|
+
}, [selectedItem]);
|
|
2023
|
+
const handleSearchChange = (newValue) => {
|
|
2024
|
+
setSearch(newValue);
|
|
2025
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
2026
|
+
debounceRef.current = setTimeout(() => {
|
|
2027
|
+
onSearchChange?.(newValue);
|
|
2028
|
+
}, debounceMs);
|
|
2029
|
+
};
|
|
2030
|
+
const handleSelect = (item) => {
|
|
2031
|
+
onSelect(item.value);
|
|
2032
|
+
setSearch(item.label);
|
|
2033
|
+
setIsOpen(false);
|
|
2034
|
+
};
|
|
2035
|
+
const handleClear = () => {
|
|
2036
|
+
setSearch("");
|
|
2037
|
+
onSelect("");
|
|
2038
|
+
inputRef.current?.focus();
|
|
2039
|
+
};
|
|
2040
|
+
const serverSearch = typeof onSearchChange === "function";
|
|
2041
|
+
const filteredItems = serverSearch ? items : items.filter(
|
|
2042
|
+
(item) => item.label.toLowerCase().includes(search.toLowerCase()) || item.description?.toLowerCase().includes(search.toLowerCase())
|
|
2043
|
+
);
|
|
2044
|
+
useEffect5(() => {
|
|
2045
|
+
const handleClickOutside = (event) => {
|
|
2046
|
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target) && inputRef.current && !inputRef.current.contains(event.target)) {
|
|
2047
|
+
setIsOpen(false);
|
|
2048
|
+
}
|
|
2049
|
+
};
|
|
2050
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
2051
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
2052
|
+
}, []);
|
|
2053
|
+
return /* @__PURE__ */ jsxs18("div", { className: "relative w-full", children: [
|
|
2054
|
+
/* @__PURE__ */ jsxs18("div", { className: "relative", children: [
|
|
2055
|
+
/* @__PURE__ */ jsx21(
|
|
2056
|
+
"input",
|
|
2057
|
+
{
|
|
2058
|
+
ref: inputRef,
|
|
2059
|
+
value: search,
|
|
2060
|
+
onChange: (e) => handleSearchChange(e.target.value),
|
|
2061
|
+
onFocus: () => setIsOpen(true),
|
|
2062
|
+
placeholder,
|
|
2063
|
+
disabled,
|
|
2064
|
+
className: `w-full h-10 px-3 pr-8 text-sm bg-background border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50 ${className}`.trim()
|
|
2065
|
+
}
|
|
2066
|
+
),
|
|
2067
|
+
search && !disabled && /* @__PURE__ */ jsx21(
|
|
2068
|
+
"button",
|
|
2069
|
+
{
|
|
2070
|
+
type: "button",
|
|
2071
|
+
onClick: handleClear,
|
|
2072
|
+
className: "absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground",
|
|
2073
|
+
children: /* @__PURE__ */ jsx21(X, { className: "w-4 h-4" })
|
|
2074
|
+
}
|
|
2075
|
+
)
|
|
2076
|
+
] }),
|
|
2077
|
+
isOpen && /* @__PURE__ */ jsx21(
|
|
2078
|
+
"div",
|
|
2079
|
+
{
|
|
2080
|
+
ref: dropdownRef,
|
|
2081
|
+
className: "absolute z-50 w-full mt-1 bg-card border rounded-lg shadow-lg max-h-64 overflow-y-auto",
|
|
2082
|
+
children: isLoading ? /* @__PURE__ */ jsx21("div", { className: "p-3 text-center text-xs text-muted-foreground", children: "Loading options\u2026" }) : filteredItems.length > 0 ? filteredItems.map((item) => /* @__PURE__ */ jsxs18(
|
|
2083
|
+
"div",
|
|
2084
|
+
{
|
|
2085
|
+
onClick: () => handleSelect(item),
|
|
2086
|
+
className: "flex items-center justify-between py-2 px-3 cursor-pointer hover:bg-muted transition-colors",
|
|
2087
|
+
children: [
|
|
2088
|
+
/* @__PURE__ */ jsxs18("div", { className: "flex items-center gap-3 flex-1 min-w-0", children: [
|
|
2089
|
+
item.icon,
|
|
2090
|
+
/* @__PURE__ */ jsxs18("div", { className: "flex-1 min-w-0", children: [
|
|
2091
|
+
/* @__PURE__ */ jsx21("div", { className: "font-medium text-sm text-foreground truncate", children: item.label }),
|
|
2092
|
+
item.description && /* @__PURE__ */ jsx21("div", { className: "text-xs text-muted-foreground truncate", children: item.description })
|
|
2093
|
+
] })
|
|
2094
|
+
] }),
|
|
2095
|
+
value === item.value && /* @__PURE__ */ jsx21(Check2, { className: "w-4 h-4 text-primary shrink-0" })
|
|
2096
|
+
]
|
|
2097
|
+
},
|
|
2098
|
+
item.value
|
|
2099
|
+
)) : /* @__PURE__ */ jsx21("div", { className: "p-4 text-center text-sm text-muted-foreground", children: "No results found" })
|
|
2100
|
+
}
|
|
2101
|
+
)
|
|
2102
|
+
] });
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// src/components/shared/SearchablePopover.tsx
|
|
2106
|
+
import { useEffect as useEffect6, useRef as useRef4, useState as useState10 } from "react";
|
|
2107
|
+
import { Check as Check3, ChevronsUpDown, Loader2 as Loader23 } from "lucide-react";
|
|
2108
|
+
import { jsx as jsx22, jsxs as jsxs19 } from "react/jsx-runtime";
|
|
2109
|
+
function SearchablePopover({
|
|
2110
|
+
items,
|
|
2111
|
+
value,
|
|
2112
|
+
onSelect,
|
|
2113
|
+
placeholder = "Select an option",
|
|
2114
|
+
searchPlaceholder = "Search...",
|
|
2115
|
+
emptyMessage = "No results found.",
|
|
2116
|
+
disabled = false,
|
|
2117
|
+
isLoading = false,
|
|
2118
|
+
triggerClassName = "",
|
|
2119
|
+
contentClassName = "",
|
|
2120
|
+
onSearchChange,
|
|
2121
|
+
searchValue,
|
|
2122
|
+
hasNextPage = false,
|
|
2123
|
+
isFetchingNextPage = false,
|
|
2124
|
+
onFetchNextPage,
|
|
2125
|
+
resolvedSelectedItem
|
|
2126
|
+
}) {
|
|
2127
|
+
const [open, setOpen] = useState10(false);
|
|
2128
|
+
const [query, setQuery] = useState10("");
|
|
2129
|
+
const popoverRef = useRef4(null);
|
|
2130
|
+
const serverSearch = typeof onSearchChange === "function";
|
|
2131
|
+
const activeSearch = serverSearch ? searchValue ?? "" : query;
|
|
2132
|
+
const selectedItem = resolvedSelectedItem ?? items.find((item) => item.value === value);
|
|
2133
|
+
useEffect6(() => {
|
|
2134
|
+
const handleClickOutside = (e) => {
|
|
2135
|
+
if (popoverRef.current && !popoverRef.current.contains(e.target)) {
|
|
2136
|
+
setOpen(false);
|
|
2137
|
+
}
|
|
2138
|
+
};
|
|
2139
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
2140
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
2141
|
+
}, []);
|
|
2142
|
+
const handleSearch = (text) => {
|
|
2143
|
+
if (serverSearch) {
|
|
2144
|
+
onSearchChange?.(text);
|
|
2145
|
+
} else {
|
|
2146
|
+
setQuery(text);
|
|
2147
|
+
}
|
|
2148
|
+
};
|
|
2149
|
+
const filteredItems = serverSearch ? items : items.filter(
|
|
2150
|
+
(item) => item.label.toLowerCase().includes(query.toLowerCase()) || item.description?.toLowerCase().includes(query.toLowerCase())
|
|
2151
|
+
);
|
|
2152
|
+
return /* @__PURE__ */ jsxs19("div", { ref: popoverRef, className: "relative w-full", children: [
|
|
2153
|
+
/* @__PURE__ */ jsxs19(
|
|
2154
|
+
"button",
|
|
2155
|
+
{
|
|
2156
|
+
type: "button",
|
|
2157
|
+
disabled,
|
|
2158
|
+
onClick: () => setOpen((prev) => !prev),
|
|
2159
|
+
className: `w-full h-10 px-3 py-2 text-sm bg-background border border-input rounded-lg flex items-center justify-between font-medium text-foreground disabled:opacity-50 ${triggerClassName}`.trim(),
|
|
2160
|
+
children: [
|
|
2161
|
+
selectedItem ? /* @__PURE__ */ jsxs19("div", { className: "flex items-center gap-2 truncate", children: [
|
|
2162
|
+
selectedItem.icon,
|
|
2163
|
+
/* @__PURE__ */ jsx22("span", { children: selectedItem.label })
|
|
2164
|
+
] }) : /* @__PURE__ */ jsx22("span", { className: "text-muted-foreground", children: placeholder }),
|
|
2165
|
+
/* @__PURE__ */ jsx22(ChevronsUpDown, { className: "ml-2 h-4 w-4 shrink-0 text-muted-foreground" })
|
|
2166
|
+
]
|
|
2167
|
+
}
|
|
2168
|
+
),
|
|
2169
|
+
open && /* @__PURE__ */ jsxs19(
|
|
2170
|
+
"div",
|
|
2171
|
+
{
|
|
2172
|
+
className: `absolute z-50 mt-1 w-full bg-card border rounded-lg shadow-xl overflow-hidden p-2 ${contentClassName}`.trim(),
|
|
2173
|
+
children: [
|
|
2174
|
+
/* @__PURE__ */ jsx22(
|
|
2175
|
+
"input",
|
|
2176
|
+
{
|
|
2177
|
+
type: "text",
|
|
2178
|
+
value: activeSearch,
|
|
2179
|
+
onChange: (e) => handleSearch(e.target.value),
|
|
2180
|
+
placeholder: searchPlaceholder,
|
|
2181
|
+
className: "w-full h-9 px-3 text-xs bg-muted border border-input rounded mb-2 focus:outline-none"
|
|
2182
|
+
}
|
|
2183
|
+
),
|
|
2184
|
+
/* @__PURE__ */ jsxs19("div", { className: "max-h-60 overflow-y-auto space-y-1", children: [
|
|
2185
|
+
isLoading ? /* @__PURE__ */ jsxs19("div", { className: "p-3 text-center text-xs text-muted-foreground flex items-center justify-center gap-2", children: [
|
|
2186
|
+
/* @__PURE__ */ jsx22(Loader23, { className: "w-4 h-4 animate-spin" }),
|
|
2187
|
+
" Loading options\u2026"
|
|
2188
|
+
] }) : filteredItems.length > 0 ? filteredItems.map((item) => /* @__PURE__ */ jsxs19(
|
|
2189
|
+
"button",
|
|
2190
|
+
{
|
|
2191
|
+
type: "button",
|
|
2192
|
+
onClick: () => {
|
|
2193
|
+
onSelect(item.value);
|
|
2194
|
+
setOpen(false);
|
|
2195
|
+
},
|
|
2196
|
+
className: "w-full text-left flex items-center justify-between py-2 px-3 rounded text-xs hover:bg-muted transition-colors",
|
|
2197
|
+
children: [
|
|
2198
|
+
/* @__PURE__ */ jsxs19("div", { className: "flex items-center gap-2 min-w-0", children: [
|
|
2199
|
+
item.icon,
|
|
2200
|
+
/* @__PURE__ */ jsxs19("div", { className: "truncate", children: [
|
|
2201
|
+
/* @__PURE__ */ jsx22("div", { className: "font-semibold text-foreground", children: item.label }),
|
|
2202
|
+
item.description && /* @__PURE__ */ jsx22("div", { className: "text-[10px] text-muted-foreground", children: item.description })
|
|
2203
|
+
] })
|
|
2204
|
+
] }),
|
|
2205
|
+
value === item.value && /* @__PURE__ */ jsx22(Check3, { className: "h-4 w-4 text-primary shrink-0" })
|
|
2206
|
+
]
|
|
2207
|
+
},
|
|
2208
|
+
item.value
|
|
2209
|
+
)) : /* @__PURE__ */ jsx22("div", { className: "p-3 text-center text-xs text-muted-foreground", children: emptyMessage }),
|
|
2210
|
+
hasNextPage && /* @__PURE__ */ jsx22(
|
|
2211
|
+
"button",
|
|
2212
|
+
{
|
|
2213
|
+
type: "button",
|
|
2214
|
+
onClick: () => onFetchNextPage?.(),
|
|
2215
|
+
disabled: isFetchingNextPage,
|
|
2216
|
+
className: "w-full py-1 text-center text-[11px] text-primary font-semibold hover:underline",
|
|
2217
|
+
children: isFetchingNextPage ? "Loading more..." : "Load more..."
|
|
2218
|
+
}
|
|
2219
|
+
)
|
|
2220
|
+
] })
|
|
2221
|
+
]
|
|
2222
|
+
}
|
|
2223
|
+
)
|
|
2224
|
+
] });
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// src/components/shared/LucideReactIcon.tsx
|
|
2228
|
+
import * as LucideIcons from "lucide-react";
|
|
2229
|
+
import { jsx as jsx23 } from "react/jsx-runtime";
|
|
2230
|
+
var toPascalCase = (str) => {
|
|
2231
|
+
return str.replace(/[-_]+/g, " ").replace(/\s+(.)/g, (_, c) => c.toUpperCase()).replace(/^(.)/, (_, c) => c.toUpperCase());
|
|
2232
|
+
};
|
|
2233
|
+
var LucideReactIcon = ({ icon, ...props }) => {
|
|
2234
|
+
if (!icon || typeof icon === "boolean" || typeof icon === "number" || Array.isArray(icon)) {
|
|
2235
|
+
return null;
|
|
2236
|
+
}
|
|
2237
|
+
if (typeof icon === "function" || typeof icon === "object" && icon !== null && ("$$typeof" in icon || "render" in icon)) {
|
|
2238
|
+
const Component = icon;
|
|
2239
|
+
return /* @__PURE__ */ jsx23(Component, { size: props.size ?? 24, color: props.color ?? "currentColor", strokeWidth: props.strokeWidth ?? 2, ...props });
|
|
2240
|
+
}
|
|
2241
|
+
const iconObj = typeof icon === "object" && icon !== null ? icon : null;
|
|
2242
|
+
const rawName = typeof icon === "string" ? icon : typeof iconObj?.name === "string" ? iconObj.name : typeof iconObj?.icon === "string" ? iconObj.icon : null;
|
|
2243
|
+
if (!rawName) return null;
|
|
2244
|
+
const pascalName = toPascalCase(rawName);
|
|
2245
|
+
const IconComponent = LucideIcons[pascalName] || LucideIcons[rawName];
|
|
2246
|
+
if (!IconComponent) {
|
|
2247
|
+
return /* @__PURE__ */ jsx23(
|
|
2248
|
+
"span",
|
|
2249
|
+
{
|
|
2250
|
+
style: {
|
|
2251
|
+
fontSize: props.size ?? 24,
|
|
2252
|
+
color: props.color ?? "currentColor"
|
|
2253
|
+
},
|
|
2254
|
+
className: "flex items-center justify-center text-xs font-medium text-center break-all leading-tight",
|
|
2255
|
+
children: rawName
|
|
2256
|
+
}
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2259
|
+
return /* @__PURE__ */ jsx23(IconComponent, { size: props.size ?? 24, color: props.color ?? "currentColor", strokeWidth: props.strokeWidth ?? 2, ...props });
|
|
2260
|
+
};
|
|
2261
|
+
|
|
2262
|
+
// src/components/shared/UnsavedChangesGuard.tsx
|
|
2263
|
+
import { useState as useState11, useEffect as useEffect7, useCallback, useRef as useRef5 } from "react";
|
|
2264
|
+
import { AlertTriangle } from "lucide-react";
|
|
2265
|
+
import { jsx as jsx24, jsxs as jsxs20 } from "react/jsx-runtime";
|
|
2266
|
+
function useUnsavedChanges(options = {}) {
|
|
2267
|
+
const {
|
|
2268
|
+
browserMessage = "You have unsaved changes. If you leave, your work will be lost.",
|
|
2269
|
+
dialogTitle = "Unsaved Changes",
|
|
2270
|
+
dialogBody = "You have unsaved changes. If you leave now, all your progress will be lost."
|
|
2271
|
+
} = options;
|
|
2272
|
+
const [isDirty, setIsDirty] = useState11(false);
|
|
2273
|
+
const [dialogOpen, setDialogOpen] = useState11(false);
|
|
2274
|
+
const pendingActionRef = useRef5(null);
|
|
2275
|
+
useEffect7(() => {
|
|
2276
|
+
if (!isDirty) return;
|
|
2277
|
+
const handler = (e) => {
|
|
2278
|
+
e.preventDefault();
|
|
2279
|
+
e.returnValue = browserMessage;
|
|
2280
|
+
return browserMessage;
|
|
2281
|
+
};
|
|
2282
|
+
window.addEventListener("beforeunload", handler);
|
|
2283
|
+
return () => window.removeEventListener("beforeunload", handler);
|
|
2284
|
+
}, [isDirty, browserMessage]);
|
|
2285
|
+
const markDirty = useCallback(() => setIsDirty(true), []);
|
|
2286
|
+
const resetDirty = useCallback(() => {
|
|
2287
|
+
setIsDirty(false);
|
|
2288
|
+
setDialogOpen(false);
|
|
2289
|
+
pendingActionRef.current = null;
|
|
2290
|
+
}, []);
|
|
2291
|
+
const guardedNavigate = useCallback(
|
|
2292
|
+
(action) => {
|
|
2293
|
+
if (!isDirty) {
|
|
2294
|
+
action();
|
|
2295
|
+
return;
|
|
2296
|
+
}
|
|
2297
|
+
pendingActionRef.current = action;
|
|
2298
|
+
setDialogOpen(true);
|
|
2299
|
+
},
|
|
2300
|
+
[isDirty]
|
|
2301
|
+
);
|
|
2302
|
+
const handleStay = useCallback(() => {
|
|
2303
|
+
setDialogOpen(false);
|
|
2304
|
+
pendingActionRef.current = null;
|
|
2305
|
+
}, []);
|
|
2306
|
+
const handleLeave = useCallback(() => {
|
|
2307
|
+
setDialogOpen(false);
|
|
2308
|
+
setIsDirty(false);
|
|
2309
|
+
const action = pendingActionRef.current;
|
|
2310
|
+
pendingActionRef.current = null;
|
|
2311
|
+
action?.();
|
|
2312
|
+
}, []);
|
|
2313
|
+
return {
|
|
2314
|
+
isDirty,
|
|
2315
|
+
markDirty,
|
|
2316
|
+
resetDirty,
|
|
2317
|
+
guardedNavigate,
|
|
2318
|
+
dialogProps: {
|
|
2319
|
+
open: dialogOpen,
|
|
2320
|
+
onStay: handleStay,
|
|
2321
|
+
onLeave: handleLeave,
|
|
2322
|
+
title: dialogTitle,
|
|
2323
|
+
body: dialogBody
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
function UnsavedChangesDialog({
|
|
2328
|
+
open,
|
|
2329
|
+
onStay,
|
|
2330
|
+
onLeave,
|
|
2331
|
+
title = "Unsaved Changes",
|
|
2332
|
+
body = "You have unsaved changes. If you leave now, all your progress will be lost."
|
|
2333
|
+
}) {
|
|
2334
|
+
if (!open) return null;
|
|
2335
|
+
return /* @__PURE__ */ jsx24("div", { className: "fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200", children: /* @__PURE__ */ jsxs20("div", { className: "w-full max-w-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-2xl p-6 shadow-2xl space-y-4", children: [
|
|
2336
|
+
/* @__PURE__ */ jsxs20("div", { className: "flex flex-col items-center text-center", children: [
|
|
2337
|
+
/* @__PURE__ */ jsx24("div", { className: "w-12 h-12 rounded-xl bg-amber-500/10 flex items-center justify-center mb-3 text-amber-500", children: /* @__PURE__ */ jsx24(AlertTriangle, { className: "w-6 h-6" }) }),
|
|
2338
|
+
/* @__PURE__ */ jsx24("h3", { className: "text-lg font-bold text-gray-900 dark:text-gray-100", children: title }),
|
|
2339
|
+
/* @__PURE__ */ jsx24("p", { className: "text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed", children: body })
|
|
2340
|
+
] }),
|
|
2341
|
+
/* @__PURE__ */ jsxs20("div", { className: "flex flex-col sm:flex-row gap-2 pt-2", children: [
|
|
2342
|
+
/* @__PURE__ */ jsx24(
|
|
2343
|
+
"button",
|
|
2344
|
+
{
|
|
2345
|
+
type: "button",
|
|
2346
|
+
onClick: onStay,
|
|
2347
|
+
className: "flex-1 h-10 px-4 text-xs font-semibold rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 hover:bg-gray-50",
|
|
2348
|
+
children: "Stay & Keep Editing"
|
|
2349
|
+
}
|
|
2350
|
+
),
|
|
2351
|
+
/* @__PURE__ */ jsx24(
|
|
2352
|
+
"button",
|
|
2353
|
+
{
|
|
2354
|
+
type: "button",
|
|
2355
|
+
onClick: onLeave,
|
|
2356
|
+
className: "flex-1 h-10 px-4 text-xs font-semibold rounded-xl bg-red-600 text-white hover:bg-red-700",
|
|
2357
|
+
children: "Leave Without Saving"
|
|
2358
|
+
}
|
|
2359
|
+
)
|
|
2360
|
+
] })
|
|
2361
|
+
] }) });
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
// src/components/shared/MultiSelectPicker.tsx
|
|
2365
|
+
import { useState as useState12, useRef as useRef6, useEffect as useEffect8 } from "react";
|
|
2366
|
+
import { X as X2, ChevronDown, Plus, Hash } from "lucide-react";
|
|
2367
|
+
import { Fragment as Fragment2, jsx as jsx25, jsxs as jsxs21 } from "react/jsx-runtime";
|
|
2368
|
+
function MultiSelectPicker({
|
|
2369
|
+
selected,
|
|
2370
|
+
onChange,
|
|
2371
|
+
options = [],
|
|
2372
|
+
isLoading = false,
|
|
2373
|
+
isFetchingNextPage = false,
|
|
2374
|
+
hasNextPage = false,
|
|
2375
|
+
onFetchNextPage,
|
|
2376
|
+
placeholder = "Search or type to add...",
|
|
2377
|
+
onCreateOption,
|
|
2378
|
+
onSearchChange,
|
|
2379
|
+
searchValue
|
|
2380
|
+
}) {
|
|
2381
|
+
const [open, setOpen] = useState12(false);
|
|
2382
|
+
const serverSearch = typeof onSearchChange === "function";
|
|
2383
|
+
const [localSearch, setLocalSearch] = useState12("");
|
|
2384
|
+
const search = serverSearch ? searchValue ?? "" : localSearch;
|
|
2385
|
+
const setSearch = serverSearch ? onSearchChange : setLocalSearch;
|
|
2386
|
+
const inputRef = useRef6(null);
|
|
2387
|
+
const containerRef = useRef6(null);
|
|
2388
|
+
useEffect8(() => {
|
|
2389
|
+
const handler = (e) => {
|
|
2390
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
2391
|
+
setOpen(false);
|
|
2392
|
+
setSearch("");
|
|
2393
|
+
}
|
|
2394
|
+
};
|
|
2395
|
+
document.addEventListener("mousedown", handler);
|
|
2396
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
2397
|
+
}, [setSearch]);
|
|
2398
|
+
const filtered = options.filter(
|
|
2399
|
+
(opt) => (serverSearch || opt.name.toLowerCase().includes(search.toLowerCase())) && !selected.includes(opt.name)
|
|
2400
|
+
);
|
|
2401
|
+
const showCustomOption = search.trim().length > 0 && !options.some((opt) => opt.name.toLowerCase() === search.trim().toLowerCase()) && !selected.some((s) => s.toLowerCase() === search.trim().toLowerCase());
|
|
2402
|
+
const addItem = (name) => {
|
|
2403
|
+
onChange([...selected, name]);
|
|
2404
|
+
setSearch("");
|
|
2405
|
+
inputRef.current?.focus();
|
|
2406
|
+
};
|
|
2407
|
+
const removeItem = (name) => {
|
|
2408
|
+
onChange(selected.filter((s) => s !== name));
|
|
2409
|
+
};
|
|
2410
|
+
return /* @__PURE__ */ jsxs21("div", { ref: containerRef, className: "relative w-full", children: [
|
|
2411
|
+
/* @__PURE__ */ jsxs21(
|
|
2412
|
+
"div",
|
|
2413
|
+
{
|
|
2414
|
+
onClick: () => {
|
|
2415
|
+
setOpen(true);
|
|
2416
|
+
inputRef.current?.focus();
|
|
2417
|
+
},
|
|
2418
|
+
className: `relative flex flex-wrap items-center gap-1.5 w-full cursor-text min-h-[42px] rounded-lg px-3 py-1.5 border border-input bg-background text-foreground ${open ? "ring-2 ring-ring border-ring" : ""}`,
|
|
2419
|
+
children: [
|
|
2420
|
+
selected.map((name) => /* @__PURE__ */ jsxs21(
|
|
2421
|
+
"span",
|
|
2422
|
+
{
|
|
2423
|
+
className: "inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-semibold bg-muted text-foreground border",
|
|
2424
|
+
children: [
|
|
2425
|
+
/* @__PURE__ */ jsx25("span", { className: "truncate max-w-[140px]", children: name }),
|
|
2426
|
+
/* @__PURE__ */ jsx25(
|
|
2427
|
+
"button",
|
|
2428
|
+
{
|
|
2429
|
+
type: "button",
|
|
2430
|
+
onClick: (e) => {
|
|
2431
|
+
e.stopPropagation();
|
|
2432
|
+
removeItem(name);
|
|
2433
|
+
},
|
|
2434
|
+
className: "rounded p-0.5 hover:bg-accent",
|
|
2435
|
+
children: /* @__PURE__ */ jsx25(X2, { className: "h-3 w-3" })
|
|
2436
|
+
}
|
|
2437
|
+
)
|
|
2438
|
+
]
|
|
2439
|
+
},
|
|
2440
|
+
name
|
|
2441
|
+
)),
|
|
2442
|
+
/* @__PURE__ */ jsx25(
|
|
2443
|
+
"input",
|
|
2444
|
+
{
|
|
2445
|
+
ref: inputRef,
|
|
2446
|
+
value: search,
|
|
2447
|
+
onChange: (e) => {
|
|
2448
|
+
setSearch(e.target.value);
|
|
2449
|
+
setOpen(true);
|
|
2450
|
+
},
|
|
2451
|
+
onFocus: () => setOpen(true),
|
|
2452
|
+
placeholder: selected.length === 0 ? placeholder : "",
|
|
2453
|
+
className: "flex-1 min-w-[120px] bg-transparent outline-none text-xs font-medium h-6 text-foreground placeholder:text-muted-foreground"
|
|
2454
|
+
}
|
|
2455
|
+
),
|
|
2456
|
+
/* @__PURE__ */ jsx25(
|
|
2457
|
+
ChevronDown,
|
|
2458
|
+
{
|
|
2459
|
+
className: `h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 ${open ? "rotate-180" : ""}`
|
|
2460
|
+
}
|
|
2461
|
+
)
|
|
2462
|
+
]
|
|
2463
|
+
}
|
|
2464
|
+
),
|
|
2465
|
+
open && /* @__PURE__ */ jsx25("div", { className: "absolute z-50 mt-1 w-full rounded-lg border bg-card text-foreground shadow-lg overflow-hidden p-1.5", children: /* @__PURE__ */ jsx25("div", { className: "max-h-60 overflow-y-auto space-y-1", children: isLoading && options.length === 0 ? /* @__PURE__ */ jsx25("div", { className: "p-3 text-center text-xs text-muted-foreground", children: "Loading options\u2026" }) : /* @__PURE__ */ jsxs21(Fragment2, { children: [
|
|
2466
|
+
filtered.length === 0 && !showCustomOption && !isLoading && /* @__PURE__ */ jsx25("div", { className: "p-3 text-center text-xs text-muted-foreground", children: "No options found" }),
|
|
2467
|
+
filtered.map((opt) => /* @__PURE__ */ jsxs21(
|
|
2468
|
+
"button",
|
|
2469
|
+
{
|
|
2470
|
+
type: "button",
|
|
2471
|
+
onClick: () => addItem(opt.name),
|
|
2472
|
+
className: "w-full text-left px-3 py-2 rounded-md flex items-center gap-2 hover:bg-muted transition-colors text-xs",
|
|
2473
|
+
children: [
|
|
2474
|
+
/* @__PURE__ */ jsx25("div", { className: "h-6 w-6 shrink-0 rounded bg-muted flex items-center justify-center", children: /* @__PURE__ */ jsx25(Hash, { className: "h-3 w-3 text-muted-foreground" }) }),
|
|
2475
|
+
/* @__PURE__ */ jsx25("div", { className: "flex-1 min-w-0", children: /* @__PURE__ */ jsx25("div", { className: "font-semibold truncate", children: opt.name }) }),
|
|
2476
|
+
/* @__PURE__ */ jsx25(Plus, { className: "h-3.5 w-3.5 text-primary shrink-0" })
|
|
2477
|
+
]
|
|
2478
|
+
},
|
|
2479
|
+
opt.id
|
|
2480
|
+
)),
|
|
2481
|
+
hasNextPage && /* @__PURE__ */ jsx25(
|
|
2482
|
+
"button",
|
|
2483
|
+
{
|
|
2484
|
+
type: "button",
|
|
2485
|
+
onClick: () => onFetchNextPage?.(),
|
|
2486
|
+
disabled: isFetchingNextPage,
|
|
2487
|
+
className: "w-full py-1.5 text-center text-xs text-primary font-semibold hover:underline",
|
|
2488
|
+
children: isFetchingNextPage ? "Loading more..." : "Load more..."
|
|
2489
|
+
}
|
|
2490
|
+
),
|
|
2491
|
+
showCustomOption && /* @__PURE__ */ jsxs21(
|
|
2492
|
+
"button",
|
|
2493
|
+
{
|
|
2494
|
+
type: "button",
|
|
2495
|
+
onClick: () => {
|
|
2496
|
+
if (onCreateOption) {
|
|
2497
|
+
onCreateOption(search.trim());
|
|
2498
|
+
} else {
|
|
2499
|
+
addItem(search.trim());
|
|
2500
|
+
}
|
|
2501
|
+
setSearch("");
|
|
2502
|
+
},
|
|
2503
|
+
className: "w-full text-left px-3 py-2 rounded-md flex items-center gap-2 bg-primary/10 text-primary font-bold text-xs hover:bg-primary/20",
|
|
2504
|
+
children: [
|
|
2505
|
+
/* @__PURE__ */ jsx25(Plus, { className: "h-3.5 w-3.5" }),
|
|
2506
|
+
/* @__PURE__ */ jsxs21("span", { children: [
|
|
2507
|
+
'Create "',
|
|
2508
|
+
search.trim(),
|
|
2509
|
+
'"'
|
|
2510
|
+
] })
|
|
2511
|
+
]
|
|
2512
|
+
}
|
|
2513
|
+
)
|
|
2514
|
+
] }) }) })
|
|
2515
|
+
] });
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
// src/components/shared/ImageEditorModal.tsx
|
|
2519
|
+
import {
|
|
2520
|
+
useState as useState13,
|
|
2521
|
+
useRef as useRef7,
|
|
2522
|
+
useCallback as useCallback2,
|
|
2523
|
+
useEffect as useEffect9,
|
|
2524
|
+
useMemo
|
|
2525
|
+
} from "react";
|
|
2526
|
+
import { AnimatePresence, motion as motion2 } from "framer-motion";
|
|
2527
|
+
import {
|
|
2528
|
+
Camera,
|
|
2529
|
+
Check as Check4,
|
|
2530
|
+
Contrast as ContrastIcon,
|
|
2531
|
+
Crop,
|
|
2532
|
+
FlipHorizontal,
|
|
2533
|
+
FlipVertical,
|
|
2534
|
+
Image as ImageIcon2,
|
|
2535
|
+
Info,
|
|
2536
|
+
Loader2 as Loader24,
|
|
2537
|
+
Maximize2,
|
|
2538
|
+
Move,
|
|
2539
|
+
RectangleHorizontal,
|
|
2540
|
+
RectangleVertical,
|
|
2541
|
+
RotateCcw,
|
|
2542
|
+
RotateCw,
|
|
2543
|
+
Sliders,
|
|
2544
|
+
Smartphone,
|
|
2545
|
+
Sparkles,
|
|
2546
|
+
Square,
|
|
2547
|
+
Sun,
|
|
2548
|
+
Tv,
|
|
2549
|
+
Upload,
|
|
2550
|
+
Wand2,
|
|
2551
|
+
X as X3,
|
|
2552
|
+
ZoomIn,
|
|
2553
|
+
ZoomOut
|
|
2554
|
+
} from "lucide-react";
|
|
2555
|
+
import { toast } from "sonner";
|
|
2556
|
+
import { Fragment as Fragment3, jsx as jsx26, jsxs as jsxs22 } from "react/jsx-runtime";
|
|
2557
|
+
var HANDLE_SIZE = 14;
|
|
2558
|
+
var DEFAULT_CROP = { x: 0.05, y: 0.05, w: 0.9, h: 0.9 };
|
|
2559
|
+
var ASPECT_PRESETS = [
|
|
2560
|
+
{ label: "Free", ratio: void 0, icon: /* @__PURE__ */ jsx26(Move, { className: "w-3.5 h-3.5" }) },
|
|
2561
|
+
{ label: "Original", ratio: -1, icon: /* @__PURE__ */ jsx26(ImageIcon2, { className: "w-3.5 h-3.5" }) },
|
|
2562
|
+
{ label: "1 : 1", ratio: 1, icon: /* @__PURE__ */ jsx26(Square, { className: "w-3.5 h-3.5" }) },
|
|
2563
|
+
{ label: "16 : 9", ratio: 16 / 9, icon: /* @__PURE__ */ jsx26(Tv, { className: "w-3.5 h-3.5" }) },
|
|
2564
|
+
{ label: "9 : 16", ratio: 9 / 16, icon: /* @__PURE__ */ jsx26(Smartphone, { className: "w-3.5 h-3.5" }) },
|
|
2565
|
+
{ label: "4 : 3", ratio: 4 / 3, icon: /* @__PURE__ */ jsx26(RectangleHorizontal, { className: "w-3.5 h-3.5" }) },
|
|
2566
|
+
{ label: "3 : 4", ratio: 3 / 4, icon: /* @__PURE__ */ jsx26(RectangleVertical, { className: "w-3.5 h-3.5" }) },
|
|
2567
|
+
{ label: "4 : 5", ratio: 4 / 5, icon: /* @__PURE__ */ jsx26(RectangleVertical, { className: "w-3.5 h-3.5" }) },
|
|
2568
|
+
{ label: "3 : 2", ratio: 3 / 2, icon: /* @__PURE__ */ jsx26(RectangleHorizontal, { className: "w-3.5 h-3.5" }) },
|
|
2569
|
+
{ label: "2 : 3", ratio: 2 / 3, icon: /* @__PURE__ */ jsx26(RectangleVertical, { className: "w-3.5 h-3.5" }) }
|
|
2570
|
+
];
|
|
2571
|
+
var HANDLE_DEFS = [
|
|
2572
|
+
{ key: "nw", cursor: "nwse-resize", style: { left: -7, top: -7 } },
|
|
2573
|
+
{ key: "ne", cursor: "nesw-resize", style: { right: -7, top: -7 } },
|
|
2574
|
+
{ key: "sw", cursor: "nesw-resize", style: { left: -7, bottom: -7 } },
|
|
2575
|
+
{ key: "se", cursor: "nwse-resize", style: { right: -7, bottom: -7 } },
|
|
2576
|
+
{ key: "n", cursor: "ns-resize", style: { left: "calc(50% - 7px)", top: -7, width: 14 } },
|
|
2577
|
+
{ key: "s", cursor: "ns-resize", style: { left: "calc(50% - 7px)", bottom: -7, width: 14 } },
|
|
2578
|
+
{ key: "w", cursor: "ew-resize", style: { top: "calc(50% - 7px)", left: -7, height: 14 } },
|
|
2579
|
+
{ key: "e", cursor: "ew-resize", style: { top: "calc(50% - 7px)", right: -7, height: 14 } }
|
|
2580
|
+
];
|
|
2581
|
+
function InlineSlider({
|
|
2582
|
+
value,
|
|
2583
|
+
onValueChange,
|
|
2584
|
+
min,
|
|
2585
|
+
max,
|
|
2586
|
+
step = 1,
|
|
2587
|
+
className
|
|
2588
|
+
}) {
|
|
2589
|
+
return /* @__PURE__ */ jsx26(
|
|
2590
|
+
"input",
|
|
2591
|
+
{
|
|
2592
|
+
type: "range",
|
|
2593
|
+
min,
|
|
2594
|
+
max,
|
|
2595
|
+
step,
|
|
2596
|
+
value: value[0],
|
|
2597
|
+
onChange: (e) => onValueChange([parseFloat(e.target.value)]),
|
|
2598
|
+
className: `w-full h-1.5 bg-muted rounded-lg appearance-none cursor-pointer accent-primary ${className ?? ""}`
|
|
2599
|
+
}
|
|
2600
|
+
);
|
|
2601
|
+
}
|
|
2602
|
+
function ImageEditorModal({
|
|
2603
|
+
open,
|
|
2604
|
+
onOpenChange,
|
|
2605
|
+
onImageSelect,
|
|
2606
|
+
aspectRatio: initialAspectRatio,
|
|
2607
|
+
maxFileSize = 10 * 1024 * 1024,
|
|
2608
|
+
acceptedFormats = ["image/jpeg", "image/png", "image/webp"],
|
|
2609
|
+
maxDimension = 4e3,
|
|
2610
|
+
initialFile = null,
|
|
2611
|
+
outputFormat = "image/jpeg",
|
|
2612
|
+
outputQuality = 0.92,
|
|
2613
|
+
minWidth,
|
|
2614
|
+
minHeight
|
|
2615
|
+
}) {
|
|
2616
|
+
const [imageSrc, setImageSrc] = useState13(null);
|
|
2617
|
+
const [fileName, setFileName] = useState13("");
|
|
2618
|
+
const [sourceDimensions, setSourceDimensions] = useState13(null);
|
|
2619
|
+
const [activeTab, setActiveTab] = useState13("crop");
|
|
2620
|
+
const [pan, setPan] = useState13({ x: 0, y: 0 });
|
|
2621
|
+
const [zoom, setZoom] = useState13(1);
|
|
2622
|
+
const [rotation, setRotation] = useState13(0);
|
|
2623
|
+
const [flipX, setFlipX] = useState13(false);
|
|
2624
|
+
const [flipY, setFlipY] = useState13(false);
|
|
2625
|
+
const [brightness, setBrightness] = useState13(0);
|
|
2626
|
+
const [contrast, setContrast] = useState13(0);
|
|
2627
|
+
const [saturation, setSaturation] = useState13(0);
|
|
2628
|
+
const [blur, setBlur] = useState13(0);
|
|
2629
|
+
const [activePresetRatio, setActivePresetRatio] = useState13(initialAspectRatio);
|
|
2630
|
+
const [cropBox, setCropBox] = useState13(DEFAULT_CROP);
|
|
2631
|
+
const [isDraggingBox, setIsDraggingBox] = useState13(false);
|
|
2632
|
+
const [isProcessing, setIsProcessing] = useState13(false);
|
|
2633
|
+
const [showCamera, setShowCamera] = useState13(false);
|
|
2634
|
+
const [isCameraStarting, setIsCameraStarting] = useState13(false);
|
|
2635
|
+
const [cameraSupported] = useState13(
|
|
2636
|
+
() => typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia
|
|
2637
|
+
);
|
|
2638
|
+
const frameRef = useRef7(null);
|
|
2639
|
+
const canvasRef = useRef7(null);
|
|
2640
|
+
const fileInputRef = useRef7(null);
|
|
2641
|
+
const videoRef = useRef7(null);
|
|
2642
|
+
const cameraStreamRef = useRef7(null);
|
|
2643
|
+
const loadedInitialFileRef = useRef7(null);
|
|
2644
|
+
const dragStartRef = useRef7(null);
|
|
2645
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
2646
|
+
const effectiveAspectRatio = useMemo(() => {
|
|
2647
|
+
if (activePresetRatio === -1 && sourceDimensions) {
|
|
2648
|
+
return sourceDimensions.width / sourceDimensions.height;
|
|
2649
|
+
}
|
|
2650
|
+
return activePresetRatio && activePresetRatio > 0 ? activePresetRatio : void 0;
|
|
2651
|
+
}, [activePresetRatio, sourceDimensions]);
|
|
2652
|
+
const isSourceBelowMin = useMemo(() => {
|
|
2653
|
+
if (!sourceDimensions) return false;
|
|
2654
|
+
return minWidth && sourceDimensions.width < minWidth || minHeight && sourceDimensions.height < minHeight;
|
|
2655
|
+
}, [sourceDimensions, minWidth, minHeight]);
|
|
2656
|
+
const filterStyle = useMemo(() => {
|
|
2657
|
+
const b = 100 + brightness;
|
|
2658
|
+
const c = 100 + contrast;
|
|
2659
|
+
const s = 100 + saturation;
|
|
2660
|
+
return `brightness(${b}%) contrast(${c}%) saturate(${s}%) blur(${blur}px)`;
|
|
2661
|
+
}, [brightness, contrast, saturation, blur]);
|
|
2662
|
+
const resetEdits = useCallback2(() => {
|
|
2663
|
+
setPan({ x: 0, y: 0 });
|
|
2664
|
+
setZoom(1);
|
|
2665
|
+
setRotation(0);
|
|
2666
|
+
setFlipX(false);
|
|
2667
|
+
setFlipY(false);
|
|
2668
|
+
setBrightness(0);
|
|
2669
|
+
setContrast(0);
|
|
2670
|
+
setSaturation(0);
|
|
2671
|
+
setBlur(0);
|
|
2672
|
+
setCropBox(DEFAULT_CROP);
|
|
2673
|
+
setActivePresetRatio(initialAspectRatio);
|
|
2674
|
+
}, [initialAspectRatio]);
|
|
2675
|
+
const resetModal = useCallback2(() => {
|
|
2676
|
+
setImageSrc(null);
|
|
2677
|
+
setSourceDimensions(null);
|
|
2678
|
+
setFileName("");
|
|
2679
|
+
setShowCamera(false);
|
|
2680
|
+
resetEdits();
|
|
2681
|
+
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
2682
|
+
}, [resetEdits]);
|
|
2683
|
+
const stopCamera = useCallback2(() => {
|
|
2684
|
+
cameraStreamRef.current?.getTracks().forEach((t) => t.stop());
|
|
2685
|
+
cameraStreamRef.current = null;
|
|
2686
|
+
}, []);
|
|
2687
|
+
const handleClose = useCallback2(() => {
|
|
2688
|
+
stopCamera();
|
|
2689
|
+
resetModal();
|
|
2690
|
+
onOpenChange(false);
|
|
2691
|
+
}, [stopCamera, resetModal, onOpenChange]);
|
|
2692
|
+
useEffect9(() => {
|
|
2693
|
+
if (!open) return;
|
|
2694
|
+
const onKey = (e) => {
|
|
2695
|
+
if (e.key === "Escape") handleClose();
|
|
2696
|
+
};
|
|
2697
|
+
window.addEventListener("keydown", onKey);
|
|
2698
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
2699
|
+
}, [open, handleClose]);
|
|
2700
|
+
useEffect9(() => {
|
|
2701
|
+
document.body.style.overflow = open ? "hidden" : "";
|
|
2702
|
+
return () => {
|
|
2703
|
+
document.body.style.overflow = "";
|
|
2704
|
+
};
|
|
2705
|
+
}, [open]);
|
|
2706
|
+
useEffect9(() => {
|
|
2707
|
+
if (!showCamera) return;
|
|
2708
|
+
let cancelled = false;
|
|
2709
|
+
(async () => {
|
|
2710
|
+
setIsCameraStarting(true);
|
|
2711
|
+
try {
|
|
2712
|
+
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" }, audio: false });
|
|
2713
|
+
if (cancelled) {
|
|
2714
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2717
|
+
cameraStreamRef.current = stream;
|
|
2718
|
+
if (videoRef.current) {
|
|
2719
|
+
videoRef.current.srcObject = stream;
|
|
2720
|
+
await videoRef.current.play();
|
|
2721
|
+
}
|
|
2722
|
+
} catch (err) {
|
|
2723
|
+
if (!cancelled) {
|
|
2724
|
+
toast.error("Camera access denied", {
|
|
2725
|
+
description: err instanceof Error ? err.message : "Grant camera permission in your browser settings."
|
|
2726
|
+
});
|
|
2727
|
+
setShowCamera(false);
|
|
2728
|
+
}
|
|
2729
|
+
} finally {
|
|
2730
|
+
if (!cancelled) setIsCameraStarting(false);
|
|
2731
|
+
}
|
|
2732
|
+
})();
|
|
2733
|
+
return () => {
|
|
2734
|
+
cancelled = true;
|
|
2735
|
+
};
|
|
2736
|
+
}, [showCamera]);
|
|
2737
|
+
useEffect9(() => () => {
|
|
2738
|
+
cameraStreamRef.current?.getTracks().forEach((t) => t.stop());
|
|
2739
|
+
}, []);
|
|
2740
|
+
const loadFile = useCallback2(
|
|
2741
|
+
(file) => {
|
|
2742
|
+
if (file.size > maxFileSize) {
|
|
2743
|
+
toast.error("File too large", { description: `Maximum file size is ${(maxFileSize / 1024 / 1024).toFixed(1)} MB.` });
|
|
2744
|
+
return;
|
|
2745
|
+
}
|
|
2746
|
+
if (!acceptedFormats.includes(file.type)) {
|
|
2747
|
+
toast.error("Unsupported file format", {
|
|
2748
|
+
description: `Accepted formats: ${acceptedFormats.map((f) => f.split("/")[1]?.toUpperCase()).join(", ")}`
|
|
2749
|
+
});
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
if (file.type === "image/svg+xml") {
|
|
2753
|
+
onImageSelect(file, file.name);
|
|
2754
|
+
onOpenChange(false);
|
|
2755
|
+
toast.success("Image selected");
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
setFileName(file.name);
|
|
2759
|
+
const reader = new FileReader();
|
|
2760
|
+
reader.onload = (e) => {
|
|
2761
|
+
const dataUrl = e.target?.result;
|
|
2762
|
+
const img = new Image();
|
|
2763
|
+
img.onload = () => {
|
|
2764
|
+
setSourceDimensions({ width: img.naturalWidth, height: img.naturalHeight });
|
|
2765
|
+
setImageSrc(dataUrl);
|
|
2766
|
+
setCropBox(DEFAULT_CROP);
|
|
2767
|
+
setPan({ x: 0, y: 0 });
|
|
2768
|
+
setZoom(1);
|
|
2769
|
+
if (minWidth && img.naturalWidth < minWidth || minHeight && img.naturalHeight < minHeight) {
|
|
2770
|
+
toast.info("Low-resolution image", {
|
|
2771
|
+
description: `Source is ${img.naturalWidth}\xD7${img.naturalHeight}px. It will be upscaled to meet the ${minWidth ?? img.naturalWidth}\xD7${minHeight ?? img.naturalHeight}px target.`
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
};
|
|
2775
|
+
img.src = dataUrl;
|
|
2776
|
+
};
|
|
2777
|
+
reader.readAsDataURL(file);
|
|
2778
|
+
},
|
|
2779
|
+
[maxFileSize, acceptedFormats, minWidth, minHeight, onImageSelect, onOpenChange]
|
|
2780
|
+
);
|
|
2781
|
+
const handleFileChange = useCallback2(
|
|
2782
|
+
(e) => {
|
|
2783
|
+
const file = e.target.files?.[0];
|
|
2784
|
+
if (file) loadFile(file);
|
|
2785
|
+
},
|
|
2786
|
+
[loadFile]
|
|
2787
|
+
);
|
|
2788
|
+
const capturePhoto = useCallback2(() => {
|
|
2789
|
+
const video = videoRef.current;
|
|
2790
|
+
if (!video || video.readyState < 2) return;
|
|
2791
|
+
const canvas = document.createElement("canvas");
|
|
2792
|
+
canvas.width = video.videoWidth;
|
|
2793
|
+
canvas.height = video.videoHeight;
|
|
2794
|
+
const ctx = canvas.getContext("2d");
|
|
2795
|
+
if (!ctx) return;
|
|
2796
|
+
ctx.drawImage(video, 0, 0);
|
|
2797
|
+
canvas.toBlob(
|
|
2798
|
+
(blob) => {
|
|
2799
|
+
if (!blob) return;
|
|
2800
|
+
const file = new File([blob], `capture-${Date.now()}.jpg`, { type: "image/jpeg" });
|
|
2801
|
+
stopCamera();
|
|
2802
|
+
setShowCamera(false);
|
|
2803
|
+
loadFile(file);
|
|
2804
|
+
},
|
|
2805
|
+
"image/jpeg",
|
|
2806
|
+
0.92
|
|
2807
|
+
);
|
|
2808
|
+
}, [loadFile, stopCamera]);
|
|
2809
|
+
useEffect9(() => {
|
|
2810
|
+
if (open && initialFile && loadedInitialFileRef.current !== initialFile) {
|
|
2811
|
+
loadedInitialFileRef.current = initialFile;
|
|
2812
|
+
loadFile(initialFile);
|
|
2813
|
+
}
|
|
2814
|
+
if (!open) loadedInitialFileRef.current = null;
|
|
2815
|
+
}, [open, initialFile, loadFile]);
|
|
2816
|
+
const applyAspectRatioToCropBox = useCallback2(
|
|
2817
|
+
(ratio) => {
|
|
2818
|
+
if (!ratio || ratio <= 0 || !frameRef.current) return;
|
|
2819
|
+
const rect = frameRef.current.getBoundingClientRect();
|
|
2820
|
+
if (rect.width <= 0 || rect.height <= 0) return;
|
|
2821
|
+
const vpAR = rect.width / rect.height;
|
|
2822
|
+
const targetFractionAR = ratio / vpAR;
|
|
2823
|
+
setCropBox((cur) => {
|
|
2824
|
+
let { x, y, w, h } = cur;
|
|
2825
|
+
const cx = x + w / 2;
|
|
2826
|
+
const cy = y + h / 2;
|
|
2827
|
+
if (w / h > targetFractionAR) {
|
|
2828
|
+
w = h * targetFractionAR;
|
|
2829
|
+
} else {
|
|
2830
|
+
h = w / targetFractionAR;
|
|
2831
|
+
}
|
|
2832
|
+
w = clamp(w, 0.1, 1);
|
|
2833
|
+
h = clamp(h, 0.1, 1);
|
|
2834
|
+
x = clamp(cx - w / 2, 0, 1 - w);
|
|
2835
|
+
y = clamp(cy - h / 2, 0, 1 - h);
|
|
2836
|
+
return { x, y, w, h };
|
|
2837
|
+
});
|
|
2838
|
+
},
|
|
2839
|
+
[]
|
|
2840
|
+
);
|
|
2841
|
+
const handleSelectPreset = useCallback2(
|
|
2842
|
+
(preset) => {
|
|
2843
|
+
setActivePresetRatio(preset.ratio);
|
|
2844
|
+
if (preset.ratio === -1 && sourceDimensions) {
|
|
2845
|
+
applyAspectRatioToCropBox(sourceDimensions.width / sourceDimensions.height);
|
|
2846
|
+
} else {
|
|
2847
|
+
applyAspectRatioToCropBox(preset.ratio);
|
|
2848
|
+
}
|
|
2849
|
+
},
|
|
2850
|
+
[sourceDimensions, applyAspectRatioToCropBox]
|
|
2851
|
+
);
|
|
2852
|
+
const startDrag = useCallback2(
|
|
2853
|
+
(e, handle) => {
|
|
2854
|
+
e.preventDefault();
|
|
2855
|
+
e.stopPropagation();
|
|
2856
|
+
if (!frameRef.current) return;
|
|
2857
|
+
const frameRect = frameRef.current.getBoundingClientRect();
|
|
2858
|
+
setIsDraggingBox(true);
|
|
2859
|
+
dragStartRef.current = {
|
|
2860
|
+
handle,
|
|
2861
|
+
startBox: { ...cropBox },
|
|
2862
|
+
startMousePx: { x: e.clientX, y: e.clientY },
|
|
2863
|
+
frameRect
|
|
2864
|
+
};
|
|
2865
|
+
const onMove = (me) => {
|
|
2866
|
+
const ref = dragStartRef.current;
|
|
2867
|
+
if (!ref) return;
|
|
2868
|
+
const { handle: h, startBox: sb, startMousePx: sm, frameRect: r } = ref;
|
|
2869
|
+
const dx = (me.clientX - sm.x) / r.width;
|
|
2870
|
+
const dy = (me.clientY - sm.y) / r.height;
|
|
2871
|
+
setCropBox(() => {
|
|
2872
|
+
let { x, y, w, h: _h } = { ...sb };
|
|
2873
|
+
if (h === "body") {
|
|
2874
|
+
x = clamp(x + dx, 0, 1 - w);
|
|
2875
|
+
y = clamp(y + dy, 0, 1 - _h);
|
|
2876
|
+
} else {
|
|
2877
|
+
let x1 = x, y1 = y, x2 = x + w, y2 = y + _h;
|
|
2878
|
+
if (h === "nw" || h === "w" || h === "sw") x1 = clamp(x + dx, 0, x2 - 0.05);
|
|
2879
|
+
if (h === "ne" || h === "e" || h === "se") x2 = clamp(x + w + dx, x1 + 0.05, 1);
|
|
2880
|
+
if (h === "nw" || h === "n" || h === "ne") y1 = clamp(y + dy, 0, y2 - 0.05);
|
|
2881
|
+
if (h === "sw" || h === "s" || h === "se") y2 = clamp(y + _h + dy, y1 + 0.05, 1);
|
|
2882
|
+
w = x2 - x1;
|
|
2883
|
+
_h = y2 - y1;
|
|
2884
|
+
if (effectiveAspectRatio && effectiveAspectRatio > 0) {
|
|
2885
|
+
const vpAR = r.width / r.height;
|
|
2886
|
+
const tAR = effectiveAspectRatio / vpAR;
|
|
2887
|
+
if (h === "n" || h === "s") {
|
|
2888
|
+
w = clamp(_h * tAR, 0.05, 1 - x1);
|
|
2889
|
+
x2 = x1 + w;
|
|
2890
|
+
} else {
|
|
2891
|
+
_h = clamp(w / tAR, 0.05, 1 - y1);
|
|
2892
|
+
y2 = y1 + _h;
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
x = clamp(x1, 0, 1);
|
|
2896
|
+
y = clamp(y1, 0, 1);
|
|
2897
|
+
w = clamp(x2 - x, 0.05, 1 - x);
|
|
2898
|
+
_h = clamp(y2 - y, 0.05, 1 - y);
|
|
2899
|
+
}
|
|
2900
|
+
return { x, y, w, h: _h };
|
|
2901
|
+
});
|
|
2902
|
+
};
|
|
2903
|
+
const onUp = () => {
|
|
2904
|
+
setIsDraggingBox(false);
|
|
2905
|
+
dragStartRef.current = null;
|
|
2906
|
+
window.removeEventListener("pointermove", onMove);
|
|
2907
|
+
window.removeEventListener("pointerup", onUp);
|
|
2908
|
+
window.removeEventListener("pointercancel", onUp);
|
|
2909
|
+
};
|
|
2910
|
+
window.addEventListener("pointermove", onMove);
|
|
2911
|
+
window.addEventListener("pointerup", onUp);
|
|
2912
|
+
window.addEventListener("pointercancel", onUp);
|
|
2913
|
+
},
|
|
2914
|
+
[cropBox, effectiveAspectRatio]
|
|
2915
|
+
);
|
|
2916
|
+
const handleFramePointerDown = useCallback2(
|
|
2917
|
+
(e) => {
|
|
2918
|
+
if (e.target.closest("[data-crop-handle]")) return;
|
|
2919
|
+
e.preventDefault();
|
|
2920
|
+
const sx = e.clientX, sy = e.clientY;
|
|
2921
|
+
const spx = pan.x, spy = pan.y;
|
|
2922
|
+
const onMove = (me) => setPan({ x: spx + me.clientX - sx, y: spy + me.clientY - sy });
|
|
2923
|
+
const onUp = () => {
|
|
2924
|
+
window.removeEventListener("pointermove", onMove);
|
|
2925
|
+
window.removeEventListener("pointerup", onUp);
|
|
2926
|
+
};
|
|
2927
|
+
window.addEventListener("pointermove", onMove);
|
|
2928
|
+
window.addEventListener("pointerup", onUp);
|
|
2929
|
+
},
|
|
2930
|
+
[pan]
|
|
2931
|
+
);
|
|
2932
|
+
const handleApply = useCallback2(async () => {
|
|
2933
|
+
if (!imageSrc || !canvasRef.current || !frameRef.current) return;
|
|
2934
|
+
setIsProcessing(true);
|
|
2935
|
+
try {
|
|
2936
|
+
const image = new Image();
|
|
2937
|
+
image.src = imageSrc;
|
|
2938
|
+
await new Promise((res, rej) => {
|
|
2939
|
+
image.onload = () => res();
|
|
2940
|
+
image.onerror = () => rej(new Error("Image failed to load"));
|
|
2941
|
+
});
|
|
2942
|
+
const origW = image.naturalWidth;
|
|
2943
|
+
const origH = image.naturalHeight;
|
|
2944
|
+
const rad = rotation * Math.PI / 180;
|
|
2945
|
+
const absSin = Math.abs(Math.sin(rad));
|
|
2946
|
+
const absCos = Math.abs(Math.cos(rad));
|
|
2947
|
+
const rotW = origW * absCos + origH * absSin;
|
|
2948
|
+
const rotH = origW * absSin + origH * absCos;
|
|
2949
|
+
const offscreen = document.createElement("canvas");
|
|
2950
|
+
offscreen.width = Math.ceil(rotW);
|
|
2951
|
+
offscreen.height = Math.ceil(rotH);
|
|
2952
|
+
const offCtx = offscreen.getContext("2d");
|
|
2953
|
+
offCtx.imageSmoothingEnabled = true;
|
|
2954
|
+
offCtx.imageSmoothingQuality = "high";
|
|
2955
|
+
offCtx.filter = filterStyle;
|
|
2956
|
+
offCtx.translate(rotW / 2, rotH / 2);
|
|
2957
|
+
offCtx.rotate(rad);
|
|
2958
|
+
offCtx.scale(flipX ? -1 : 1, flipY ? -1 : 1);
|
|
2959
|
+
offCtx.drawImage(image, -origW / 2, -origH / 2);
|
|
2960
|
+
const vpRect = frameRef.current.getBoundingClientRect();
|
|
2961
|
+
const vpW = vpRect.width, vpH = vpRect.height;
|
|
2962
|
+
const baseScale = Math.min(vpW / rotW, vpH / rotH);
|
|
2963
|
+
const effectiveScale = baseScale * zoom;
|
|
2964
|
+
const displayedW = rotW * effectiveScale;
|
|
2965
|
+
const displayedH = rotH * effectiveScale;
|
|
2966
|
+
const marginX = (vpW - displayedW) / 2 + pan.x;
|
|
2967
|
+
const marginY = (vpH - displayedH) / 2 + pan.y;
|
|
2968
|
+
const cropPxX = cropBox.x * vpW, cropPxY = cropBox.y * vpH;
|
|
2969
|
+
const cropPxW = cropBox.w * vpW, cropPxH = cropBox.h * vpH;
|
|
2970
|
+
const sx = clamp((cropPxX - marginX) / effectiveScale, 0, rotW);
|
|
2971
|
+
const sy = clamp((cropPxY - marginY) / effectiveScale, 0, rotH);
|
|
2972
|
+
const sx2 = clamp((cropPxX + cropPxW - marginX) / effectiveScale, 0, rotW);
|
|
2973
|
+
const sy2 = clamp((cropPxY + cropPxH - marginY) / effectiveScale, 0, rotH);
|
|
2974
|
+
const sw = Math.max(1, sx2 - sx);
|
|
2975
|
+
const sh = Math.max(1, sy2 - sy);
|
|
2976
|
+
let outW = sw, outH = sh;
|
|
2977
|
+
const maxSide = Math.max(outW, outH);
|
|
2978
|
+
if (maxDimension && maxSide > maxDimension) {
|
|
2979
|
+
const ds = maxDimension / maxSide;
|
|
2980
|
+
outW *= ds;
|
|
2981
|
+
outH *= ds;
|
|
2982
|
+
}
|
|
2983
|
+
if (minWidth && outW < minWidth) {
|
|
2984
|
+
const us = minWidth / outW;
|
|
2985
|
+
outW = minWidth;
|
|
2986
|
+
outH *= us;
|
|
2987
|
+
}
|
|
2988
|
+
if (minHeight && outH < minHeight) {
|
|
2989
|
+
const us = minHeight / outH;
|
|
2990
|
+
outH = minHeight;
|
|
2991
|
+
outW *= us;
|
|
2992
|
+
}
|
|
2993
|
+
outW = Math.round(Math.max(1, outW));
|
|
2994
|
+
outH = Math.round(Math.max(1, outH));
|
|
2995
|
+
const outputCanvas = canvasRef.current;
|
|
2996
|
+
outputCanvas.width = outW;
|
|
2997
|
+
outputCanvas.height = outH;
|
|
2998
|
+
const outCtx = outputCanvas.getContext("2d");
|
|
2999
|
+
outCtx.imageSmoothingEnabled = true;
|
|
3000
|
+
outCtx.imageSmoothingQuality = "high";
|
|
3001
|
+
if (outputFormat === "image/jpeg") {
|
|
3002
|
+
outCtx.fillStyle = "#fff";
|
|
3003
|
+
outCtx.fillRect(0, 0, outW, outH);
|
|
3004
|
+
}
|
|
3005
|
+
outCtx.drawImage(offscreen, sx, sy, sw, sh, 0, 0, outW, outH);
|
|
3006
|
+
outputCanvas.toBlob(
|
|
3007
|
+
(blob) => {
|
|
3008
|
+
if (!blob) {
|
|
3009
|
+
toast.error("Failed to process image. Please try again.");
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
const ext = outputFormat === "image/png" ? "png" : "jpg";
|
|
3013
|
+
const baseName = fileName.replace(/\.[^./]+$/, "") || "edited-image";
|
|
3014
|
+
onImageSelect(blob, `${baseName}.${ext}`);
|
|
3015
|
+
resetModal();
|
|
3016
|
+
onOpenChange(false);
|
|
3017
|
+
toast.success("Image updated successfully");
|
|
3018
|
+
},
|
|
3019
|
+
outputFormat,
|
|
3020
|
+
outputQuality
|
|
3021
|
+
);
|
|
3022
|
+
} catch (err) {
|
|
3023
|
+
toast.error("Failed to process image", {
|
|
3024
|
+
description: err instanceof Error ? err.message : "Please try again."
|
|
3025
|
+
});
|
|
3026
|
+
} finally {
|
|
3027
|
+
setIsProcessing(false);
|
|
3028
|
+
}
|
|
3029
|
+
}, [
|
|
3030
|
+
imageSrc,
|
|
3031
|
+
rotation,
|
|
3032
|
+
flipX,
|
|
3033
|
+
flipY,
|
|
3034
|
+
zoom,
|
|
3035
|
+
pan,
|
|
3036
|
+
cropBox,
|
|
3037
|
+
fileName,
|
|
3038
|
+
filterStyle,
|
|
3039
|
+
maxDimension,
|
|
3040
|
+
minWidth,
|
|
3041
|
+
minHeight,
|
|
3042
|
+
outputFormat,
|
|
3043
|
+
outputQuality,
|
|
3044
|
+
onImageSelect,
|
|
3045
|
+
onOpenChange,
|
|
3046
|
+
resetModal
|
|
3047
|
+
]);
|
|
3048
|
+
if (!open) return null;
|
|
3049
|
+
const btnBase = "inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
|
|
3050
|
+
return /* @__PURE__ */ jsx26(AnimatePresence, { children: open && /* @__PURE__ */ jsxs22("div", { className: "fixed inset-0 z-50 select-none flex", children: [
|
|
3051
|
+
/* @__PURE__ */ jsx26("div", { className: "absolute inset-0 bg-black/70 backdrop-blur-sm", onClick: handleClose }),
|
|
3052
|
+
/* @__PURE__ */ jsxs22(
|
|
3053
|
+
motion2.div,
|
|
3054
|
+
{
|
|
3055
|
+
initial: { opacity: 0, scale: 0.98 },
|
|
3056
|
+
animate: { opacity: 1, scale: 1 },
|
|
3057
|
+
exit: { opacity: 0, scale: 0.98 },
|
|
3058
|
+
transition: { duration: 0.15, ease: "easeOut" },
|
|
3059
|
+
className: "relative z-10 flex flex-col w-full h-full bg-background text-foreground overflow-hidden",
|
|
3060
|
+
role: "dialog",
|
|
3061
|
+
"aria-modal": "true",
|
|
3062
|
+
"aria-label": "Studio Image Editor",
|
|
3063
|
+
children: [
|
|
3064
|
+
/* @__PURE__ */ jsxs22("div", { className: "flex items-center gap-2 px-3 sm:px-4 h-11 border-b border-border bg-card shrink-0", children: [
|
|
3065
|
+
/* @__PURE__ */ jsxs22("div", { className: "flex items-center gap-2 min-w-0 flex-1", children: [
|
|
3066
|
+
/* @__PURE__ */ jsx26(Wand2, { className: "w-4 h-4 text-primary shrink-0" }),
|
|
3067
|
+
/* @__PURE__ */ jsx26("span", { className: "text-sm font-semibold text-foreground leading-none truncate", children: "Studio Editor" }),
|
|
3068
|
+
sourceDimensions && /* @__PURE__ */ jsxs22("span", { className: "text-[11px] text-muted-foreground hidden sm:inline truncate", children: [
|
|
3069
|
+
"\xB7 ",
|
|
3070
|
+
sourceDimensions.width,
|
|
3071
|
+
"\xD7",
|
|
3072
|
+
sourceDimensions.height,
|
|
3073
|
+
"px",
|
|
3074
|
+
minWidth && minHeight ? ` (min ${minWidth}\xD7${minHeight})` : ""
|
|
3075
|
+
] }),
|
|
3076
|
+
isSourceBelowMin && /* @__PURE__ */ jsxs22("span", { className: "hidden sm:inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30 shrink-0", children: [
|
|
3077
|
+
/* @__PURE__ */ jsx26(Info, { className: "w-3 h-3" }),
|
|
3078
|
+
"Upscale"
|
|
3079
|
+
] })
|
|
3080
|
+
] }),
|
|
3081
|
+
imageSrc && /* @__PURE__ */ jsxs22("div", { className: "flex items-center bg-muted rounded-md p-0.5 gap-0.5 shrink-0", children: [
|
|
3082
|
+
/* @__PURE__ */ jsxs22(
|
|
3083
|
+
"button",
|
|
3084
|
+
{
|
|
3085
|
+
type: "button",
|
|
3086
|
+
id: "editor-tab-crop",
|
|
3087
|
+
onClick: () => setActiveTab("crop"),
|
|
3088
|
+
className: [
|
|
3089
|
+
"flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium transition-all",
|
|
3090
|
+
activeTab === "crop" ? "bg-background text-foreground shadow-sm font-semibold" : "text-muted-foreground hover:text-foreground"
|
|
3091
|
+
].join(" "),
|
|
3092
|
+
children: [
|
|
3093
|
+
/* @__PURE__ */ jsx26(Crop, { className: "w-3 h-3 text-primary" }),
|
|
3094
|
+
/* @__PURE__ */ jsx26("span", { children: "Crop" })
|
|
3095
|
+
]
|
|
3096
|
+
}
|
|
3097
|
+
),
|
|
3098
|
+
/* @__PURE__ */ jsxs22(
|
|
3099
|
+
"button",
|
|
3100
|
+
{
|
|
3101
|
+
type: "button",
|
|
3102
|
+
id: "editor-tab-adjust",
|
|
3103
|
+
onClick: () => setActiveTab("adjust"),
|
|
3104
|
+
className: [
|
|
3105
|
+
"flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium transition-all",
|
|
3106
|
+
activeTab === "adjust" ? "bg-background text-foreground shadow-sm font-semibold" : "text-muted-foreground hover:text-foreground"
|
|
3107
|
+
].join(" "),
|
|
3108
|
+
children: [
|
|
3109
|
+
/* @__PURE__ */ jsx26(Sliders, { className: "w-3 h-3 text-secondary" }),
|
|
3110
|
+
/* @__PURE__ */ jsx26("span", { children: "Adjust" })
|
|
3111
|
+
]
|
|
3112
|
+
}
|
|
3113
|
+
)
|
|
3114
|
+
] }),
|
|
3115
|
+
/* @__PURE__ */ jsxs22("div", { className: "flex items-center gap-1 shrink-0", children: [
|
|
3116
|
+
imageSrc && /* @__PURE__ */ jsxs22(
|
|
3117
|
+
"button",
|
|
3118
|
+
{
|
|
3119
|
+
type: "button",
|
|
3120
|
+
onClick: resetEdits,
|
|
3121
|
+
className: `${btnBase} h-7 text-xs px-2.5 text-muted-foreground hover:text-foreground hover:bg-muted border border-transparent rounded-md`,
|
|
3122
|
+
children: [
|
|
3123
|
+
/* @__PURE__ */ jsx26(RotateCcw, { className: "w-3 h-3 sm:mr-1" }),
|
|
3124
|
+
/* @__PURE__ */ jsx26("span", { className: "hidden sm:inline", children: "Reset" })
|
|
3125
|
+
]
|
|
3126
|
+
}
|
|
3127
|
+
),
|
|
3128
|
+
/* @__PURE__ */ jsx26(
|
|
3129
|
+
"button",
|
|
3130
|
+
{
|
|
3131
|
+
type: "button",
|
|
3132
|
+
onClick: handleClose,
|
|
3133
|
+
id: "editor-close-btn",
|
|
3134
|
+
className: `${btnBase} h-7 w-7 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted`,
|
|
3135
|
+
"aria-label": "Close editor",
|
|
3136
|
+
children: /* @__PURE__ */ jsx26(X3, { className: "w-3.5 h-3.5" })
|
|
3137
|
+
}
|
|
3138
|
+
)
|
|
3139
|
+
] })
|
|
3140
|
+
] }),
|
|
3141
|
+
/* @__PURE__ */ jsx26("div", { className: "flex-1 flex flex-col overflow-hidden min-h-0", children: !imageSrc ? (
|
|
3142
|
+
/* ── Upload / Camera screen ─────────────────────────── */
|
|
3143
|
+
showCamera ? /* @__PURE__ */ jsx26("div", { className: "flex-1 flex flex-col items-center justify-center p-6 bg-background", children: /* @__PURE__ */ jsxs22("div", { className: "w-full max-w-sm space-y-4", children: [
|
|
3144
|
+
/* @__PURE__ */ jsxs22("div", { className: "relative aspect-video rounded-xl overflow-hidden border border-border bg-muted flex items-center justify-center", children: [
|
|
3145
|
+
isCameraStarting && /* @__PURE__ */ jsx26("div", { className: "absolute inset-0 flex items-center justify-center bg-background/80 z-10", children: /* @__PURE__ */ jsx26(Loader24, { className: "w-6 h-6 animate-spin text-primary" }) }),
|
|
3146
|
+
/* @__PURE__ */ jsx26("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: "w-full h-full object-cover" })
|
|
3147
|
+
] }),
|
|
3148
|
+
/* @__PURE__ */ jsxs22("div", { className: "flex gap-3", children: [
|
|
3149
|
+
/* @__PURE__ */ jsxs22(
|
|
3150
|
+
"button",
|
|
3151
|
+
{
|
|
3152
|
+
type: "button",
|
|
3153
|
+
onClick: () => {
|
|
3154
|
+
stopCamera();
|
|
3155
|
+
setShowCamera(false);
|
|
3156
|
+
},
|
|
3157
|
+
className: `${btnBase} flex-1 bg-muted border border-border text-foreground hover:bg-muted/80 h-9 px-4 rounded-lg`,
|
|
3158
|
+
children: [
|
|
3159
|
+
/* @__PURE__ */ jsx26(X3, { className: "w-4 h-4 mr-1.5" }),
|
|
3160
|
+
" Cancel"
|
|
3161
|
+
]
|
|
3162
|
+
}
|
|
3163
|
+
),
|
|
3164
|
+
/* @__PURE__ */ jsxs22(
|
|
3165
|
+
"button",
|
|
3166
|
+
{
|
|
3167
|
+
type: "button",
|
|
3168
|
+
onClick: capturePhoto,
|
|
3169
|
+
disabled: isCameraStarting,
|
|
3170
|
+
id: "camera-capture-btn",
|
|
3171
|
+
className: `${btnBase} flex-1 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 rounded-lg font-medium`,
|
|
3172
|
+
children: [
|
|
3173
|
+
/* @__PURE__ */ jsx26(Camera, { className: "w-4 h-4 mr-1.5" }),
|
|
3174
|
+
" Capture"
|
|
3175
|
+
]
|
|
3176
|
+
}
|
|
3177
|
+
)
|
|
3178
|
+
] })
|
|
3179
|
+
] }) }) : /* @__PURE__ */ jsx26("div", { className: "flex-1 flex flex-col items-center justify-center p-6 bg-background", children: /* @__PURE__ */ jsxs22("div", { className: "w-full max-w-sm border-2 border-dashed border-border hover:border-primary/50 transition-colors rounded-2xl p-8 text-center space-y-5 bg-card/60", children: [
|
|
3180
|
+
/* @__PURE__ */ jsx26("div", { className: "w-14 h-14 bg-primary/10 text-primary rounded-2xl flex items-center justify-center mx-auto border border-primary/20", children: /* @__PURE__ */ jsx26(Upload, { className: "w-7 h-7" }) }),
|
|
3181
|
+
/* @__PURE__ */ jsxs22("div", { className: "space-y-1", children: [
|
|
3182
|
+
/* @__PURE__ */ jsx26("p", { className: "text-base font-semibold text-foreground", children: "Select an Image" }),
|
|
3183
|
+
/* @__PURE__ */ jsxs22("p", { className: "text-xs text-muted-foreground", children: [
|
|
3184
|
+
"JPEG, PNG or WebP \xB7 up to ",
|
|
3185
|
+
(maxFileSize / 1024 / 1024).toFixed(0),
|
|
3186
|
+
" MB"
|
|
3187
|
+
] })
|
|
3188
|
+
] }),
|
|
3189
|
+
/* @__PURE__ */ jsxs22("div", { className: "flex flex-wrap items-center justify-center gap-3", children: [
|
|
3190
|
+
/* @__PURE__ */ jsx26(
|
|
3191
|
+
"button",
|
|
3192
|
+
{
|
|
3193
|
+
type: "button",
|
|
3194
|
+
id: "editor-choose-file-btn",
|
|
3195
|
+
onClick: () => fileInputRef.current?.click(),
|
|
3196
|
+
className: `${btnBase} bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-6 rounded-xl font-medium shadow-md`,
|
|
3197
|
+
children: "Choose File"
|
|
3198
|
+
}
|
|
3199
|
+
),
|
|
3200
|
+
cameraSupported && /* @__PURE__ */ jsxs22(
|
|
3201
|
+
"button",
|
|
3202
|
+
{
|
|
3203
|
+
type: "button",
|
|
3204
|
+
id: "editor-camera-btn",
|
|
3205
|
+
onClick: () => setShowCamera(true),
|
|
3206
|
+
className: `${btnBase} bg-card border border-border text-foreground hover:bg-muted h-9 px-5 rounded-xl font-medium`,
|
|
3207
|
+
children: [
|
|
3208
|
+
/* @__PURE__ */ jsx26(Camera, { className: "w-4 h-4 mr-1.5" }),
|
|
3209
|
+
" Camera"
|
|
3210
|
+
]
|
|
3211
|
+
}
|
|
3212
|
+
)
|
|
3213
|
+
] }),
|
|
3214
|
+
/* @__PURE__ */ jsx26("input", { ref: fileInputRef, type: "file", className: "hidden", accept: acceptedFormats.join(","), onChange: handleFileChange })
|
|
3215
|
+
] }) })
|
|
3216
|
+
) : (
|
|
3217
|
+
/* ── Editing workspace ──────────────────────────────── */
|
|
3218
|
+
/* @__PURE__ */ jsxs22(Fragment3, { children: [
|
|
3219
|
+
/* @__PURE__ */ jsxs22("div", { className: "flex-1 relative bg-muted/30 overflow-hidden select-none min-h-0", children: [
|
|
3220
|
+
/* @__PURE__ */ jsx26(
|
|
3221
|
+
"div",
|
|
3222
|
+
{
|
|
3223
|
+
className: "absolute inset-0 pointer-events-none opacity-[0.15]",
|
|
3224
|
+
style: {
|
|
3225
|
+
backgroundImage: "radial-gradient(circle, hsl(var(--muted-foreground)/0.6) 1px, transparent 1px)",
|
|
3226
|
+
backgroundSize: "20px 20px"
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
),
|
|
3230
|
+
/* @__PURE__ */ jsxs22("div", { className: "absolute top-2 left-2 z-30 flex items-center bg-card/90 border border-border rounded-md shadow-sm backdrop-blur-sm", children: [
|
|
3231
|
+
/* @__PURE__ */ jsx26(
|
|
3232
|
+
"button",
|
|
3233
|
+
{
|
|
3234
|
+
type: "button",
|
|
3235
|
+
id: "editor-zoom-out",
|
|
3236
|
+
onClick: () => setZoom((z) => Math.max(0.2, z - 0.15)),
|
|
3237
|
+
className: "h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors",
|
|
3238
|
+
title: "Zoom out",
|
|
3239
|
+
children: /* @__PURE__ */ jsx26(ZoomOut, { className: "w-3 h-3" })
|
|
3240
|
+
}
|
|
3241
|
+
),
|
|
3242
|
+
/* @__PURE__ */ jsxs22("span", { className: "w-9 text-center font-mono text-[10px] text-foreground font-medium", children: [
|
|
3243
|
+
Math.round(zoom * 100),
|
|
3244
|
+
"%"
|
|
3245
|
+
] }),
|
|
3246
|
+
/* @__PURE__ */ jsx26(
|
|
3247
|
+
"button",
|
|
3248
|
+
{
|
|
3249
|
+
type: "button",
|
|
3250
|
+
id: "editor-zoom-in",
|
|
3251
|
+
onClick: () => setZoom((z) => Math.min(5, z + 0.15)),
|
|
3252
|
+
className: "h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors",
|
|
3253
|
+
title: "Zoom in",
|
|
3254
|
+
children: /* @__PURE__ */ jsx26(ZoomIn, { className: "w-3 h-3" })
|
|
3255
|
+
}
|
|
3256
|
+
),
|
|
3257
|
+
/* @__PURE__ */ jsx26("div", { className: "h-3 w-px bg-border mx-0.5" }),
|
|
3258
|
+
/* @__PURE__ */ jsx26(
|
|
3259
|
+
"button",
|
|
3260
|
+
{
|
|
3261
|
+
type: "button",
|
|
3262
|
+
id: "editor-fit-btn",
|
|
3263
|
+
onClick: () => {
|
|
3264
|
+
setZoom(1);
|
|
3265
|
+
setPan({ x: 0, y: 0 });
|
|
3266
|
+
},
|
|
3267
|
+
className: "h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors",
|
|
3268
|
+
title: "Fit to view",
|
|
3269
|
+
children: /* @__PURE__ */ jsx26(Maximize2, { className: "w-3 h-3" })
|
|
3270
|
+
}
|
|
3271
|
+
)
|
|
3272
|
+
] }),
|
|
3273
|
+
/* @__PURE__ */ jsxs22(
|
|
3274
|
+
"div",
|
|
3275
|
+
{
|
|
3276
|
+
ref: frameRef,
|
|
3277
|
+
onPointerDown: handleFramePointerDown,
|
|
3278
|
+
className: "absolute inset-3 touch-none cursor-grab active:cursor-grabbing",
|
|
3279
|
+
children: [
|
|
3280
|
+
/* @__PURE__ */ jsx26("div", { className: "absolute inset-0 flex items-center justify-center", children: /* @__PURE__ */ jsx26(
|
|
3281
|
+
"img",
|
|
3282
|
+
{
|
|
3283
|
+
src: imageSrc,
|
|
3284
|
+
alt: "Editor preview",
|
|
3285
|
+
draggable: false,
|
|
3286
|
+
className: "max-w-full max-h-full object-contain pointer-events-none select-none block",
|
|
3287
|
+
style: {
|
|
3288
|
+
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom * (flipX ? -1 : 1)}, ${zoom * (flipY ? -1 : 1)}) rotate(${rotation}deg)`,
|
|
3289
|
+
filter: filterStyle,
|
|
3290
|
+
transition: isDraggingBox ? "none" : "transform 0.1s ease-out"
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
) }),
|
|
3294
|
+
/* @__PURE__ */ jsx26("div", { className: "absolute inset-0 pointer-events-none z-10", children: /* @__PURE__ */ jsxs22(
|
|
3295
|
+
"div",
|
|
3296
|
+
{
|
|
3297
|
+
className: "absolute",
|
|
3298
|
+
style: {
|
|
3299
|
+
left: `${cropBox.x * 100}%`,
|
|
3300
|
+
top: `${cropBox.y * 100}%`,
|
|
3301
|
+
width: `${cropBox.w * 100}%`,
|
|
3302
|
+
height: `${cropBox.h * 100}%`,
|
|
3303
|
+
boxShadow: "0 0 0 9999px rgba(0,0,0,0.55)"
|
|
3304
|
+
},
|
|
3305
|
+
children: [
|
|
3306
|
+
/* @__PURE__ */ jsxs22(
|
|
3307
|
+
"div",
|
|
3308
|
+
{
|
|
3309
|
+
"data-crop-handle": "body",
|
|
3310
|
+
onPointerDown: (e) => startDrag(e, "body"),
|
|
3311
|
+
className: "absolute inset-0 border-2 border-white/90 cursor-move pointer-events-auto group",
|
|
3312
|
+
children: [
|
|
3313
|
+
/* @__PURE__ */ jsx26("div", { className: `absolute inset-0 grid grid-cols-3 grid-rows-3 transition-opacity ${isDraggingBox ? "opacity-50" : "opacity-0 group-hover:opacity-30"}`, children: Array.from({ length: 9 }).map((_, i) => /* @__PURE__ */ jsx26("div", { className: [i % 3 !== 2 ? "border-r border-white/50" : "", i < 6 ? "border-b border-white/50" : ""].join(" ") }, i)) }),
|
|
3314
|
+
[
|
|
3315
|
+
"absolute -top-px -left-px border-t-2 border-l-2",
|
|
3316
|
+
"absolute -top-px -right-px border-t-2 border-r-2",
|
|
3317
|
+
"absolute -bottom-px -left-px border-b-2 border-l-2",
|
|
3318
|
+
"absolute -bottom-px -right-px border-b-2 border-r-2"
|
|
3319
|
+
].map((cls, i) => /* @__PURE__ */ jsx26("div", { className: `${cls} w-3.5 h-3.5 border-white pointer-events-none` }, i))
|
|
3320
|
+
]
|
|
3321
|
+
}
|
|
3322
|
+
),
|
|
3323
|
+
HANDLE_DEFS.map(({ key, cursor, style }) => /* @__PURE__ */ jsx26(
|
|
3324
|
+
"div",
|
|
3325
|
+
{
|
|
3326
|
+
"data-crop-handle": key,
|
|
3327
|
+
onPointerDown: (e) => key && startDrag(e, key),
|
|
3328
|
+
className: "absolute bg-white border border-black/30 rounded-sm shadow-sm pointer-events-auto z-20 hover:scale-110 transition-transform",
|
|
3329
|
+
style: { width: HANDLE_SIZE, height: HANDLE_SIZE, cursor, ...style }
|
|
3330
|
+
},
|
|
3331
|
+
key
|
|
3332
|
+
))
|
|
3333
|
+
]
|
|
3334
|
+
}
|
|
3335
|
+
) })
|
|
3336
|
+
]
|
|
3337
|
+
}
|
|
3338
|
+
)
|
|
3339
|
+
] }),
|
|
3340
|
+
activeTab === "crop" ? /* @__PURE__ */ jsxs22("div", { className: "bg-card border-t border-border shrink-0", children: [
|
|
3341
|
+
/* @__PURE__ */ jsxs22("div", { className: "flex items-center gap-2 px-3 sm:px-4 h-10 border-b border-border/50", children: [
|
|
3342
|
+
/* @__PURE__ */ jsx26("span", { className: "text-[10px] font-semibold text-muted-foreground uppercase tracking-wider shrink-0", children: "Rotate" }),
|
|
3343
|
+
/* @__PURE__ */ jsx26("div", { className: "flex-1 min-w-0", children: /* @__PURE__ */ jsx26(
|
|
3344
|
+
InlineSlider,
|
|
3345
|
+
{
|
|
3346
|
+
value: [rotation],
|
|
3347
|
+
onValueChange: ([v]) => setRotation(v),
|
|
3348
|
+
min: -180,
|
|
3349
|
+
max: 180,
|
|
3350
|
+
step: 1
|
|
3351
|
+
}
|
|
3352
|
+
) }),
|
|
3353
|
+
/* @__PURE__ */ jsxs22("span", { className: "font-mono text-[11px] text-foreground w-8 text-right shrink-0", children: [
|
|
3354
|
+
rotation,
|
|
3355
|
+
"\xB0"
|
|
3356
|
+
] }),
|
|
3357
|
+
/* @__PURE__ */ jsx26("div", { className: "h-4 w-px bg-border mx-1 shrink-0" }),
|
|
3358
|
+
/* @__PURE__ */ jsxs22(
|
|
3359
|
+
"button",
|
|
3360
|
+
{
|
|
3361
|
+
type: "button",
|
|
3362
|
+
id: "editor-rotate-ccw",
|
|
3363
|
+
onClick: () => setRotation((r) => r - 90),
|
|
3364
|
+
title: "Rotate \u221290\xB0",
|
|
3365
|
+
className: "h-7 px-2 flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded hover:bg-muted transition-colors shrink-0",
|
|
3366
|
+
children: [
|
|
3367
|
+
/* @__PURE__ */ jsx26(RotateCcw, { className: "w-3 h-3" }),
|
|
3368
|
+
/* @__PURE__ */ jsx26("span", { className: "hidden sm:inline", children: "\u221290\xB0" })
|
|
3369
|
+
]
|
|
3370
|
+
}
|
|
3371
|
+
),
|
|
3372
|
+
/* @__PURE__ */ jsxs22(
|
|
3373
|
+
"button",
|
|
3374
|
+
{
|
|
3375
|
+
type: "button",
|
|
3376
|
+
id: "editor-rotate-cw",
|
|
3377
|
+
onClick: () => setRotation((r) => r + 90),
|
|
3378
|
+
title: "Rotate +90\xB0",
|
|
3379
|
+
className: "h-7 px-2 flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded hover:bg-muted transition-colors shrink-0",
|
|
3380
|
+
children: [
|
|
3381
|
+
/* @__PURE__ */ jsx26(RotateCw, { className: "w-3 h-3" }),
|
|
3382
|
+
/* @__PURE__ */ jsx26("span", { className: "hidden sm:inline", children: "+90\xB0" })
|
|
3383
|
+
]
|
|
3384
|
+
}
|
|
3385
|
+
),
|
|
3386
|
+
/* @__PURE__ */ jsx26("div", { className: "h-4 w-px bg-border mx-0.5 shrink-0" }),
|
|
3387
|
+
/* @__PURE__ */ jsx26(
|
|
3388
|
+
"button",
|
|
3389
|
+
{
|
|
3390
|
+
type: "button",
|
|
3391
|
+
id: "editor-flip-h",
|
|
3392
|
+
onClick: () => setFlipX((f) => !f),
|
|
3393
|
+
title: "Flip Horizontal",
|
|
3394
|
+
className: ["h-7 w-7 flex items-center justify-center rounded border transition-colors shrink-0", flipX ? "bg-primary/10 text-primary border-primary/40" : "border-border text-muted-foreground hover:text-foreground hover:bg-muted"].join(" "),
|
|
3395
|
+
children: /* @__PURE__ */ jsx26(FlipHorizontal, { className: "w-3.5 h-3.5" })
|
|
3396
|
+
}
|
|
3397
|
+
),
|
|
3398
|
+
/* @__PURE__ */ jsx26(
|
|
3399
|
+
"button",
|
|
3400
|
+
{
|
|
3401
|
+
type: "button",
|
|
3402
|
+
id: "editor-flip-v",
|
|
3403
|
+
onClick: () => setFlipY((f) => !f),
|
|
3404
|
+
title: "Flip Vertical",
|
|
3405
|
+
className: ["h-7 w-7 flex items-center justify-center rounded border transition-colors shrink-0", flipY ? "bg-primary/10 text-primary border-primary/40" : "border-border text-muted-foreground hover:text-foreground hover:bg-muted"].join(" "),
|
|
3406
|
+
children: /* @__PURE__ */ jsx26(FlipVertical, { className: "w-3.5 h-3.5" })
|
|
3407
|
+
}
|
|
3408
|
+
)
|
|
3409
|
+
] }),
|
|
3410
|
+
/* @__PURE__ */ jsx26("div", { className: "flex items-center gap-1.5 px-3 sm:px-4 h-9 overflow-x-auto scrollbar-hide", children: ASPECT_PRESETS.map((preset) => {
|
|
3411
|
+
const active = activePresetRatio === preset.ratio;
|
|
3412
|
+
return /* @__PURE__ */ jsxs22(
|
|
3413
|
+
"button",
|
|
3414
|
+
{
|
|
3415
|
+
type: "button",
|
|
3416
|
+
onClick: () => handleSelectPreset(preset),
|
|
3417
|
+
className: [
|
|
3418
|
+
"flex items-center gap-1 px-2.5 py-1 rounded text-[11px] font-medium border shrink-0 transition-all whitespace-nowrap",
|
|
3419
|
+
active ? "bg-primary/10 border-primary/50 text-primary font-semibold" : "border-border text-muted-foreground hover:text-foreground hover:bg-muted"
|
|
3420
|
+
].join(" "),
|
|
3421
|
+
children: [
|
|
3422
|
+
preset.icon,
|
|
3423
|
+
/* @__PURE__ */ jsx26("span", { children: preset.label })
|
|
3424
|
+
]
|
|
3425
|
+
},
|
|
3426
|
+
preset.label
|
|
3427
|
+
);
|
|
3428
|
+
}) })
|
|
3429
|
+
] }) : (
|
|
3430
|
+
/* Adjustments panel — compact 2-col grid */
|
|
3431
|
+
/* @__PURE__ */ jsx26("div", { className: "bg-card border-t border-border px-4 py-3 shrink-0", children: /* @__PURE__ */ jsx26("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3 max-w-4xl mx-auto", children: [
|
|
3432
|
+
{ label: "Brightness", icon: /* @__PURE__ */ jsx26(Sun, { className: "w-3 h-3 text-amber-500" }), value: brightness, set: setBrightness, min: -100, max: 100, unit: "%" },
|
|
3433
|
+
{ label: "Contrast", icon: /* @__PURE__ */ jsx26(ContrastIcon, { className: "w-3 h-3 text-blue-500" }), value: contrast, set: setContrast, min: -100, max: 100, unit: "%" },
|
|
3434
|
+
{ label: "Saturation", icon: /* @__PURE__ */ jsx26(Sparkles, { className: "w-3 h-3 text-violet-500" }), value: saturation, set: setSaturation, min: -100, max: 100, unit: "%" },
|
|
3435
|
+
{ label: "Blur", icon: /* @__PURE__ */ jsx26(Sliders, { className: "w-3 h-3 text-emerald-500" }), value: blur, set: setBlur, min: 0, max: 10, step: 0.5, unit: "px" }
|
|
3436
|
+
].map(({ label, icon, value, set, min, max, step = 1, unit }) => /* @__PURE__ */ jsxs22("div", { className: "flex items-center gap-2", children: [
|
|
3437
|
+
/* @__PURE__ */ jsxs22("span", { className: "flex items-center gap-1 text-[11px] font-medium text-foreground w-20 shrink-0", children: [
|
|
3438
|
+
icon,
|
|
3439
|
+
label
|
|
3440
|
+
] }),
|
|
3441
|
+
/* @__PURE__ */ jsx26("div", { className: "flex-1 min-w-0", children: /* @__PURE__ */ jsx26(InlineSlider, { value: [value], onValueChange: ([v]) => set(v), min, max, step }) }),
|
|
3442
|
+
/* @__PURE__ */ jsxs22("span", { className: "font-mono text-[11px] text-muted-foreground w-9 text-right shrink-0", children: [
|
|
3443
|
+
value,
|
|
3444
|
+
unit
|
|
3445
|
+
] })
|
|
3446
|
+
] }, label)) }) })
|
|
3447
|
+
),
|
|
3448
|
+
/* @__PURE__ */ jsxs22("div", { className: "flex items-center justify-between gap-3 px-3 sm:px-4 h-10 border-t border-border bg-card shrink-0", children: [
|
|
3449
|
+
/* @__PURE__ */ jsxs22(
|
|
3450
|
+
"button",
|
|
3451
|
+
{
|
|
3452
|
+
type: "button",
|
|
3453
|
+
id: "editor-cancel-btn",
|
|
3454
|
+
onClick: resetModal,
|
|
3455
|
+
className: `${btnBase} flex items-center gap-1.5 h-7 px-3 text-xs text-muted-foreground hover:text-foreground border border-border rounded hover:bg-muted transition-colors`,
|
|
3456
|
+
children: [
|
|
3457
|
+
/* @__PURE__ */ jsx26(X3, { className: "w-3 h-3" }),
|
|
3458
|
+
"Cancel"
|
|
3459
|
+
]
|
|
3460
|
+
}
|
|
3461
|
+
),
|
|
3462
|
+
/* @__PURE__ */ jsxs22(
|
|
3463
|
+
"button",
|
|
3464
|
+
{
|
|
3465
|
+
type: "button",
|
|
3466
|
+
id: "editor-apply-btn",
|
|
3467
|
+
onClick: handleApply,
|
|
3468
|
+
disabled: isProcessing,
|
|
3469
|
+
className: `${btnBase} flex items-center gap-1.5 h-7 px-5 text-xs font-semibold bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors disabled:opacity-60`,
|
|
3470
|
+
children: [
|
|
3471
|
+
isProcessing ? /* @__PURE__ */ jsx26(Loader24, { className: "w-3 h-3 animate-spin" }) : /* @__PURE__ */ jsx26(Check4, { className: "w-3 h-3" }),
|
|
3472
|
+
isProcessing ? "Processing\u2026" : "Apply Edits"
|
|
3473
|
+
]
|
|
3474
|
+
}
|
|
3475
|
+
)
|
|
3476
|
+
] })
|
|
3477
|
+
] })
|
|
3478
|
+
) })
|
|
3479
|
+
]
|
|
3480
|
+
}
|
|
3481
|
+
),
|
|
3482
|
+
/* @__PURE__ */ jsx26("canvas", { ref: canvasRef, className: "hidden", "aria-hidden": "true" })
|
|
3483
|
+
] }) });
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
// src/components/shared/ImageViewerModal.tsx
|
|
3487
|
+
import { useState as useState14, useEffect as useEffect10, useCallback as useCallback3, useRef as useRef8 } from "react";
|
|
3488
|
+
import { createPortal } from "react-dom";
|
|
3489
|
+
import { motion as motion3, AnimatePresence as AnimatePresence2, useMotionValue } from "framer-motion";
|
|
3490
|
+
import { useGesture } from "@use-gesture/react";
|
|
3491
|
+
import {
|
|
3492
|
+
X as X4,
|
|
3493
|
+
ChevronLeft,
|
|
3494
|
+
ChevronRight,
|
|
3495
|
+
ZoomIn as ZoomIn2,
|
|
3496
|
+
ZoomOut as ZoomOut2,
|
|
3497
|
+
RotateCw as RotateCw2,
|
|
3498
|
+
Maximize,
|
|
3499
|
+
Minimize,
|
|
3500
|
+
RefreshCcw
|
|
3501
|
+
} from "lucide-react";
|
|
3502
|
+
import { Fragment as Fragment4, jsx as jsx27, jsxs as jsxs23 } from "react/jsx-runtime";
|
|
3503
|
+
function ImageViewerModal({
|
|
3504
|
+
isOpen,
|
|
3505
|
+
onClose,
|
|
3506
|
+
images = [],
|
|
3507
|
+
selectedImage,
|
|
3508
|
+
setSelectedImage,
|
|
3509
|
+
productName
|
|
3510
|
+
}) {
|
|
3511
|
+
const scale = useMotionValue(1);
|
|
3512
|
+
const x = useMotionValue(0);
|
|
3513
|
+
const y = useMotionValue(0);
|
|
3514
|
+
const rotation = useMotionValue(0);
|
|
3515
|
+
const [mounted, setMounted] = useState14(false);
|
|
3516
|
+
const [isZoomed, setIsZoomed] = useState14(false);
|
|
3517
|
+
const [currentZoomLevel, setCurrentZoomLevel] = useState14(1);
|
|
3518
|
+
const [isFullscreen, setIsFullscreen] = useState14(false);
|
|
3519
|
+
const [mainImgLoaded, setMainImgLoaded] = useState14(false);
|
|
3520
|
+
const containerRef = useRef8(null);
|
|
3521
|
+
const prevSelectedImageRef = useRef8(selectedImage);
|
|
3522
|
+
const prevIsOpenRef = useRef8(isOpen);
|
|
3523
|
+
useEffect10(() => {
|
|
3524
|
+
setMounted(true);
|
|
3525
|
+
}, []);
|
|
3526
|
+
useEffect10(() => {
|
|
3527
|
+
if (prevSelectedImageRef.current !== selectedImage || prevIsOpenRef.current !== isOpen) {
|
|
3528
|
+
setIsZoomed(false);
|
|
3529
|
+
setCurrentZoomLevel(1);
|
|
3530
|
+
setMainImgLoaded(false);
|
|
3531
|
+
prevSelectedImageRef.current = selectedImage;
|
|
3532
|
+
prevIsOpenRef.current = isOpen;
|
|
3533
|
+
}
|
|
3534
|
+
scale.set(1);
|
|
3535
|
+
x.set(0);
|
|
3536
|
+
y.set(0);
|
|
3537
|
+
rotation.set(0);
|
|
3538
|
+
}, [selectedImage, isOpen, scale, x, y, rotation]);
|
|
3539
|
+
const handleNext = useCallback3(() => {
|
|
3540
|
+
if (!images.length) return;
|
|
3541
|
+
setSelectedImage((selectedImage + 1) % images.length);
|
|
3542
|
+
}, [selectedImage, images.length, setSelectedImage]);
|
|
3543
|
+
const handlePrev = useCallback3(() => {
|
|
3544
|
+
if (!images.length) return;
|
|
3545
|
+
setSelectedImage((selectedImage - 1 + images.length) % images.length);
|
|
3546
|
+
}, [selectedImage, images.length, setSelectedImage]);
|
|
3547
|
+
const handleZoomIn = useCallback3(() => {
|
|
3548
|
+
const next = Math.min(scale.get() + 0.5, 4);
|
|
3549
|
+
scale.set(next);
|
|
3550
|
+
setCurrentZoomLevel(next);
|
|
3551
|
+
setIsZoomed(next > 1);
|
|
3552
|
+
}, [scale]);
|
|
3553
|
+
const handleZoomOut = useCallback3(() => {
|
|
3554
|
+
const next = Math.max(scale.get() - 0.5, 1);
|
|
3555
|
+
scale.set(next);
|
|
3556
|
+
setCurrentZoomLevel(next);
|
|
3557
|
+
setIsZoomed(next > 1);
|
|
3558
|
+
if (next === 1) {
|
|
3559
|
+
x.set(0);
|
|
3560
|
+
y.set(0);
|
|
3561
|
+
}
|
|
3562
|
+
}, [scale, x, y]);
|
|
3563
|
+
const handleRotate = useCallback3(() => {
|
|
3564
|
+
rotation.set(rotation.get() + 90);
|
|
3565
|
+
}, [rotation]);
|
|
3566
|
+
const handleReset = useCallback3(() => {
|
|
3567
|
+
scale.set(1);
|
|
3568
|
+
x.set(0);
|
|
3569
|
+
y.set(0);
|
|
3570
|
+
rotation.set(0);
|
|
3571
|
+
setCurrentZoomLevel(1);
|
|
3572
|
+
setIsZoomed(false);
|
|
3573
|
+
}, [scale, x, y, rotation]);
|
|
3574
|
+
const toggleFullscreen = useCallback3(() => {
|
|
3575
|
+
if (!containerRef.current) return;
|
|
3576
|
+
if (!document.fullscreenElement) {
|
|
3577
|
+
containerRef.current.requestFullscreen().catch((err) => {
|
|
3578
|
+
logger.error(`Error attempting to enable full-screen mode: ${err.message}`);
|
|
3579
|
+
});
|
|
3580
|
+
} else {
|
|
3581
|
+
document.exitFullscreen().catch(() => {
|
|
3582
|
+
});
|
|
3583
|
+
}
|
|
3584
|
+
}, []);
|
|
3585
|
+
useEffect10(() => {
|
|
3586
|
+
const handleFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
|
|
3587
|
+
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
3588
|
+
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
|
3589
|
+
}, []);
|
|
3590
|
+
useEffect10(() => {
|
|
3591
|
+
if (!isOpen) return;
|
|
3592
|
+
const handleKeyDown = (e) => {
|
|
3593
|
+
if (e.key === "Escape") onClose();
|
|
3594
|
+
if (e.key === "ArrowLeft") handlePrev();
|
|
3595
|
+
if (e.key === "ArrowRight") handleNext();
|
|
3596
|
+
if (e.key === "+" || e.key === "=") handleZoomIn();
|
|
3597
|
+
if (e.key === "-") handleZoomOut();
|
|
3598
|
+
};
|
|
3599
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
3600
|
+
document.body.style.overflow = "hidden";
|
|
3601
|
+
return () => {
|
|
3602
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
3603
|
+
document.body.style.overflow = "";
|
|
3604
|
+
};
|
|
3605
|
+
}, [isOpen, onClose, handleNext, handlePrev, handleZoomIn, handleZoomOut]);
|
|
3606
|
+
const bind = useGesture(
|
|
3607
|
+
{
|
|
3608
|
+
onDrag: ({ down, movement: [mx], offset: [ox, oy] }) => {
|
|
3609
|
+
if (scale.get() > 1) {
|
|
3610
|
+
if (down) {
|
|
3611
|
+
x.set(ox);
|
|
3612
|
+
y.set(oy);
|
|
3613
|
+
}
|
|
3614
|
+
} else {
|
|
3615
|
+
if (!down) {
|
|
3616
|
+
if (mx < -60) handleNext();
|
|
3617
|
+
else if (mx > 60) handlePrev();
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
},
|
|
3621
|
+
onPinch: ({ offset: [s] }) => {
|
|
3622
|
+
scale.set(s);
|
|
3623
|
+
setCurrentZoomLevel(s);
|
|
3624
|
+
setIsZoomed(s > 1);
|
|
3625
|
+
if (s <= 1) {
|
|
3626
|
+
x.set(0);
|
|
3627
|
+
y.set(0);
|
|
3628
|
+
}
|
|
3629
|
+
},
|
|
3630
|
+
onWheel: ({ delta: [, dy], ctrlKey }) => {
|
|
3631
|
+
if (ctrlKey) {
|
|
3632
|
+
const next = Math.min(Math.max(1, scale.get() - dy / 100), 4);
|
|
3633
|
+
scale.set(next);
|
|
3634
|
+
setCurrentZoomLevel(next);
|
|
3635
|
+
setIsZoomed(next > 1);
|
|
3636
|
+
}
|
|
3637
|
+
},
|
|
3638
|
+
onDoubleClick: ({ event }) => {
|
|
3639
|
+
event.stopPropagation();
|
|
3640
|
+
if (scale.get() > 1) {
|
|
3641
|
+
handleReset();
|
|
3642
|
+
} else {
|
|
3643
|
+
scale.set(2.5);
|
|
3644
|
+
setCurrentZoomLevel(2.5);
|
|
3645
|
+
setIsZoomed(true);
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
},
|
|
3649
|
+
{
|
|
3650
|
+
drag: { from: () => [x.get(), y.get()], threshold: 10 },
|
|
3651
|
+
pinch: { scaleBounds: { min: 1, max: 4 }, modifierKey: null }
|
|
3652
|
+
}
|
|
3653
|
+
);
|
|
3654
|
+
if (!images.length) return null;
|
|
3655
|
+
const btnStyle = {
|
|
3656
|
+
background: "rgba(255, 255, 255, 0.1)",
|
|
3657
|
+
border: "none",
|
|
3658
|
+
borderRadius: "9999px",
|
|
3659
|
+
padding: "8px",
|
|
3660
|
+
color: "rgba(255, 255, 255, 0.9)",
|
|
3661
|
+
cursor: "pointer",
|
|
3662
|
+
display: "inline-flex",
|
|
3663
|
+
alignItems: "center",
|
|
3664
|
+
justifyContent: "center",
|
|
3665
|
+
transition: "background-color 0.15s ease, color 0.15s ease"
|
|
3666
|
+
};
|
|
3667
|
+
const navBtnStyle = {
|
|
3668
|
+
position: "absolute",
|
|
3669
|
+
top: "50%",
|
|
3670
|
+
transform: "translateY(-50%)",
|
|
3671
|
+
background: "rgba(0, 0, 0, 0.6)",
|
|
3672
|
+
border: "1px solid rgba(255, 255, 255, 0.2)",
|
|
3673
|
+
borderRadius: "9999px",
|
|
3674
|
+
padding: "14px",
|
|
3675
|
+
color: "#ffffff",
|
|
3676
|
+
cursor: "pointer",
|
|
3677
|
+
backdropFilter: "blur(8px)",
|
|
3678
|
+
zIndex: 40,
|
|
3679
|
+
display: "flex",
|
|
3680
|
+
alignItems: "center",
|
|
3681
|
+
justifyContent: "center",
|
|
3682
|
+
boxShadow: "0 10px 25px rgba(0, 0, 0, 0.5)",
|
|
3683
|
+
transition: "transform 0.15s ease, background-color 0.15s ease"
|
|
3684
|
+
};
|
|
3685
|
+
const modalContent = /* @__PURE__ */ jsx27(AnimatePresence2, { children: isOpen && /* @__PURE__ */ jsxs23(
|
|
3686
|
+
motion3.div,
|
|
3687
|
+
{
|
|
3688
|
+
ref: containerRef,
|
|
3689
|
+
initial: { opacity: 0 },
|
|
3690
|
+
animate: { opacity: 1 },
|
|
3691
|
+
exit: { opacity: 0 },
|
|
3692
|
+
transition: { duration: 0.15, ease: "easeOut" },
|
|
3693
|
+
style: {
|
|
3694
|
+
position: "fixed",
|
|
3695
|
+
top: 0,
|
|
3696
|
+
left: 0,
|
|
3697
|
+
right: 0,
|
|
3698
|
+
bottom: 0,
|
|
3699
|
+
width: "100vw",
|
|
3700
|
+
height: "100vh",
|
|
3701
|
+
zIndex: 99999,
|
|
3702
|
+
backgroundColor: "#000000",
|
|
3703
|
+
display: "flex",
|
|
3704
|
+
flexDirection: "column",
|
|
3705
|
+
userSelect: "none",
|
|
3706
|
+
WebkitUserSelect: "none",
|
|
3707
|
+
overflow: "hidden",
|
|
3708
|
+
boxSizing: "border-box"
|
|
3709
|
+
},
|
|
3710
|
+
className: "fixed inset-0 z-[99999] bg-black flex flex-col select-none overflow-hidden",
|
|
3711
|
+
onClick: onClose,
|
|
3712
|
+
role: "dialog",
|
|
3713
|
+
"aria-modal": "true",
|
|
3714
|
+
"aria-label": `${productName} image viewer`,
|
|
3715
|
+
children: [
|
|
3716
|
+
/* @__PURE__ */ jsxs23(
|
|
3717
|
+
"div",
|
|
3718
|
+
{
|
|
3719
|
+
style: {
|
|
3720
|
+
display: "flex",
|
|
3721
|
+
justifyContent: "space-between",
|
|
3722
|
+
alignItems: "center",
|
|
3723
|
+
padding: "12px 20px",
|
|
3724
|
+
zIndex: 50,
|
|
3725
|
+
color: "#ffffff",
|
|
3726
|
+
background: "linear-gradient(to bottom, rgba(0,0,0,0.85), rgba(0,0,0,0))",
|
|
3727
|
+
flexShrink: 0,
|
|
3728
|
+
width: "100%",
|
|
3729
|
+
boxSizing: "border-box"
|
|
3730
|
+
},
|
|
3731
|
+
onClick: (e) => e.stopPropagation(),
|
|
3732
|
+
children: [
|
|
3733
|
+
/* @__PURE__ */ jsxs23("div", { style: { display: "flex", alignItems: "center", gap: "12px" }, children: [
|
|
3734
|
+
/* @__PURE__ */ jsxs23(
|
|
3735
|
+
"span",
|
|
3736
|
+
{
|
|
3737
|
+
style: {
|
|
3738
|
+
fontSize: "13px",
|
|
3739
|
+
fontWeight: 500,
|
|
3740
|
+
letterSpacing: "0.08em",
|
|
3741
|
+
color: "rgba(255, 255, 255, 0.9)",
|
|
3742
|
+
backgroundColor: "rgba(255, 255, 255, 0.12)",
|
|
3743
|
+
padding: "4px 12px",
|
|
3744
|
+
borderRadius: "9999px",
|
|
3745
|
+
border: "1px solid rgba(255, 255, 255, 0.15)"
|
|
3746
|
+
},
|
|
3747
|
+
children: [
|
|
3748
|
+
selectedImage + 1,
|
|
3749
|
+
" / ",
|
|
3750
|
+
images.length
|
|
3751
|
+
]
|
|
3752
|
+
}
|
|
3753
|
+
),
|
|
3754
|
+
productName && /* @__PURE__ */ jsx27(
|
|
3755
|
+
"span",
|
|
3756
|
+
{
|
|
3757
|
+
style: {
|
|
3758
|
+
fontSize: "13px",
|
|
3759
|
+
fontWeight: 500,
|
|
3760
|
+
color: "rgba(255, 255, 255, 0.7)",
|
|
3761
|
+
whiteSpace: "nowrap",
|
|
3762
|
+
overflow: "hidden",
|
|
3763
|
+
textOverflow: "ellipsis",
|
|
3764
|
+
maxWidth: "300px"
|
|
3765
|
+
},
|
|
3766
|
+
className: "hidden sm:inline",
|
|
3767
|
+
children: productName
|
|
3768
|
+
}
|
|
3769
|
+
)
|
|
3770
|
+
] }),
|
|
3771
|
+
/* @__PURE__ */ jsxs23("div", { style: { display: "flex", alignItems: "center", gap: "8px" }, children: [
|
|
3772
|
+
currentZoomLevel > 1 && /* @__PURE__ */ jsxs23(
|
|
3773
|
+
"span",
|
|
3774
|
+
{
|
|
3775
|
+
style: {
|
|
3776
|
+
fontSize: "11px",
|
|
3777
|
+
fontFamily: "monospace",
|
|
3778
|
+
color: "rgba(255, 255, 255, 0.7)",
|
|
3779
|
+
marginRight: "4px"
|
|
3780
|
+
},
|
|
3781
|
+
className: "hidden sm:inline",
|
|
3782
|
+
children: [
|
|
3783
|
+
Math.round(currentZoomLevel * 100),
|
|
3784
|
+
"%"
|
|
3785
|
+
]
|
|
3786
|
+
}
|
|
3787
|
+
),
|
|
3788
|
+
/* @__PURE__ */ jsx27(
|
|
3789
|
+
"button",
|
|
3790
|
+
{
|
|
3791
|
+
type: "button",
|
|
3792
|
+
onClick: handleZoomOut,
|
|
3793
|
+
style: btnStyle,
|
|
3794
|
+
title: "Zoom Out (-)",
|
|
3795
|
+
onMouseEnter: (e) => e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.25)",
|
|
3796
|
+
onMouseLeave: (e) => e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.1)",
|
|
3797
|
+
children: /* @__PURE__ */ jsx27(ZoomOut2, { className: "w-5 h-5", style: { width: "20px", height: "20px" } })
|
|
3798
|
+
}
|
|
3799
|
+
),
|
|
3800
|
+
/* @__PURE__ */ jsx27(
|
|
3801
|
+
"button",
|
|
3802
|
+
{
|
|
3803
|
+
type: "button",
|
|
3804
|
+
onClick: handleZoomIn,
|
|
3805
|
+
style: btnStyle,
|
|
3806
|
+
title: "Zoom In (+)",
|
|
3807
|
+
onMouseEnter: (e) => e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.25)",
|
|
3808
|
+
onMouseLeave: (e) => e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.1)",
|
|
3809
|
+
children: /* @__PURE__ */ jsx27(ZoomIn2, { className: "w-5 h-5", style: { width: "20px", height: "20px" } })
|
|
3810
|
+
}
|
|
3811
|
+
),
|
|
3812
|
+
/* @__PURE__ */ jsx27(
|
|
3813
|
+
"button",
|
|
3814
|
+
{
|
|
3815
|
+
type: "button",
|
|
3816
|
+
onClick: handleRotate,
|
|
3817
|
+
style: btnStyle,
|
|
3818
|
+
title: "Rotate 90\xB0",
|
|
3819
|
+
onMouseEnter: (e) => e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.25)",
|
|
3820
|
+
onMouseLeave: (e) => e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.1)",
|
|
3821
|
+
children: /* @__PURE__ */ jsx27(RotateCw2, { className: "w-5 h-5", style: { width: "20px", height: "20px" } })
|
|
3822
|
+
}
|
|
3823
|
+
),
|
|
3824
|
+
/* @__PURE__ */ jsx27(
|
|
3825
|
+
"button",
|
|
3826
|
+
{
|
|
3827
|
+
type: "button",
|
|
3828
|
+
onClick: handleReset,
|
|
3829
|
+
style: btnStyle,
|
|
3830
|
+
title: "Reset View",
|
|
3831
|
+
onMouseEnter: (e) => e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.25)",
|
|
3832
|
+
onMouseLeave: (e) => e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.1)",
|
|
3833
|
+
children: /* @__PURE__ */ jsx27(RefreshCcw, { className: "w-5 h-5", style: { width: "20px", height: "20px" } })
|
|
3834
|
+
}
|
|
3835
|
+
),
|
|
3836
|
+
/* @__PURE__ */ jsx27("div", { style: { width: "1px", height: "20px", backgroundColor: "rgba(255,255,255,0.2)", margin: "0 6px" } }),
|
|
3837
|
+
/* @__PURE__ */ jsx27(
|
|
3838
|
+
"button",
|
|
3839
|
+
{
|
|
3840
|
+
type: "button",
|
|
3841
|
+
onClick: toggleFullscreen,
|
|
3842
|
+
style: btnStyle,
|
|
3843
|
+
title: isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen",
|
|
3844
|
+
onMouseEnter: (e) => e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.25)",
|
|
3845
|
+
onMouseLeave: (e) => e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.1)",
|
|
3846
|
+
children: isFullscreen ? /* @__PURE__ */ jsx27(Minimize, { className: "w-5 h-5", style: { width: "20px", height: "20px" } }) : /* @__PURE__ */ jsx27(Maximize, { className: "w-5 h-5", style: { width: "20px", height: "20px" } })
|
|
3847
|
+
}
|
|
3848
|
+
),
|
|
3849
|
+
/* @__PURE__ */ jsx27("div", { style: { width: "1px", height: "20px", backgroundColor: "rgba(255,255,255,0.2)", margin: "0 6px" } }),
|
|
3850
|
+
/* @__PURE__ */ jsx27(
|
|
3851
|
+
"button",
|
|
3852
|
+
{
|
|
3853
|
+
type: "button",
|
|
3854
|
+
onClick: onClose,
|
|
3855
|
+
style: {
|
|
3856
|
+
...btnStyle,
|
|
3857
|
+
backgroundColor: "rgba(239, 68, 68, 0.25)",
|
|
3858
|
+
color: "#f87171"
|
|
3859
|
+
},
|
|
3860
|
+
title: "Close (Esc)",
|
|
3861
|
+
"aria-label": "Close viewer",
|
|
3862
|
+
onMouseEnter: (e) => e.currentTarget.style.backgroundColor = "rgba(239, 68, 68, 0.45)",
|
|
3863
|
+
onMouseLeave: (e) => e.currentTarget.style.backgroundColor = "rgba(239, 68, 68, 0.25)",
|
|
3864
|
+
children: /* @__PURE__ */ jsx27(X4, { className: "w-6 h-6", style: { width: "24px", height: "24px" } })
|
|
3865
|
+
}
|
|
3866
|
+
)
|
|
3867
|
+
] })
|
|
3868
|
+
]
|
|
3869
|
+
}
|
|
3870
|
+
),
|
|
3871
|
+
/* @__PURE__ */ jsxs23(
|
|
3872
|
+
"div",
|
|
3873
|
+
{
|
|
3874
|
+
style: {
|
|
3875
|
+
flex: 1,
|
|
3876
|
+
position: "relative",
|
|
3877
|
+
display: "flex",
|
|
3878
|
+
alignItems: "center",
|
|
3879
|
+
justifyContent: "center",
|
|
3880
|
+
width: "100%",
|
|
3881
|
+
height: "100%",
|
|
3882
|
+
minHeight: 0,
|
|
3883
|
+
overflow: "hidden",
|
|
3884
|
+
touchAction: "none"
|
|
3885
|
+
},
|
|
3886
|
+
...bind(),
|
|
3887
|
+
onClick: (e) => e.stopPropagation(),
|
|
3888
|
+
children: [
|
|
3889
|
+
/* @__PURE__ */ jsxs23(
|
|
3890
|
+
motion3.div,
|
|
3891
|
+
{
|
|
3892
|
+
style: {
|
|
3893
|
+
scale,
|
|
3894
|
+
rotate: rotation,
|
|
3895
|
+
x,
|
|
3896
|
+
y,
|
|
3897
|
+
cursor: isZoomed ? "grab" : "default",
|
|
3898
|
+
width: "100%",
|
|
3899
|
+
height: "100%",
|
|
3900
|
+
padding: "16px",
|
|
3901
|
+
display: "flex",
|
|
3902
|
+
alignItems: "center",
|
|
3903
|
+
justifyContent: "center",
|
|
3904
|
+
boxSizing: "border-box"
|
|
3905
|
+
},
|
|
3906
|
+
className: "will-change-transform",
|
|
3907
|
+
children: [
|
|
3908
|
+
!mainImgLoaded && /* @__PURE__ */ jsx27(
|
|
3909
|
+
"div",
|
|
3910
|
+
{
|
|
3911
|
+
style: {
|
|
3912
|
+
position: "absolute",
|
|
3913
|
+
inset: 0,
|
|
3914
|
+
display: "flex",
|
|
3915
|
+
alignItems: "center",
|
|
3916
|
+
justifyContent: "center",
|
|
3917
|
+
zIndex: 10
|
|
3918
|
+
},
|
|
3919
|
+
children: /* @__PURE__ */ jsx27(
|
|
3920
|
+
"div",
|
|
3921
|
+
{
|
|
3922
|
+
style: {
|
|
3923
|
+
width: "160px",
|
|
3924
|
+
height: "160px",
|
|
3925
|
+
borderRadius: "16px",
|
|
3926
|
+
backgroundColor: "rgba(255,255,255,0.05)",
|
|
3927
|
+
border: "1px solid rgba(255,255,255,0.1)"
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
)
|
|
3931
|
+
}
|
|
3932
|
+
),
|
|
3933
|
+
/* @__PURE__ */ jsx27(
|
|
3934
|
+
motion3.img,
|
|
3935
|
+
{
|
|
3936
|
+
src: images[selectedImage] ?? void 0,
|
|
3937
|
+
alt: `${productName} - Image ${selectedImage + 1}`,
|
|
3938
|
+
style: {
|
|
3939
|
+
maxWidth: "100%",
|
|
3940
|
+
maxHeight: "100%",
|
|
3941
|
+
objectFit: "contain",
|
|
3942
|
+
userSelect: "none",
|
|
3943
|
+
WebkitUserSelect: "none",
|
|
3944
|
+
display: "block",
|
|
3945
|
+
margin: "auto",
|
|
3946
|
+
opacity: mainImgLoaded ? 1 : 0,
|
|
3947
|
+
transition: "opacity 0.25s ease-out",
|
|
3948
|
+
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.8)"
|
|
3949
|
+
},
|
|
3950
|
+
draggable: false,
|
|
3951
|
+
onLoad: () => setMainImgLoaded(true),
|
|
3952
|
+
onDragStart: (e) => e.preventDefault(),
|
|
3953
|
+
onMouseDown: (e) => e.preventDefault()
|
|
3954
|
+
},
|
|
3955
|
+
images[selectedImage]
|
|
3956
|
+
)
|
|
3957
|
+
]
|
|
3958
|
+
}
|
|
3959
|
+
),
|
|
3960
|
+
images.length > 1 && /* @__PURE__ */ jsxs23(Fragment4, { children: [
|
|
3961
|
+
/* @__PURE__ */ jsx27(
|
|
3962
|
+
"button",
|
|
3963
|
+
{
|
|
3964
|
+
type: "button",
|
|
3965
|
+
"aria-label": "Previous image",
|
|
3966
|
+
style: { ...navBtnStyle, left: "24px" },
|
|
3967
|
+
onClick: (e) => {
|
|
3968
|
+
e.stopPropagation();
|
|
3969
|
+
handlePrev();
|
|
3970
|
+
},
|
|
3971
|
+
onMouseEnter: (e) => e.currentTarget.style.backgroundColor = "rgba(0,0,0,0.85)",
|
|
3972
|
+
onMouseLeave: (e) => e.currentTarget.style.backgroundColor = "rgba(0,0,0,0.6)",
|
|
3973
|
+
children: /* @__PURE__ */ jsx27(ChevronLeft, { className: "w-8 h-8", style: { width: "32px", height: "32px" } })
|
|
3974
|
+
}
|
|
3975
|
+
),
|
|
3976
|
+
/* @__PURE__ */ jsx27(
|
|
3977
|
+
"button",
|
|
3978
|
+
{
|
|
3979
|
+
type: "button",
|
|
3980
|
+
"aria-label": "Next image",
|
|
3981
|
+
style: { ...navBtnStyle, right: "24px" },
|
|
3982
|
+
onClick: (e) => {
|
|
3983
|
+
e.stopPropagation();
|
|
3984
|
+
handleNext();
|
|
3985
|
+
},
|
|
3986
|
+
onMouseEnter: (e) => e.currentTarget.style.backgroundColor = "rgba(0,0,0,0.85)",
|
|
3987
|
+
onMouseLeave: (e) => e.currentTarget.style.backgroundColor = "rgba(0,0,0,0.6)",
|
|
3988
|
+
children: /* @__PURE__ */ jsx27(ChevronRight, { className: "w-8 h-8", style: { width: "32px", height: "32px" } })
|
|
3989
|
+
}
|
|
3990
|
+
)
|
|
3991
|
+
] })
|
|
3992
|
+
]
|
|
3993
|
+
}
|
|
3994
|
+
),
|
|
3995
|
+
images.length > 1 && /* @__PURE__ */ jsx27(
|
|
3996
|
+
"div",
|
|
3997
|
+
{
|
|
3998
|
+
style: {
|
|
3999
|
+
padding: "16px 20px",
|
|
4000
|
+
zIndex: 50,
|
|
4001
|
+
background: "linear-gradient(to top, rgba(0, 0, 0, 0.95), rgba(0, 0, 0, 0))",
|
|
4002
|
+
display: "flex",
|
|
4003
|
+
justifyContent: "center",
|
|
4004
|
+
flexShrink: 0,
|
|
4005
|
+
width: "100%",
|
|
4006
|
+
boxSizing: "border-box"
|
|
4007
|
+
},
|
|
4008
|
+
onClick: (e) => e.stopPropagation(),
|
|
4009
|
+
children: /* @__PURE__ */ jsx27(
|
|
4010
|
+
"div",
|
|
4011
|
+
{
|
|
4012
|
+
style: {
|
|
4013
|
+
display: "flex",
|
|
4014
|
+
gap: "12px",
|
|
4015
|
+
overflowX: "auto",
|
|
4016
|
+
maxWidth: "100%",
|
|
4017
|
+
paddingBottom: "4px"
|
|
4018
|
+
},
|
|
4019
|
+
children: images.map((img, idx) => /* @__PURE__ */ jsx27(
|
|
4020
|
+
"button",
|
|
4021
|
+
{
|
|
4022
|
+
type: "button",
|
|
4023
|
+
onClick: () => setSelectedImage(idx),
|
|
4024
|
+
"aria-label": `View image ${idx + 1}`,
|
|
4025
|
+
style: {
|
|
4026
|
+
position: "relative",
|
|
4027
|
+
width: "60px",
|
|
4028
|
+
height: "60px",
|
|
4029
|
+
borderRadius: "10px",
|
|
4030
|
+
overflow: "hidden",
|
|
4031
|
+
flexShrink: 0,
|
|
4032
|
+
border: selectedImage === idx ? "2px solid #ffffff" : "2px solid rgba(255, 255, 255, 0.2)",
|
|
4033
|
+
opacity: selectedImage === idx ? 1 : 0.5,
|
|
4034
|
+
cursor: "pointer",
|
|
4035
|
+
transform: selectedImage === idx ? "scale(1.05)" : "scale(1)",
|
|
4036
|
+
transition: "all 0.2s ease",
|
|
4037
|
+
background: "none",
|
|
4038
|
+
padding: 0
|
|
4039
|
+
},
|
|
4040
|
+
children: /* @__PURE__ */ jsx27(
|
|
4041
|
+
SmartImage_default,
|
|
4042
|
+
{
|
|
4043
|
+
src: img,
|
|
4044
|
+
alt: `Thumbnail ${idx + 1}`,
|
|
4045
|
+
className: "w-full h-full object-cover"
|
|
4046
|
+
}
|
|
4047
|
+
)
|
|
4048
|
+
},
|
|
4049
|
+
idx
|
|
4050
|
+
))
|
|
4051
|
+
}
|
|
4052
|
+
)
|
|
4053
|
+
}
|
|
4054
|
+
)
|
|
4055
|
+
]
|
|
4056
|
+
}
|
|
4057
|
+
) });
|
|
4058
|
+
if (!mounted) return null;
|
|
4059
|
+
return createPortal(modalContent, document.body);
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
// src/components/shared/AnalyticsScripts.tsx
|
|
4063
|
+
import { useEffect as useEffect11, useState as useState15 } from "react";
|
|
4064
|
+
import Script from "next/script";
|
|
4065
|
+
|
|
4066
|
+
// src/utils/cookieConsent.ts
|
|
4067
|
+
var STORAGE_KEY = "kasuvia_cookie_consent";
|
|
4068
|
+
var COOKIE_CONSENT_CHANGED_EVENT = "kasuvia:cookie-consent-changed";
|
|
4069
|
+
function getCookieConsent() {
|
|
4070
|
+
try {
|
|
4071
|
+
const value = window.localStorage.getItem(STORAGE_KEY);
|
|
4072
|
+
return value === "accepted" || value === "rejected" ? value : null;
|
|
4073
|
+
} catch {
|
|
4074
|
+
return null;
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
function setCookieConsent(value) {
|
|
4078
|
+
try {
|
|
4079
|
+
window.localStorage.setItem(STORAGE_KEY, value);
|
|
4080
|
+
window.dispatchEvent(new Event(COOKIE_CONSENT_CHANGED_EVENT));
|
|
4081
|
+
} catch {
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
4084
|
+
|
|
4085
|
+
// src/components/shared/AnalyticsScripts.tsx
|
|
4086
|
+
import { Fragment as Fragment5, jsx as jsx28, jsxs as jsxs24 } from "react/jsx-runtime";
|
|
4087
|
+
function AnalyticsScripts({ websiteId, scriptUrl, recorderUrl }) {
|
|
4088
|
+
const [accepted, setAccepted] = useState15(false);
|
|
4089
|
+
useEffect11(() => {
|
|
4090
|
+
const sync = () => setAccepted(getCookieConsent() === "accepted");
|
|
4091
|
+
sync();
|
|
4092
|
+
window.addEventListener(COOKIE_CONSENT_CHANGED_EVENT, sync);
|
|
4093
|
+
window.addEventListener("storage", sync);
|
|
4094
|
+
return () => {
|
|
4095
|
+
window.removeEventListener(COOKIE_CONSENT_CHANGED_EVENT, sync);
|
|
4096
|
+
window.removeEventListener("storage", sync);
|
|
4097
|
+
};
|
|
4098
|
+
}, []);
|
|
4099
|
+
if (!accepted || !websiteId) return null;
|
|
4100
|
+
return /* @__PURE__ */ jsxs24(Fragment5, { children: [
|
|
4101
|
+
scriptUrl && /* @__PURE__ */ jsx28(Script, { async: true, defer: true, "data-website-id": websiteId, src: scriptUrl, strategy: "afterInteractive" }),
|
|
4102
|
+
recorderUrl && /* @__PURE__ */ jsx28(Script, { defer: true, "data-website-id": websiteId, src: recorderUrl, strategy: "afterInteractive" })
|
|
4103
|
+
] });
|
|
4104
|
+
}
|
|
4105
|
+
|
|
4106
|
+
// src/components/shared/CookieConsentBanner.tsx
|
|
4107
|
+
import { useEffect as useEffect12, useState as useState16 } from "react";
|
|
4108
|
+
import { jsx as jsx29, jsxs as jsxs25 } from "react/jsx-runtime";
|
|
4109
|
+
function CookieConsentBanner({
|
|
4110
|
+
privacyUrl = "/privacy",
|
|
4111
|
+
className,
|
|
4112
|
+
acceptButtonClassName,
|
|
4113
|
+
rejectButtonClassName
|
|
4114
|
+
}) {
|
|
4115
|
+
const [visible, setVisible] = useState16(false);
|
|
4116
|
+
useEffect12(() => {
|
|
4117
|
+
setVisible(getCookieConsent() === null);
|
|
4118
|
+
}, []);
|
|
4119
|
+
if (!visible) return null;
|
|
4120
|
+
const choose = (value) => {
|
|
4121
|
+
setCookieConsent(value);
|
|
4122
|
+
setVisible(false);
|
|
4123
|
+
};
|
|
4124
|
+
const bannerClass = className ?? "fixed inset-x-0 bottom-0 z-50 border-t border-border bg-background/95 backdrop-blur-sm p-4 shadow-[0_-4px_16px_rgba(0,0,0,0.08)]";
|
|
4125
|
+
const rejectClass = rejectButtonClassName ?? "inline-flex items-center justify-center rounded-md border border-input bg-background px-3 h-9 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors";
|
|
4126
|
+
const acceptClass = acceptButtonClassName ?? "inline-flex items-center justify-center rounded-md bg-primary px-3 h-9 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors";
|
|
4127
|
+
return /* @__PURE__ */ jsx29("div", { className: bannerClass, children: /* @__PURE__ */ jsxs25("div", { className: "mx-auto flex max-w-4xl flex-col items-center gap-3 sm:flex-row sm:justify-between", children: [
|
|
4128
|
+
/* @__PURE__ */ jsxs25("p", { className: "text-center text-sm text-muted-foreground sm:text-left", children: [
|
|
4129
|
+
"We use cookies to understand how you use this site and improve your experience.",
|
|
4130
|
+
" ",
|
|
4131
|
+
/* @__PURE__ */ jsx29(
|
|
4132
|
+
"a",
|
|
4133
|
+
{
|
|
4134
|
+
href: privacyUrl,
|
|
4135
|
+
className: "underline underline-offset-2 hover:text-foreground",
|
|
4136
|
+
children: "Learn more"
|
|
4137
|
+
}
|
|
4138
|
+
)
|
|
4139
|
+
] }),
|
|
4140
|
+
/* @__PURE__ */ jsxs25("div", { className: "flex shrink-0 gap-2", children: [
|
|
4141
|
+
/* @__PURE__ */ jsx29(
|
|
4142
|
+
"button",
|
|
4143
|
+
{
|
|
4144
|
+
type: "button",
|
|
4145
|
+
className: rejectClass,
|
|
4146
|
+
onClick: () => choose("rejected"),
|
|
4147
|
+
children: "Reject non-essential"
|
|
4148
|
+
}
|
|
4149
|
+
),
|
|
4150
|
+
/* @__PURE__ */ jsx29(
|
|
4151
|
+
"button",
|
|
4152
|
+
{
|
|
4153
|
+
type: "button",
|
|
4154
|
+
className: acceptClass,
|
|
4155
|
+
onClick: () => choose("accepted"),
|
|
4156
|
+
children: "Accept"
|
|
4157
|
+
}
|
|
4158
|
+
)
|
|
4159
|
+
] })
|
|
4160
|
+
] }) });
|
|
4161
|
+
}
|
|
4162
|
+
export {
|
|
4163
|
+
AddressAutocomplete,
|
|
4164
|
+
AnalyticsScripts,
|
|
4165
|
+
BespokeAvailabilityBadge,
|
|
4166
|
+
BespokeProgressTracker,
|
|
4167
|
+
CardSkeleton,
|
|
4168
|
+
CartItem,
|
|
4169
|
+
CartItemSkeleton,
|
|
4170
|
+
CartSidebar,
|
|
4171
|
+
CartTypeMismatchBanner,
|
|
4172
|
+
CookieConsentBanner,
|
|
4173
|
+
DeliverableReviewer,
|
|
4174
|
+
ImageEditorModal,
|
|
4175
|
+
ImageViewerModal,
|
|
4176
|
+
LucideReactIcon,
|
|
4177
|
+
MapPinDrop,
|
|
4178
|
+
MultiSelectPicker,
|
|
4179
|
+
OfflineBanner,
|
|
4180
|
+
OrderItemSkeleton,
|
|
4181
|
+
ProductCard,
|
|
4182
|
+
ProductCardSkeleton,
|
|
4183
|
+
ProductGrid,
|
|
4184
|
+
SearchableInput,
|
|
4185
|
+
SearchablePopover,
|
|
4186
|
+
SlotPicker,
|
|
4187
|
+
SmartImage,
|
|
4188
|
+
SmartMotionImage,
|
|
4189
|
+
TableSkeleton,
|
|
4190
|
+
TruncatedValue,
|
|
4191
|
+
UnsavedChangesDialog,
|
|
4192
|
+
useUnsavedChanges
|
|
4193
|
+
};
|
|
4194
|
+
//# sourceMappingURL=index.mjs.map
|