@kasuvia/sdk 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +42 -15
  2. package/dist/AddressAutocomplete-CjcqPElD.d.mts +46 -0
  3. package/dist/AddressAutocomplete-CvJHuRkf.d.ts +46 -0
  4. package/dist/components/index.d.mts +497 -0
  5. package/dist/components/index.d.ts +497 -0
  6. package/dist/components/index.js +4217 -0
  7. package/dist/components/index.js.map +1 -0
  8. package/dist/components/index.mjs +4194 -0
  9. package/dist/components/index.mjs.map +1 -0
  10. package/dist/config/server.d.mts +26 -0
  11. package/dist/config/server.d.ts +26 -0
  12. package/dist/config/server.js +38 -0
  13. package/dist/config/server.js.map +1 -0
  14. package/dist/config/server.mjs +13 -0
  15. package/dist/config/server.mjs.map +1 -0
  16. package/dist/hooks/index.d.mts +393 -160
  17. package/dist/hooks/index.d.ts +393 -160
  18. package/dist/hooks/index.js +2792 -2137
  19. package/dist/hooks/index.js.map +1 -1
  20. package/dist/hooks/index.mjs +2734 -2121
  21. package/dist/hooks/index.mjs.map +1 -1
  22. package/dist/index-CNW_LINy.d.mts +578 -0
  23. package/dist/index-CNW_LINy.d.ts +578 -0
  24. package/dist/{index-C1chUA3P.d.mts → index-F7N6UItS.d.mts} +1 -1
  25. package/dist/{index-C1chUA3P.d.ts → index-F7N6UItS.d.ts} +1 -1
  26. package/dist/index-NkI10J_E.d.mts +40 -0
  27. package/dist/index-NkI10J_E.d.ts +40 -0
  28. package/dist/index.d.mts +20437 -103
  29. package/dist/index.d.ts +20437 -103
  30. package/dist/index.js +2111 -1802
  31. package/dist/index.js.map +1 -1
  32. package/dist/index.mjs +2006 -1786
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/media.service-Cx7dQjzX.d.mts +1393 -0
  35. package/dist/media.service-mrBFJ2UQ.d.ts +1393 -0
  36. package/dist/services/index.d.mts +908 -2
  37. package/dist/services/index.d.ts +908 -2
  38. package/dist/services/index.js +1492 -1686
  39. package/dist/services/index.js.map +1 -1
  40. package/dist/services/index.mjs +1431 -1670
  41. package/dist/services/index.mjs.map +1 -1
  42. package/package.json +46 -6
  43. package/src/components/bespoke/BespokeAvailabilityBadge.tsx +32 -0
  44. package/src/components/bespoke/BespokeProgressTracker.tsx +58 -0
  45. package/src/components/bespoke/index.ts +3 -0
  46. package/src/components/booking/SlotPicker.tsx +97 -0
  47. package/src/components/booking/index.ts +1 -0
  48. package/src/components/cart/CartItem.tsx +81 -0
  49. package/src/components/cart/CartSidebar.tsx +115 -0
  50. package/src/components/cart/CartTypeMismatchBanner.tsx +55 -0
  51. package/src/components/cart/index.ts +3 -0
  52. package/src/components/index.ts +8 -0
  53. package/src/components/orders/DeliverableReviewer.tsx +139 -0
  54. package/src/components/orders/index.ts +1 -0
  55. package/src/components/product/ProductCard.tsx +350 -0
  56. package/src/components/product/ProductGrid.tsx +84 -0
  57. package/src/components/product/index.ts +2 -0
  58. package/src/components/shared/AddressAutocomplete.tsx +212 -0
  59. package/src/components/shared/AnalyticsScripts.tsx +56 -0
  60. package/src/components/shared/CookieConsentBanner.tsx +98 -0
  61. package/src/components/shared/ImageEditorModal.tsx +1066 -0
  62. package/src/components/shared/ImageViewerModal.tsx +598 -0
  63. package/src/components/shared/LucideReactIcon.tsx +52 -0
  64. package/src/components/shared/MapPinDrop.tsx +101 -0
  65. package/src/components/shared/MultiSelectPicker.tsx +193 -0
  66. package/src/components/shared/OfflineBanner.tsx +46 -0
  67. package/src/components/shared/SearchableInput.tsx +153 -0
  68. package/src/components/shared/SearchablePopover.tsx +166 -0
  69. package/src/components/shared/SmartImage.tsx +311 -0
  70. package/src/components/shared/TruncatedValue.tsx +27 -0
  71. package/src/components/shared/UnsavedChangesGuard.tsx +138 -0
  72. package/src/components/shared/index.ts +15 -0
  73. package/src/components/skeletons/CardSkeleton.tsx +26 -0
  74. package/src/components/skeletons/CartItemSkeleton.tsx +26 -0
  75. package/src/components/skeletons/OrderItemSkeleton.tsx +26 -0
  76. package/src/components/skeletons/ProductCardSkeleton.tsx +26 -0
  77. package/src/components/skeletons/TableSkeleton.tsx +42 -0
  78. package/src/components/skeletons/index.ts +5 -0
  79. package/src/config/index.ts +22 -4
  80. package/src/config/server.ts +45 -0
  81. package/src/context/sdk-context.tsx +5 -2
  82. package/src/generated/auth-openapi.ts +2935 -0
  83. package/src/generated/cart-openapi.ts +652 -0
  84. package/src/generated/cms-openapi.ts +1668 -0
  85. package/src/generated/crm-openapi.ts +986 -0
  86. package/src/generated/graphql.ts +126 -0
  87. package/src/generated/index.ts +11 -0
  88. package/src/generated/media-openapi.ts +613 -0
  89. package/src/generated/notification-openapi.ts +862 -0
  90. package/src/generated/payment-openapi.ts +2713 -0
  91. package/src/generated/store-openapi.ts +9510 -0
  92. package/src/graphql/operations/collections.graphql +32 -0
  93. package/src/graphql/operations/products.graphql +197 -0
  94. package/src/graphql/operations/wishlist.graphql +25 -0
  95. package/src/hooks/index.ts +9 -2
  96. package/src/hooks/use-auth.ts +163 -69
  97. package/src/hooks/use-bespoke-availability.ts +39 -22
  98. package/src/hooks/use-business-config.ts +51 -0
  99. package/src/hooks/use-cart.ts +310 -238
  100. package/src/hooks/use-cms.ts +107 -127
  101. package/src/hooks/use-collections.ts +35 -55
  102. package/src/hooks/use-consultations.ts +9 -136
  103. package/src/hooks/use-countries.ts +19 -28
  104. package/src/hooks/use-currencies.ts +8 -20
  105. package/src/hooks/use-customer-address.ts +64 -0
  106. package/src/hooks/use-feedback.ts +34 -0
  107. package/src/hooks/use-languages.ts +26 -39
  108. package/src/hooks/use-media-upload.ts +23 -0
  109. package/src/hooks/use-notification-stream.ts +88 -0
  110. package/src/hooks/use-notifications.ts +163 -0
  111. package/src/hooks/use-order-stream.ts +29 -29
  112. package/src/hooks/use-orders.ts +91 -73
  113. package/src/hooks/use-payment.ts +116 -84
  114. package/src/hooks/use-products.ts +65 -269
  115. package/src/hooks/use-push-notifications.ts +122 -0
  116. package/src/hooks/use-reviews.ts +175 -0
  117. package/src/hooks/use-service-booking.ts +109 -132
  118. package/src/hooks/use-states.ts +16 -30
  119. package/src/hooks/use-wishlist.ts +44 -69
  120. package/src/index.ts +15 -11
  121. package/src/services/auth.service.ts +422 -132
  122. package/src/services/bespoke.service.ts +69 -10
  123. package/src/services/cart.service.ts +155 -186
  124. package/src/services/cms.service.ts +41 -50
  125. package/src/services/collection.service.ts +25 -63
  126. package/src/services/config.service.ts +10 -21
  127. package/src/services/consultation.service.ts +36 -93
  128. package/src/services/feedback.service.ts +76 -0
  129. package/src/services/index.ts +7 -1
  130. package/src/services/legal.service.ts +62 -0
  131. package/src/services/logistics.service.ts +235 -53
  132. package/src/services/media.service.ts +115 -0
  133. package/src/services/notifications.service.ts +204 -20
  134. package/src/services/order.service.ts +498 -127
  135. package/src/services/payment.service.ts +149 -100
  136. package/src/services/product.service.ts +157 -814
  137. package/src/services/review.service.ts +211 -0
  138. package/src/services/service-booking.service.ts +198 -99
  139. package/src/services/wishlist.service.ts +27 -74
  140. package/src/types/auth/index.ts +161 -18
  141. package/src/types/business/index.ts +9 -1
  142. package/src/types/cms/index.ts +91 -6
  143. package/src/types/cms/legal.ts +18 -0
  144. package/src/types/consultations/index.ts +60 -68
  145. package/src/types/index.ts +5 -3
  146. package/src/types/media/index.ts +35 -3
  147. package/src/types/payments/index.ts +80 -16
  148. package/src/types/reviews/index.ts +116 -0
  149. package/src/types/services/index.ts +113 -38
  150. package/src/types/store/checkout.ts +53 -0
  151. package/src/types/store/index.ts +273 -52
  152. package/src/types/store/invoice.ts +31 -0
  153. package/src/types/store/shipping.ts +84 -3
  154. package/src/types/ui/index.ts +1 -1
  155. package/src/utils/checkout.ts +19 -3
  156. package/src/utils/cookieConsent.ts +34 -0
  157. package/src/utils/format.ts +408 -0
  158. package/src/utils/geolocation.ts +134 -0
  159. package/src/utils/graphql-url.ts +47 -0
  160. package/src/utils/guest-session.ts +59 -0
  161. package/src/utils/http-client.ts +147 -0
  162. package/src/utils/index.ts +11 -4
  163. package/src/utils/status-colors.ts +79 -0
  164. package/src/utils/token-refresh.ts +115 -0
  165. package/src/utils/validators.ts +13 -0
  166. package/src/utils/youtube.ts +18 -0
  167. package/dist/index-C5YO9gTl.d.mts +0 -507
  168. package/dist/index-C5YO9gTl.d.ts +0 -507
  169. package/dist/index-CefG7_xq.d.mts +0 -793
  170. package/dist/index-DeHCf3zI.d.ts +0 -793
  171. package/src/graphql/collections.graphql.ts +0 -48
  172. package/src/graphql/index.ts +0 -7
  173. package/src/graphql/products.graphql.ts +0 -860
  174. package/src/graphql/wishlist.graphql.ts +0 -34
  175. package/src/hooks/use-consultation-stream.ts +0 -142
  176. package/src/types/bespoke/index.ts +0 -39
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export * from './ProductCard'
2
+ export * from './ProductGrid'