@fayz-ai/storefront 0.8.0 → 0.8.3

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,174 @@
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+ import type { ProductImage } from '@fayz-ai/shop/types'
3
+ import type { ProductGalleryProps } from '../component-contracts'
4
+ import { SmoothImage } from './SmoothImage'
5
+ import { TID } from '../testids'
6
+ import { storefrontComponentContracts } from '../component-selectors'
7
+
8
+ /**
9
+ * Product gallery: one framed image, hover-to-zoom, and a thumbnail strip when
10
+ * the product has more than one photo.
11
+ *
12
+ * It replaces a bare <img> that rendered only the primary image. Three things
13
+ * were wrong with that:
14
+ *
15
+ * · the frame was a grid child with the default `stretch`, so it grew to the
16
+ * height of the (much taller) details column and left a slab of empty dark
17
+ * box under a square photo;
18
+ * · `plg_shop_product_images` has always been a collection with sort_order and
19
+ * is_primary, and every extra photo a merchant uploaded was invisible;
20
+ * · zooming is table stakes for anything sold by its looks — food especially.
21
+ *
22
+ * Zoom is pointer-driven: the transform origin follows the cursor, so the part
23
+ * under the pointer is the part magnified. Disabled on coarse pointers, where
24
+ * there is no hover and a stuck 2× would just be a broken image.
25
+ */
26
+ export function ProductGallery({ product, images, primaryImage }: ProductGalleryProps) {
27
+ const ordered = orderImages(images, primaryImage)
28
+ const [index, setIndex] = useState(0)
29
+ const [zoom, setZoom] = useState<{ x: number; y: number } | null>(null)
30
+ const frameRef = useRef<HTMLDivElement>(null)
31
+ const canHover = useCanHover()
32
+
33
+ // A different product (client-side navigation) must not keep the previous
34
+ // product's selected slide.
35
+ useEffect(() => { setIndex(0); setZoom(null) }, [product.id])
36
+
37
+ const current = ordered[Math.min(index, ordered.length - 1)]
38
+
39
+ function trackPointer(event: React.MouseEvent<HTMLDivElement>) {
40
+ if (!canHover) return
41
+ const frame = frameRef.current
42
+ if (!frame) return
43
+ const rect = frame.getBoundingClientRect()
44
+ setZoom({
45
+ x: ((event.clientX - rect.left) / rect.width) * 100,
46
+ y: ((event.clientY - rect.top) / rect.height) * 100,
47
+ })
48
+ }
49
+
50
+ function step(delta: number) {
51
+ if (ordered.length < 2) return
52
+ setIndex((current) => (current + delta + ordered.length) % ordered.length)
53
+ setZoom(null)
54
+ }
55
+
56
+ return (
57
+ <div className="flex flex-col gap-3 self-start">
58
+ <div
59
+ {...storefrontComponentContracts.productDetail.gallery}
60
+ ref={frameRef}
61
+ // `self-start` above and a fixed aspect here: the frame is sized by the
62
+ // image, never by whatever the column beside it happens to contain.
63
+ className="group relative aspect-square w-full overflow-hidden border bg-muted"
64
+ style={{ borderRadius: 'var(--sf-radius-card)' }}
65
+ onMouseMove={trackPointer}
66
+ onMouseLeave={() => setZoom(null)}
67
+ onKeyDown={(event) => {
68
+ if (event.key === 'ArrowRight') step(1)
69
+ if (event.key === 'ArrowLeft') step(-1)
70
+ }}
71
+ tabIndex={ordered.length > 1 ? 0 : -1}
72
+ role={ordered.length > 1 ? 'group' : undefined}
73
+ aria-label={ordered.length > 1 ? `Imagens de ${product.name}` : undefined}
74
+ >
75
+ {/*
76
+ The transform lives on a wrapper, not on the image: SmoothImage writes
77
+ its own inline `transition` for the load fade (SmoothImage.tsx:52,
78
+ spread AFTER any style passed in), which would have stretched the zoom
79
+ to 420ms and left it visibly lagging the cursor.
80
+ */}
81
+ {current && (
82
+ <div
83
+ className="h-full w-full transition-transform duration-150 ease-out"
84
+ style={zoom
85
+ ? { transform: 'scale(2)', transformOrigin: `${zoom.x}% ${zoom.y}%` }
86
+ : { transform: 'scale(1)' }}
87
+ >
88
+ <SmoothImage
89
+ key={current.id}
90
+ src={current.url}
91
+ alt={current.altText ?? product.name}
92
+ data-testid={TID.pdpGalleryImage}
93
+ className="h-full w-full object-cover"
94
+ />
95
+ </div>
96
+ )}
97
+
98
+ {ordered.length > 1 && (
99
+ <>
100
+ <GalleryArrow side="left" onClick={() => step(-1)} />
101
+ <GalleryArrow side="right" onClick={() => step(1)} />
102
+ <span className="pointer-events-none absolute bottom-3 right-3 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white">
103
+ {index + 1}/{ordered.length}
104
+ </span>
105
+ </>
106
+ )}
107
+ </div>
108
+
109
+ {ordered.length > 1 && (
110
+ <ul data-testid={TID.pdpGalleryThumbs} className="flex gap-2 overflow-x-auto pb-1">
111
+ {ordered.map((image, position) => (
112
+ <li key={image.id}>
113
+ <button
114
+ type="button"
115
+ data-testid={TID.pdpGalleryThumb}
116
+ data-index={position}
117
+ aria-label={`Imagem ${position + 1} de ${ordered.length}`}
118
+ aria-current={position === index}
119
+ onClick={() => { setIndex(position); setZoom(null) }}
120
+ className={`h-16 w-16 shrink-0 overflow-hidden rounded-lg border transition ${
121
+ position === index ? 'border-primary ring-2 ring-primary/20' : 'border-border opacity-70 hover:opacity-100'
122
+ }`}
123
+ >
124
+ <img src={image.url} alt="" className="h-full w-full object-cover" loading="lazy" />
125
+ </button>
126
+ </li>
127
+ ))}
128
+ </ul>
129
+ )}
130
+ </div>
131
+ )
132
+ }
133
+
134
+ /** Primary first, then sort_order — the order the merchant set in the admin. */
135
+ function orderImages(images: ProductImage[], primary?: ProductImage): ProductImage[] {
136
+ const all = images.length > 0 ? images : primary ? [primary] : []
137
+ return [...all].sort((a, b) => {
138
+ if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1
139
+ return (a.sortOrder ?? 0) - (b.sortOrder ?? 0)
140
+ })
141
+ }
142
+
143
+ /**
144
+ * Whether the device actually has a hover-capable pointer. On a phone every
145
+ * element is permanently "hovered" by some emulations, which would leave the
146
+ * photo stuck at 2× with no way back.
147
+ */
148
+ function useCanHover(): boolean {
149
+ const [canHover, setCanHover] = useState(false)
150
+ useEffect(() => {
151
+ const query = window.matchMedia?.('(hover: hover) and (pointer: fine)')
152
+ if (!query) return
153
+ setCanHover(query.matches)
154
+ const onChange = (event: MediaQueryListEvent) => setCanHover(event.matches)
155
+ query.addEventListener?.('change', onChange)
156
+ return () => query.removeEventListener?.('change', onChange)
157
+ }, [])
158
+ return canHover
159
+ }
160
+
161
+ function GalleryArrow({ side, onClick }: { side: 'left' | 'right'; onClick: () => void }) {
162
+ return (
163
+ <button
164
+ type="button"
165
+ onClick={onClick}
166
+ aria-label={side === 'left' ? 'Imagem anterior' : 'Próxima imagem'}
167
+ className={`absolute top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-background/80 opacity-0 shadow-md backdrop-blur transition hover:bg-background focus:opacity-100 group-hover:opacity-100 ${
168
+ side === 'left' ? 'left-3' : 'right-3'
169
+ }`}
170
+ >
171
+ <span aria-hidden className="text-lg leading-none">{side === 'left' ? '‹' : '›'}</span>
172
+ </button>
173
+ )
174
+ }
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import React, { createContext, useContext } from 'react'
2
2
  import type { ShopProvider } from '@fayz-ai/shop/provider'
