@shopify/shop-minis-react 0.0.3 → 0.0.4

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.
@@ -0,0 +1,427 @@
1
+ import * as React from 'react'
2
+
3
+ import {cva, type VariantProps} from 'class-variance-authority'
4
+ import {Slot as SlotPrimitive} from 'radix-ui'
5
+
6
+ import {useShopNavigation} from '../../hooks/navigation/useShopNavigation'
7
+ import {useSavedProductsActions} from '../../hooks/user/useSavedProductsActions'
8
+ import {formatMoney} from '../../lib/formatMoney'
9
+ import {cn} from '../../lib/utils'
10
+ import {
11
+ type Product,
12
+ type ProductVariant,
13
+ } from '../../types/minisSDK.generated.d'
14
+ import {Badge} from '../ui/badge'
15
+ import {Button} from '../ui/button'
16
+ import {Icon, type LucideIconName} from '../ui/icon'
17
+ import {Touchable} from '../ui/touchable'
18
+
19
+ const productCardVariants = cva(
20
+ 'relative overflow-hidden rounded-xl border border-gray-200',
21
+ {
22
+ variants: {
23
+ variant: {
24
+ default: '',
25
+ priceOverlay: '',
26
+ compact: '',
27
+ },
28
+ touchable: {
29
+ true: 'cursor-pointer',
30
+ false: '',
31
+ },
32
+ },
33
+ defaultVariants: {
34
+ variant: 'default',
35
+ touchable: true,
36
+ },
37
+ }
38
+ )
39
+
40
+ // Primitive components (building blocks)
41
+ export interface ProductCardRootProps
42
+ extends React.ComponentProps<'div'>,
43
+ VariantProps<typeof productCardVariants> {
44
+ variant?: 'default' | 'priceOverlay' | 'compact'
45
+ touchable?: boolean
46
+ asChild?: boolean
47
+ onPress?: () => void
48
+ }
49
+
50
+ function ProductCardRoot({
51
+ className,
52
+ variant,
53
+ touchable = true,
54
+ asChild = false,
55
+ onPress,
56
+ ...props
57
+ }: ProductCardRootProps) {
58
+ const Comp = asChild ? SlotPrimitive.Slot : 'div'
59
+
60
+ const content = (
61
+ <Comp
62
+ className={cn(
63
+ productCardVariants({variant, touchable}),
64
+ 'border-0',
65
+ className
66
+ )}
67
+ {...props}
68
+ />
69
+ )
70
+
71
+ if (touchable && onPress) {
72
+ return (
73
+ <Touchable onPress={onPress} asChild>
74
+ {content}
75
+ </Touchable>
76
+ )
77
+ }
78
+
79
+ return content
80
+ }
81
+
82
+ function ProductCardImageContainer({
83
+ className,
84
+ variant = 'default',
85
+ ...props
86
+ }: React.ComponentProps<'div'> & {
87
+ variant?: 'default' | 'priceOverlay' | 'compact'
88
+ }) {
89
+ return (
90
+ <div
91
+ data-slot="product-card-image-container"
92
+ className={cn(
93
+ 'relative overflow-hidden rounded-xl border border-gray-200',
94
+ 'w-full aspect-square',
95
+ variant === 'compact' ? 'min-h-[104px]' : 'min-h-[134px]',
96
+ className
97
+ )}
98
+ {...props}
99
+ />
100
+ )
101
+ }
102
+
103
+ function ProductCardImage({
104
+ className,
105
+ src,
106
+ alt,
107
+ ...props
108
+ }: React.ComponentProps<'img'> & {
109
+ src?: string
110
+ alt?: string
111
+ }) {
112
+ return (
113
+ <div className="w-full h-full bg-gray-100 flex items-center justify-center">
114
+ {src ? (
115
+ <img
116
+ data-slot="product-card-image"
117
+ src={src}
118
+ alt={alt}
119
+ className={cn('w-full h-full object-cover', className)}
120
+ {...props}
121
+ />
122
+ ) : (
123
+ <div className="text-gray-400 text-sm">No Image</div>
124
+ )}
125
+ </div>
126
+ )
127
+ }
128
+
129
+ function ProductCardBadge({
130
+ className,
131
+ position = 'bottom-left',
132
+ children,
133
+ ...props
134
+ }: React.ComponentProps<typeof Badge> & {
135
+ position?: 'top-left' | 'bottom-left'
136
+ }) {
137
+ return (
138
+ <div
139
+ className={cn(
140
+ 'absolute z-10',
141
+ position === 'top-left' ? 'top-3 left-3' : 'bottom-2 left-2'
142
+ )}
143
+ >
144
+ <Badge
145
+ className={cn('bg-black/50 text-white rounded', className)}
146
+ {...props}
147
+ >
148
+ {children}
149
+ </Badge>
150
+ </div>
151
+ )
152
+ }
153
+
154
+ function ProductCardFavoriteButton({
155
+ className,
156
+ onPress,
157
+ filled = false,
158
+ icon = 'Heart',
159
+ ...props
160
+ }: React.ComponentProps<'div'> & {
161
+ onPress?: () => void
162
+ filled?: boolean
163
+ icon?: LucideIconName
164
+ }) {
165
+ return (
166
+ <div className={cn('absolute bottom-3 right-3 z-10', className)} {...props}>
167
+ <Touchable onPress={onPress} asChild>
168
+ <Button
169
+ variant="secondary"
170
+ size="icon"
171
+ className={cn(
172
+ 'h-8 w-8 rounded-full border-0 shadow-sm',
173
+ filled ? 'bg-primary' : 'bg-grayscale-l6/60 backdrop-blur-sm'
174
+ )}
175
+ >
176
+ <Icon name={icon} filled={filled} className="h-4 w-4 text-white" />
177
+ </Button>
178
+ </Touchable>
179
+ </div>
180
+ )
181
+ }
182
+
183
+ function ProductCardInfo({
184
+ className,
185
+ variant = 'default',
186
+ ...props
187
+ }: React.ComponentProps<'div'> & {
188
+ variant?: 'default' | 'priceOverlay' | 'compact'
189
+ }) {
190
+ if (variant !== 'default') {
191
+ return null
192
+ }
193
+
194
+ return (
195
+ <div
196
+ data-slot="product-card-info"
197
+ className={cn('px-1 pt-2 pb-0 space-y-1', className)}
198
+ {...props}
199
+ />
200
+ )
201
+ }
202
+
203
+ function ProductCardTitle({
204
+ className,
205
+ children,
206
+ ...props
207
+ }: React.ComponentProps<'h3'>) {
208
+ return (
209
+ <h3
210
+ data-slot="product-card-title"
211
+ className={cn(
212
+ 'text-sm font-medium leading-tight text-gray-900',
213
+ 'truncate overflow-hidden whitespace-nowrap text-ellipsis',
214
+ className
215
+ )}
216
+ {...props}
217
+ >
218
+ {children}
219
+ </h3>
220
+ )
221
+ }
222
+
223
+ function ProductCardPrice({className, ...props}: React.ComponentProps<'div'>) {
224
+ return (
225
+ <div
226
+ data-slot="product-card-price"
227
+ className={cn('flex items-center gap-2', className)}
228
+ {...props}
229
+ />
230
+ )
231
+ }
232
+
233
+ function ProductCardCurrentPrice({
234
+ className,
235
+ ...props
236
+ }: React.ComponentProps<'span'>) {
237
+ return (
238
+ <span
239
+ data-slot="product-card-current-price"
240
+ className={cn('text-sm font-semibold text-gray-900', className)}
241
+ {...props}
242
+ />
243
+ )
244
+ }
245
+
246
+ function ProductCardOriginalPrice({
247
+ className,
248
+ ...props
249
+ }: React.ComponentProps<'span'>) {
250
+ return (
251
+ <span
252
+ data-slot="product-card-original-price"
253
+ className={cn('text-sm text-gray-500 line-through', className)}
254
+ {...props}
255
+ />
256
+ )
257
+ }
258
+
259
+ export interface ProductCardProps {
260
+ product: Product
261
+ selectedProductVariant?: ProductVariant
262
+ variant?: 'default' | 'priceOverlay' | 'compact'
263
+ touchable?: boolean
264
+ badgeText?: string
265
+ badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline'
266
+ onFavoriteToggled?: (isFavorited: boolean) => void
267
+ sectionId?: string
268
+ }
269
+
270
+ // Composed ProductCard component
271
+ function ProductCard({
272
+ product,
273
+ selectedProductVariant,
274
+ variant = 'default',
275
+ touchable = true,
276
+ badgeText,
277
+ badgeVariant = 'secondary',
278
+ onFavoriteToggled,
279
+ }: ProductCardProps) {
280
+ const {navigateToProduct} = useShopNavigation()
281
+ const {saveProduct, unsaveProduct} = useSavedProductsActions()
282
+
283
+ const {
284
+ id,
285
+ title,
286
+ featuredImage,
287
+ price,
288
+ compareAtPrice,
289
+ isFavorited,
290
+ defaultVariantId,
291
+ shop,
292
+ } = product
293
+
294
+ // Use selected variant data if available
295
+ const displayImage = selectedProductVariant?.image || featuredImage
296
+ const displayPrice = selectedProductVariant?.price || price
297
+ const displayCompareAtPrice =
298
+ selectedProductVariant?.compareAtPrice || compareAtPrice
299
+
300
+ // Local state for optimistic UI updates
301
+ const [isFavoritedLocal, setIsFavoritedLocal] = React.useState(isFavorited)
302
+
303
+ const currencyCode = displayPrice?.currencyCode
304
+ const amount = displayPrice?.amount
305
+ const imageUrl = displayImage?.url
306
+ const imageAltText = displayImage?.altText || title
307
+ const compareAtPriceAmount = displayCompareAtPrice?.amount
308
+ const hasDiscount = compareAtPriceAmount && compareAtPriceAmount !== amount
309
+
310
+ const handlePress = React.useCallback(() => {
311
+ if (!touchable) return
312
+
313
+ navigateToProduct({
314
+ productId: id,
315
+ })
316
+ }, [navigateToProduct, id, touchable])
317
+
318
+ const handleFavoritePress = React.useCallback(async () => {
319
+ const previousState = isFavoritedLocal
320
+
321
+ // Optimistic update
322
+ setIsFavoritedLocal(!previousState)
323
+ onFavoriteToggled?.(!previousState)
324
+
325
+ try {
326
+ if (previousState) {
327
+ await unsaveProduct({
328
+ productId: id,
329
+ shopId: shop.id,
330
+ productVariantId: selectedProductVariant?.id || defaultVariantId,
331
+ })
332
+ } else {
333
+ await saveProduct({
334
+ productId: id,
335
+ shopId: shop.id,
336
+ productVariantId: selectedProductVariant?.id || defaultVariantId,
337
+ })
338
+ }
339
+ } catch (error) {
340
+ // Revert optimistic update on error
341
+ setIsFavoritedLocal(previousState)
342
+ onFavoriteToggled?.(previousState)
343
+ }
344
+ }, [
345
+ isFavoritedLocal,
346
+ id,
347
+ shop.id,
348
+ selectedProductVariant?.id,
349
+ defaultVariantId,
350
+ saveProduct,
351
+ unsaveProduct,
352
+ onFavoriteToggled,
353
+ ])
354
+
355
+ return (
356
+ <ProductCardRoot
357
+ variant={variant}
358
+ touchable={touchable}
359
+ onPress={handlePress}
360
+ >
361
+ <ProductCardImageContainer variant={variant}>
362
+ <ProductCardImage src={imageUrl} alt={imageAltText} />
363
+
364
+ {/* Price overlay badge for priceOverlay variant */}
365
+ {variant === 'priceOverlay' && currencyCode && amount && (
366
+ <ProductCardBadge position="top-left">
367
+ {formatMoney(amount, currencyCode)}
368
+ </ProductCardBadge>
369
+ )}
370
+
371
+ {/* Custom badge */}
372
+ {badgeText && (
373
+ <ProductCardBadge position="bottom-left" variant={badgeVariant}>
374
+ {badgeText}
375
+ </ProductCardBadge>
376
+ )}
377
+
378
+ {/* Favorite button */}
379
+ <ProductCardFavoriteButton
380
+ filled={isFavoritedLocal}
381
+ onPress={handleFavoritePress}
382
+ />
383
+ </ProductCardImageContainer>
384
+
385
+ {/* Product info for default variant */}
386
+ <ProductCardInfo variant={variant}>
387
+ <ProductCardTitle>{title}</ProductCardTitle>
388
+
389
+ <ProductCardPrice>
390
+ {hasDiscount ? (
391
+ <>
392
+ <ProductCardCurrentPrice>
393
+ {formatMoney(amount, currencyCode)}
394
+ </ProductCardCurrentPrice>
395
+ <ProductCardOriginalPrice>
396
+ {formatMoney(
397
+ compareAtPriceAmount,
398
+ displayCompareAtPrice?.currencyCode || currencyCode
399
+ )}
400
+ </ProductCardOriginalPrice>
401
+ </>
402
+ ) : (
403
+ <ProductCardCurrentPrice>
404
+ {formatMoney(amount, currencyCode)}
405
+ </ProductCardCurrentPrice>
406
+ )}
407
+ </ProductCardPrice>
408
+ </ProductCardInfo>
409
+ </ProductCardRoot>
410
+ )
411
+ }
412
+
413
+ export {
414
+ // Composed component
415
+ ProductCard,
416
+ // Primitive components for custom composition
417
+ ProductCardRoot,
418
+ ProductCardImageContainer,
419
+ ProductCardImage,
420
+ ProductCardBadge,
421
+ ProductCardFavoriteButton,
422
+ ProductCardInfo,
423
+ ProductCardTitle,
424
+ ProductCardPrice,
425
+ ProductCardCurrentPrice,
426
+ ProductCardOriginalPrice,
427
+ }
@@ -202,7 +202,7 @@ function ProductLinkActions({
202
202
  >
203
203
  <Touchable onPress={onActionPress} asChild>
204
204
  <Button
205
- variant="ghost"
205
+ variant="borderless"
206
206
  size="icon"
207
207
  className="h-auto w-auto p-0 hover:bg-transparent"
208
208
  >
@@ -327,7 +327,7 @@ function ProductLink({product}: ProductLinkProps) {
327
327
  className={cn(
328
328
  'h-3 w-3',
329
329
  i < Math.floor(averageRating!)
330
- ? 'text-gray-900'
330
+ ? 'text-primary'
331
331
  : 'text-gray-300'
332
332
  )}
333
333
  />
@@ -1,3 +1,4 @@
1
+ export * from './commerce/product-card'
1
2
  export * from './commerce/product-link'
2
3
 
3
4
  export * from './ui/accordion'
@@ -130,7 +130,7 @@ function AlertDialogCancel({
130
130
  }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
131
131
  return (
132
132
  <AlertDialogPrimitive.Cancel
133
- className={cn(buttonVariants({variant: 'outline'}), className)}
133
+ className={cn(buttonVariants({variant: 'outlined'}), className)}
134
134
  {...props}
135
135
  />
136
136
  )
@@ -10,17 +10,23 @@ const buttonVariants = cva(
10
10
  {
11
11
  variants: {
12
12
  variant: {
13
- default:
13
+ primary:
14
14
  'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
15
- destructive:
16
- 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
17
- outline:
18
- 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
19
- secondary:
20
- 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
21
- ghost:
22
- 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
23
- link: 'text-primary underline-offset-4 hover:underline',
15
+ secondary: 'bg-slate-900 text-white shadow-xs hover:bg-slate-800',
16
+ tertiary:
17
+ 'bg-slate-100 text-slate-900 shadow-xs hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700',
18
+ blurred:
19
+ 'bg-black/20 text-white shadow-xs hover:bg-black/30 backdrop-blur-md border border-white/20',
20
+ text: 'bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground',
21
+ borderless: 'bg-transparent text-primary hover:text-primary/80',
22
+ borderlessUnbranded:
23
+ 'bg-transparent text-foreground hover:text-foreground/80',
24
+ outlined:
25
+ 'border border-border bg-background text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground',
26
+ dangerous:
27
+ 'bg-destructive text-white shadow-xs hover:bg-destructive/90',
28
+ 'outlined-dangerous':
29
+ 'border border-destructive bg-transparent text-destructive shadow-xs hover:bg-destructive/10',
24
30
  },
25
31
  size: {
26
32
  default: 'h-9 px-4 py-2 has-[>svg]:px-3',
@@ -30,7 +36,7 @@ const buttonVariants = cva(
30
36
  },
31
37
  },
32
38
  defaultVariants: {
33
- variant: 'default',
39
+ variant: 'primary',
34
40
  size: 'default',
35
41
  },
36
42
  }
@@ -173,7 +173,7 @@ function CarouselItem({className, ...props}: React.ComponentProps<'div'>) {
173
173
 
174
174
  function CarouselPrevious({
175
175
  className,
176
- variant = 'outline',
176
+ variant = 'outlined',
177
177
  size = 'icon',
178
178
  ...props
179
179
  }: React.ComponentProps<typeof Button>) {
@@ -203,7 +203,7 @@ function CarouselPrevious({
203
203
 
204
204
  function CarouselNext({
205
205
  className,
206
- variant = 'outline',
206
+ variant = 'outlined',
207
207
  size = 'icon',
208
208
  ...props
209
209
  }: React.ComponentProps<typeof Button>) {