@kasuvia/sdk 0.0.7 → 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 +239 -54
- 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 +95 -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-D9ojrziq.d.mts +0 -786
- package/dist/index-ac_Jx9NI.d.mts +0 -492
- package/dist/index-ac_Jx9NI.d.ts +0 -492
- package/dist/index-hgK4j47a.d.ts +0 -786
- 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,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React, { useState } from 'react'
|
|
3
|
+
import { useApproveBespokeDeliverable, useRequestBespokeRevision } from '../../hooks/use-bespoke-availability'
|
|
4
|
+
import type { Order } from '../../types/store'
|
|
5
|
+
import { logger } from '../../utils/logger'
|
|
6
|
+
|
|
7
|
+
export interface DeliverableReviewerProps {
|
|
8
|
+
order: Order
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* REAL BUG FIXED (2026-09-20): rebuilt entirely — this used to fetch a
|
|
14
|
+
* "/service/projects/{order_id}/deliverables/" list that doesn't exist
|
|
15
|
+
* anywhere in the backend. The real bespoke deliverable model is a SINGLE
|
|
16
|
+
* proofing record per order (BespokeFulfillmentResponse, embedded as
|
|
17
|
+
* `order.bespoke_fulfillment` and flattened onto the SDK's Order type as
|
|
18
|
+
* `bespoke_*` fields by normaliseStorefrontOrder), not a fetchable list —
|
|
19
|
+
* there is no separate GET endpoint for it at all. Approve/request-revision
|
|
20
|
+
* now call the real endpoints (POST /orders/{id}/bespoke/approve,
|
|
21
|
+
* .../request-revision) instead of a fictional deliverable-id-scoped route.
|
|
22
|
+
*/
|
|
23
|
+
export function DeliverableReviewer({ order, className = "space-y-4 p-4 border rounded-xl bg-muted/50" }: DeliverableReviewerProps) {
|
|
24
|
+
const [revisionNotes, setRevisionNotes] = useState('')
|
|
25
|
+
const [showRevisionForm, setShowRevisionForm] = useState(false)
|
|
26
|
+
|
|
27
|
+
const approveMutation = useApproveBespokeDeliverable()
|
|
28
|
+
const revisionMutation = useRequestBespokeRevision()
|
|
29
|
+
|
|
30
|
+
if (!order.bespoke_deliverable_submitted_at) {
|
|
31
|
+
return (
|
|
32
|
+
<div className={className}>
|
|
33
|
+
<h4 className="text-xs font-bold uppercase tracking-wider text-foreground">Deliverable</h4>
|
|
34
|
+
<p className="text-xs text-muted-foreground py-4 text-center">
|
|
35
|
+
The seller hasn't submitted a deliverable for review yet.
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const awaitingApproval = order.bespoke_status === 'awaiting_approval'
|
|
42
|
+
|
|
43
|
+
const handleApprove = async () => {
|
|
44
|
+
try {
|
|
45
|
+
await approveMutation.mutateAsync(order.id)
|
|
46
|
+
} catch (error) {
|
|
47
|
+
logger.error(error)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const handleRequestRevision = async () => {
|
|
52
|
+
if (!revisionNotes.trim() || revisionNotes.trim().length < 10) return
|
|
53
|
+
try {
|
|
54
|
+
await revisionMutation.mutateAsync({ orderId: order.id, notes: revisionNotes.trim() })
|
|
55
|
+
setShowRevisionForm(false)
|
|
56
|
+
setRevisionNotes('')
|
|
57
|
+
} catch (error) {
|
|
58
|
+
logger.error(error)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className={className}>
|
|
64
|
+
<div className="flex justify-between items-center">
|
|
65
|
+
<h4 className="text-xs font-bold uppercase tracking-wider text-foreground">Deliverable</h4>
|
|
66
|
+
{order.bespoke_status && (
|
|
67
|
+
<span className={`px-2 py-0.5 rounded text-[10px] font-semibold uppercase ${
|
|
68
|
+
order.bespoke_status === 'confirmed' ? 'bg-primary/10 text-primary' :
|
|
69
|
+
order.bespoke_status === 'awaiting_approval' ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400' :
|
|
70
|
+
'bg-amber-500/10 text-amber-600 dark:text-amber-400'
|
|
71
|
+
}`}>
|
|
72
|
+
{order.bespoke_status.replace('_', ' ')}
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div className="p-3 bg-card border rounded-lg space-y-2">
|
|
78
|
+
{order.bespoke_deliverable_url && (
|
|
79
|
+
<a
|
|
80
|
+
href={order.bespoke_deliverable_url}
|
|
81
|
+
target="_blank"
|
|
82
|
+
rel="noreferrer"
|
|
83
|
+
className="text-xs text-primary hover:underline flex items-center gap-1"
|
|
84
|
+
>
|
|
85
|
+
📎 View Deliverable
|
|
86
|
+
</a>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
{order.bespoke_deliverable_notes && (
|
|
90
|
+
<p className="text-xs text-muted-foreground">{order.bespoke_deliverable_notes}</p>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{(order.bespoke_revision_count ?? 0) > 0 && (
|
|
94
|
+
<p className="text-[10px] text-muted-foreground">Revisions requested: {order.bespoke_revision_count}</p>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{awaitingApproval && (
|
|
98
|
+
<div className="flex items-center gap-2 pt-2">
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={handleApprove}
|
|
102
|
+
disabled={approveMutation.isPending}
|
|
103
|
+
className="px-3 py-1.5 bg-primary text-primary-foreground text-xs font-medium rounded hover:bg-primary/90 disabled:opacity-50"
|
|
104
|
+
>
|
|
105
|
+
{approveMutation.isPending ? 'Approving…' : 'Approve Deliverable'}
|
|
106
|
+
</button>
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={() => setShowRevisionForm(!showRevisionForm)}
|
|
110
|
+
className="px-3 py-1.5 border text-xs font-medium rounded hover:bg-muted"
|
|
111
|
+
>
|
|
112
|
+
Request Revision
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{showRevisionForm && (
|
|
118
|
+
<div className="pt-2 space-y-2">
|
|
119
|
+
<textarea
|
|
120
|
+
rows={2}
|
|
121
|
+
value={revisionNotes}
|
|
122
|
+
onChange={(e) => setRevisionNotes(e.target.value)}
|
|
123
|
+
placeholder="Explain what needs to change (at least 10 characters)..."
|
|
124
|
+
className="w-full p-2 border rounded text-xs bg-transparent"
|
|
125
|
+
/>
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={handleRequestRevision}
|
|
129
|
+
disabled={revisionMutation.isPending || revisionNotes.trim().length < 10}
|
|
130
|
+
className="px-3 py-1 bg-amber-600 text-white text-xs font-medium rounded hover:bg-amber-700 disabled:opacity-50"
|
|
131
|
+
>
|
|
132
|
+
{revisionMutation.isPending ? 'Submitting…' : 'Submit Revision Request'}
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './DeliverableReviewer'
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React, { useState, useRef, useId } from 'react'
|
|
3
|
+
import type { Product } from '../../types/store'
|
|
4
|
+
import { useCart } from '../../hooks/use-cart'
|
|
5
|
+
import { formatPrice } from '../../utils/format'
|
|
6
|
+
import { SmartImage } from '../shared/SmartImage'
|
|
7
|
+
|
|
8
|
+
export interface ProductCardProps {
|
|
9
|
+
product: Product
|
|
10
|
+
onSelectProduct?: (product: Product) => void
|
|
11
|
+
onWishlistToggle?: (product: Product, e: React.MouseEvent) => void
|
|
12
|
+
isWishlistSaved?: boolean
|
|
13
|
+
showWishlistButton?: boolean
|
|
14
|
+
priority?: boolean
|
|
15
|
+
className?: string
|
|
16
|
+
/** Sizing/aspect-ratio classes for the image's container (e.g. "aspect-square").
|
|
17
|
+
* Kept separate from `imageClassName` because SmartImage renders the image
|
|
18
|
+
* in `fill` mode — sizing has to live on the container, not the <img>. */
|
|
19
|
+
imageContainerClassName?: string
|
|
20
|
+
imageClassName?: string
|
|
21
|
+
titleClassName?: string
|
|
22
|
+
priceClassName?: string
|
|
23
|
+
buttonClassName?: string
|
|
24
|
+
enableImageCarousel?: boolean
|
|
25
|
+
showCarouselDots?: boolean
|
|
26
|
+
showCarouselArrows?: boolean
|
|
27
|
+
activeDotClassName?: string
|
|
28
|
+
dotClassName?: string
|
|
29
|
+
formatPriceFn?: (price: number) => string
|
|
30
|
+
/** Rendered top-left over the image. Falls back to a plain product-type
|
|
31
|
+
* badge when omitted — pass your own (e.g. New/Popular/On Sale badges) to
|
|
32
|
+
* replace it entirely. */
|
|
33
|
+
badgeSlot?: React.ReactNode
|
|
34
|
+
/**
|
|
35
|
+
* Built-in star-rating row (product.average_rating / rating_count — real
|
|
36
|
+
* platform fields, synced from kasuvia-crm's review pipeline). On by
|
|
37
|
+
* default when a product actually has a rating; a storefront with no use
|
|
38
|
+
* for it, or that wants to build its own, can pass `showRating={false}`.
|
|
39
|
+
*/
|
|
40
|
+
showRating?: boolean
|
|
41
|
+
ratingClassName?: string
|
|
42
|
+
/**
|
|
43
|
+
* Generic composable slot rendered between the price row and the CTA
|
|
44
|
+
* button — for "and many more" (stock urgency, shipping badges, a custom
|
|
45
|
+
* promo line, anything else a specific storefront wants) without forking
|
|
46
|
+
* this component. Renders nothing by default.
|
|
47
|
+
*/
|
|
48
|
+
footerSlot?: React.ReactNode
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function StarIcon({ half, gradientId }: { half?: boolean; gradientId: string }) {
|
|
52
|
+
return (
|
|
53
|
+
<svg viewBox="0 0 20 20" className="w-3.5 h-3.5" aria-hidden="true">
|
|
54
|
+
{half ? (
|
|
55
|
+
<>
|
|
56
|
+
<defs>
|
|
57
|
+
<linearGradient id={gradientId}>
|
|
58
|
+
<stop offset="50%" stopColor="currentColor" />
|
|
59
|
+
<stop offset="50%" stopColor="transparent" stopOpacity="1" />
|
|
60
|
+
</linearGradient>
|
|
61
|
+
</defs>
|
|
62
|
+
<path
|
|
63
|
+
fill={`url(#${gradientId})`}
|
|
64
|
+
stroke="currentColor"
|
|
65
|
+
strokeWidth="1"
|
|
66
|
+
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"
|
|
67
|
+
/>
|
|
68
|
+
</>
|
|
69
|
+
) : (
|
|
70
|
+
<path
|
|
71
|
+
fill="currentColor"
|
|
72
|
+
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"
|
|
73
|
+
/>
|
|
74
|
+
)}
|
|
75
|
+
</svg>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Renders 5 stars for a 0-5 `average_rating`, including a half star when
|
|
80
|
+
* the fractional part is >= 0.25 and < 0.75 (standard rounding convention
|
|
81
|
+
* for star displays — avoids a misleading full/empty jump at the midpoint).
|
|
82
|
+
* `idPrefix` (from the caller's own `useId()`) keeps the half-star gradient's
|
|
83
|
+
* `<linearGradient id>` unique per card instance — several cards with a
|
|
84
|
+
* half star can render on the same page (a listing grid), and duplicate DOM
|
|
85
|
+
* ids are invalid HTML even though browsers happen to render them right. */
|
|
86
|
+
function StarRow({ rating, idPrefix }: { rating: number; idPrefix: string }) {
|
|
87
|
+
const stars = [];
|
|
88
|
+
for (let i = 1; i <= 5; i++) {
|
|
89
|
+
const diff = rating - (i - 1);
|
|
90
|
+
if (diff >= 0.75) stars.push(<StarIcon key={i} gradientId={`${idPrefix}-star-${i}`} />);
|
|
91
|
+
else if (diff >= 0.25) stars.push(<StarIcon key={i} half gradientId={`${idPrefix}-star-${i}`} />);
|
|
92
|
+
else stars.push(<svg key={i} viewBox="0 0 20 20" className="w-3.5 h-3.5 text-muted-foreground/30" aria-hidden="true"><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" /></svg>);
|
|
93
|
+
}
|
|
94
|
+
return <>{stars}</>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function ProductCard({
|
|
98
|
+
product,
|
|
99
|
+
onSelectProduct,
|
|
100
|
+
onWishlistToggle,
|
|
101
|
+
isWishlistSaved = false,
|
|
102
|
+
showWishlistButton = false,
|
|
103
|
+
priority = false,
|
|
104
|
+
className = "flex flex-col h-full rounded-xl border bg-card overflow-hidden shadow-sm hover:shadow-md transition-shadow group",
|
|
105
|
+
imageContainerClassName = "w-full aspect-square",
|
|
106
|
+
imageClassName = "transition-transform duration-500 group-hover:scale-105",
|
|
107
|
+
titleClassName = "text-base font-semibold text-foreground line-clamp-1 cursor-pointer hover:underline",
|
|
108
|
+
priceClassName = "text-lg font-bold text-foreground",
|
|
109
|
+
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",
|
|
110
|
+
enableImageCarousel = true,
|
|
111
|
+
showCarouselDots = true,
|
|
112
|
+
showCarouselArrows = true,
|
|
113
|
+
activeDotClassName = "bg-primary w-4 h-1.5 rounded-full transition-all",
|
|
114
|
+
dotClassName = "bg-white/70 hover:bg-white w-1.5 h-1.5 rounded-full transition-all",
|
|
115
|
+
formatPriceFn,
|
|
116
|
+
badgeSlot,
|
|
117
|
+
showRating = true,
|
|
118
|
+
ratingClassName = "flex items-center gap-1 text-amber-500",
|
|
119
|
+
footerSlot,
|
|
120
|
+
}: ProductCardProps) {
|
|
121
|
+
const { addItem } = useCart()
|
|
122
|
+
const ratingIdPrefix = useId()
|
|
123
|
+
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
|
124
|
+
const touchStartX = useRef<number | null>(null)
|
|
125
|
+
const touchEndX = useRef<number | null>(null)
|
|
126
|
+
|
|
127
|
+
const productType = product.product_type
|
|
128
|
+
const serviceType = product.service_type || 'appointment'
|
|
129
|
+
const isConsultationRequired = product.service_requires_consultation
|
|
130
|
+
const images = product.images && product.images.length > 0 ? product.images : []
|
|
131
|
+
// Swipe-to-browse (touch) and hover arrows/dots (pointer) both gate on
|
|
132
|
+
// this — a single-image product never renders carousel affordances at all.
|
|
133
|
+
const hasMultipleImages = images.length > 1 && enableImageCarousel
|
|
134
|
+
const hasRating = showRating && product.average_rating != null && product.rating_count > 0
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* REAL BUG FIXED (2026-09-21), live-reproduced: for `service_type ===
|
|
138
|
+
* 'recurring'` this used to fall through to `addItem(product)` —
|
|
139
|
+
* silently trying to cart a SERVICE product. The real backend
|
|
140
|
+
* (kasuvia-cart-management) hard-rejects every service product from the
|
|
141
|
+
* cart outright (ErrServiceNotCartable), no sub-type exception — so this
|
|
142
|
+
* either failed with no user-facing feedback (fire-and-forget mutation,
|
|
143
|
+
* no error handling here) or, worse, looked like it might have worked.
|
|
144
|
+
* Separately, `onBookAppointment`/`onRequestQuote`/`onRequestBespoke`
|
|
145
|
+
* were dead props — no consumer in kasuvia-storefront ever passed them
|
|
146
|
+
* (confirmed by reading its actual card wrapper), so "Book Appointment"/
|
|
147
|
+
* "Get a Quote"/bespoke clicks on every product LISTING card (home page,
|
|
148
|
+
* catalog, related products, wishlist) were silent no-ops even for the
|
|
149
|
+
* sub-types that didn't try to cart. Every non-cartable type now routes
|
|
150
|
+
* to the product detail page instead, which already has the full, real
|
|
151
|
+
* flow for each one (BookingSheet, /bespoke/[slug], or an honest
|
|
152
|
+
* disabled state) — a listing card was never the right place to
|
|
153
|
+
* complete a booking/commission/quote flow inline anyway.
|
|
154
|
+
*/
|
|
155
|
+
const handleCTA = () => {
|
|
156
|
+
if (productType === 'physical' || productType === 'digital') {
|
|
157
|
+
addItem(product)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
onSelectProduct?.(product)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const getCtaText = () => {
|
|
164
|
+
if (productType === 'physical' || productType === 'digital') return 'Add to Cart'
|
|
165
|
+
if (productType === 'bespoke') return 'Start Commission'
|
|
166
|
+
if (productType === 'service') {
|
|
167
|
+
if (isConsultationRequired || serviceType === 'project') return 'Get a Quote'
|
|
168
|
+
if (serviceType === 'appointment') return 'Book Appointment'
|
|
169
|
+
return 'View Details'
|
|
170
|
+
}
|
|
171
|
+
return 'View Details'
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const handleNextImage = (e?: React.MouseEvent) => {
|
|
175
|
+
e?.stopPropagation()
|
|
176
|
+
if (!hasMultipleImages) return
|
|
177
|
+
setCurrentImageIndex((prev) => (prev + 1) % images.length)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const handlePrevImage = (e?: React.MouseEvent) => {
|
|
181
|
+
e?.stopPropagation()
|
|
182
|
+
if (!hasMultipleImages) return
|
|
183
|
+
setCurrentImageIndex((prev) => (prev - 1 + images.length) % images.length)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const handleTouchStart = (e: React.TouchEvent) => {
|
|
187
|
+
if (!hasMultipleImages) return
|
|
188
|
+
touchStartX.current = e.touches[0].clientX
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const handleTouchMove = (e: React.TouchEvent) => {
|
|
192
|
+
if (!hasMultipleImages) return
|
|
193
|
+
touchEndX.current = e.touches[0].clientX
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const handleTouchEnd = () => {
|
|
197
|
+
if (!hasMultipleImages || touchStartX.current === null || touchEndX.current === null) return
|
|
198
|
+
const distance = touchStartX.current - touchEndX.current
|
|
199
|
+
const isSwipeLeft = distance > 40
|
|
200
|
+
const isSwipeRight = distance < -40
|
|
201
|
+
|
|
202
|
+
if (isSwipeLeft) handleNextImage()
|
|
203
|
+
else if (isSwipeRight) handlePrevImage()
|
|
204
|
+
|
|
205
|
+
touchStartX.current = null
|
|
206
|
+
touchEndX.current = null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div className={className}>
|
|
211
|
+
<div
|
|
212
|
+
className="relative overflow-hidden cursor-pointer group/image"
|
|
213
|
+
onClick={() => onSelectProduct?.(product)}
|
|
214
|
+
onTouchStart={handleTouchStart}
|
|
215
|
+
onTouchMove={handleTouchMove}
|
|
216
|
+
onTouchEnd={handleTouchEnd}
|
|
217
|
+
>
|
|
218
|
+
{images.length > 0 ? (
|
|
219
|
+
<SmartImage
|
|
220
|
+
src={images[currentImageIndex]}
|
|
221
|
+
alt={`${product.name} - image ${currentImageIndex + 1}`}
|
|
222
|
+
className={imageContainerClassName}
|
|
223
|
+
imageClassName={imageClassName}
|
|
224
|
+
priority={priority}
|
|
225
|
+
sizes="(min-width: 1024px) 25vw, 50vw"
|
|
226
|
+
/>
|
|
227
|
+
) : (
|
|
228
|
+
<div className={`${imageContainerClassName} flex items-center justify-center text-muted-foreground text-sm`}>
|
|
229
|
+
No Image
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
{badgeSlot ? (
|
|
234
|
+
<div className="absolute top-2 left-2 z-10 flex flex-col gap-1">
|
|
235
|
+
{badgeSlot}
|
|
236
|
+
</div>
|
|
237
|
+
) : (
|
|
238
|
+
<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">
|
|
239
|
+
{productType}
|
|
240
|
+
</span>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{showWishlistButton && (
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
aria-label={isWishlistSaved ? "Remove from wishlist" : "Save to wishlist"}
|
|
247
|
+
onClick={(e) => {
|
|
248
|
+
e.stopPropagation()
|
|
249
|
+
onWishlistToggle?.(product, e)
|
|
250
|
+
}}
|
|
251
|
+
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"
|
|
252
|
+
>
|
|
253
|
+
<svg
|
|
254
|
+
className={`w-4 h-4 ${isWishlistSaved ? "fill-red-500 text-red-500" : "text-muted-foreground"}`}
|
|
255
|
+
viewBox="0 0 24 24"
|
|
256
|
+
stroke="currentColor"
|
|
257
|
+
strokeWidth="2"
|
|
258
|
+
>
|
|
259
|
+
<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" />
|
|
260
|
+
</svg>
|
|
261
|
+
</button>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{/* Carousel controls & dots — swipe-to-browse is handled by the
|
|
265
|
+
touch handlers on this wrapper above regardless of these props;
|
|
266
|
+
these only control the visible pointer-device affordances. */}
|
|
267
|
+
{hasMultipleImages && (
|
|
268
|
+
<>
|
|
269
|
+
{showCarouselArrows && (
|
|
270
|
+
<>
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
aria-label="Previous image"
|
|
274
|
+
onClick={handlePrevImage}
|
|
275
|
+
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"
|
|
276
|
+
>
|
|
277
|
+
‹
|
|
278
|
+
</button>
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
aria-label="Next image"
|
|
282
|
+
onClick={handleNextImage}
|
|
283
|
+
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"
|
|
284
|
+
>
|
|
285
|
+
›
|
|
286
|
+
</button>
|
|
287
|
+
</>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{showCarouselDots && (
|
|
291
|
+
<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">
|
|
292
|
+
{images.map((_, idx) => (
|
|
293
|
+
<button
|
|
294
|
+
key={idx}
|
|
295
|
+
type="button"
|
|
296
|
+
aria-label={`Go to image ${idx + 1}`}
|
|
297
|
+
onClick={(e) => {
|
|
298
|
+
e.stopPropagation()
|
|
299
|
+
setCurrentImageIndex(idx)
|
|
300
|
+
}}
|
|
301
|
+
className={idx === currentImageIndex ? activeDotClassName : dotClassName}
|
|
302
|
+
/>
|
|
303
|
+
))}
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
306
|
+
</>
|
|
307
|
+
)}
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
<div className="p-4 flex flex-col flex-1 space-y-3">
|
|
311
|
+
<h3 className={titleClassName} onClick={() => onSelectProduct?.(product)}>
|
|
312
|
+
{product.name}
|
|
313
|
+
</h3>
|
|
314
|
+
|
|
315
|
+
{product.description && (
|
|
316
|
+
<p className="text-xs text-muted-foreground line-clamp-2">
|
|
317
|
+
{product.description}
|
|
318
|
+
</p>
|
|
319
|
+
)}
|
|
320
|
+
|
|
321
|
+
{hasRating && (
|
|
322
|
+
<div className={ratingClassName}>
|
|
323
|
+
<StarRow rating={product.average_rating!} idPrefix={ratingIdPrefix} />
|
|
324
|
+
<span className="text-xs text-muted-foreground font-medium">
|
|
325
|
+
{product.average_rating!.toFixed(1)} ({product.rating_count})
|
|
326
|
+
</span>
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
|
|
330
|
+
<div className="flex items-center justify-between pt-2 mt-auto">
|
|
331
|
+
<div className={priceClassName}>
|
|
332
|
+
{formatPriceFn
|
|
333
|
+
? formatPriceFn(product.sale_price ?? product.base_price)
|
|
334
|
+
: formatPrice(product.sale_price ?? product.base_price, product.currency)}
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
{footerSlot}
|
|
339
|
+
|
|
340
|
+
<button
|
|
341
|
+
type="button"
|
|
342
|
+
onClick={handleCTA}
|
|
343
|
+
className={buttonClassName}
|
|
344
|
+
>
|
|
345
|
+
{getCtaText()}
|
|
346
|
+
</button>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
)
|
|
350
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { useQuery } from '@tanstack/react-query'
|
|
4
|
+
import type { Product, ListParams } from '../../types/store'
|
|
5
|
+
import { getStorefrontProducts } from '../../services/product.service'
|
|
6
|
+
import { useSdkContext, resolveHookArgs } from '../../context/sdk-context'
|
|
7
|
+
import { getCacheTier } from '../../utils/cache'
|
|
8
|
+
import { ProductCard } from './ProductCard'
|
|
9
|
+
import { CardSkeleton } from '../skeletons/CardSkeleton'
|
|
10
|
+
|
|
11
|
+
export interface ProductGridProps {
|
|
12
|
+
params?: ListParams
|
|
13
|
+
onSelectProduct?: (product: Product) => void
|
|
14
|
+
onWishlistToggle?: (product: Product, e: React.MouseEvent) => void
|
|
15
|
+
isWishlistSavedFn?: (product: Product) => boolean
|
|
16
|
+
showWishlistButton?: boolean
|
|
17
|
+
formatPriceFn?: (price: number) => string
|
|
18
|
+
className?: string
|
|
19
|
+
cardClassName?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ProductGrid({
|
|
23
|
+
params,
|
|
24
|
+
onSelectProduct,
|
|
25
|
+
onWishlistToggle,
|
|
26
|
+
isWishlistSavedFn,
|
|
27
|
+
showWishlistButton = false,
|
|
28
|
+
formatPriceFn,
|
|
29
|
+
className = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6",
|
|
30
|
+
cardClassName,
|
|
31
|
+
}: ProductGridProps) {
|
|
32
|
+
// REAL BUG FIXED (2026-09-21): this called `useStorefrontProducts`, which
|
|
33
|
+
// fetched from a Next.js API route (`/api/products`) deleted earlier this
|
|
34
|
+
// session as confirmed orphaned — this component was missed by that grep
|
|
35
|
+
// since it has no consumer in kasuvia-storefront yet. Fixed to call the
|
|
36
|
+
// real GraphQL service function directly, resolving config from
|
|
37
|
+
// SdkContext the same way every other SDK hook does.
|
|
38
|
+
const ctx = useSdkContext()
|
|
39
|
+
const { config } = resolveHookArgs(undefined, undefined, ctx)
|
|
40
|
+
const { data, isLoading, error } = useQuery({
|
|
41
|
+
queryKey: ['products', 'grid', params],
|
|
42
|
+
queryFn: () => getStorefrontProducts(config, params),
|
|
43
|
+
...getCacheTier('medium'),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
if (isLoading) {
|
|
47
|
+
return <CardSkeleton count={8} containerClassName={className} />
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (error) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="text-center py-12 text-destructive">
|
|
53
|
+
Failed to load products. Please try again.
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const products: Product[] = data?.items || []
|
|
59
|
+
|
|
60
|
+
if (products.length === 0) {
|
|
61
|
+
return (
|
|
62
|
+
<div className="text-center py-12 text-muted-foreground">
|
|
63
|
+
No products found.
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className={className}>
|
|
70
|
+
{products.map((product) => (
|
|
71
|
+
<ProductCard
|
|
72
|
+
key={product.id}
|
|
73
|
+
product={product}
|
|
74
|
+
onSelectProduct={onSelectProduct}
|
|
75
|
+
onWishlistToggle={onWishlistToggle}
|
|
76
|
+
isWishlistSaved={isWishlistSavedFn ? isWishlistSavedFn(product) : false}
|
|
77
|
+
showWishlistButton={showWishlistButton}
|
|
78
|
+
formatPriceFn={formatPriceFn}
|
|
79
|
+
className={cardClassName}
|
|
80
|
+
/>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|