3
3
  import type { MockShopSeed } from '@fayz-ai/shop/mock'
4
+ import type { PaymentMethodKind } from '@fayz-ai/shop/types'
4
5
  import type { StorefrontTheme } from './theme'
5
6
  import type { HomeConfig, NavLink, FooterConfig } from './sections'
6
7
  import type { StorefrontAuthConfig } from './auth'
@@ -122,7 +123,15 @@ export interface StorefrontConfig {
122
123
  * real charge (demo). M4 sets 'pix-mercadopago' so orders stay pending until
123
124
  * a real payment webhook confirms settlement.
124
125
  */
125
- payments?: { mode?: 'mock' | 'pix-mercadopago' }
126
+ payments?: {
127
+ mode?: 'mock' | 'pix-mercadopago'
128
+ /**
129
+ * Which methods the checkout offers. Declaring them is what lets the order
130
+ * record how the buyer intends to pay instead of assuming 'credit_card',
131
+ * which is what it did while the card form was a simulation.
132
+ */
133
+ methods?: readonly PaymentMethodKind[]
134
+ }
126
135
  /** Product enquiry behavior for catalog/enquiry stores. */
127
136
  enquiry?: StorefrontEnquiryConfig
128
137
  /** Feature toggles — defaults depend on commerceMode. */
@@ -139,7 +148,7 @@ export interface ResolvedStorefrontConfig extends StorefrontConfig {
139
148
  currency: string
140
149
  locale: string
141
150
  shipping: { flatRate: number; freeAbove?: number }
142
- payments: { mode: 'mock' | 'pix-mercadopago' }
151
+ payments: { mode: 'mock' | 'pix-mercadopago'; methods: readonly PaymentMethodKind[] }
143
152
  commerceMode: StorefrontCommerceMode
144
153
  enquiry: Required<Pick<StorefrontEnquiryConfig, 'label' | 'successMessage' | 'subjectPrefix'>> &
145
154
  Omit<StorefrontEnquiryConfig, 'label' | 'successMessage' | 'subjectPrefix'>
@@ -159,7 +168,10 @@ export function resolveConfig(config: StorefrontConfig): ResolvedStorefrontConfi
159
168
  currency: config.currency ?? 'BRL',
160
169
  locale: config.locale ?? 'pt-BR',
161
170
  shipping: { flatRate: config.shipping?.flatRate ?? 0, freeAbove: config.shipping?.freeAbove },
162
- payments: { mode: config.payments?.mode ?? 'mock' },
171
+ payments: {
172
+ mode: config.payments?.mode ?? 'mock',
173
+ methods: config.payments?.methods?.length ? config.payments.methods : ['pix', 'credit_card', 'cash'],
174
+ },
163
175
  commerceMode,
164
176
  enquiry: {
165
177
  label: config.enquiry?.label ?? 'Contact me',