@fayz-ai/shop 0.2.0

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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/dist/catalog.cjs +176 -0
  4. package/dist/catalog.cjs.map +1 -0
  5. package/dist/catalog.d.ts +4 -0
  6. package/dist/catalog.d.ts.map +1 -0
  7. package/dist/catalog.js +170 -0
  8. package/dist/catalog.js.map +1 -0
  9. package/dist/commerce.d.ts +28 -0
  10. package/dist/commerce.d.ts.map +1 -0
  11. package/dist/index.cjs +1047 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.ts +12 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +1027 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/mock-catalog.d.ts +42 -0
  18. package/dist/mock-catalog.d.ts.map +1 -0
  19. package/dist/mock-provider.d.ts +48 -0
  20. package/dist/mock-provider.d.ts.map +1 -0
  21. package/dist/mock.cjs +430 -0
  22. package/dist/mock.cjs.map +1 -0
  23. package/dist/mock.d.ts +3 -0
  24. package/dist/mock.d.ts.map +1 -0
  25. package/dist/mock.js +427 -0
  26. package/dist/mock.js.map +1 -0
  27. package/dist/provider.cjs +4 -0
  28. package/dist/provider.cjs.map +1 -0
  29. package/dist/provider.d.ts +42 -0
  30. package/dist/provider.d.ts.map +1 -0
  31. package/dist/provider.js +3 -0
  32. package/dist/provider.js.map +1 -0
  33. package/dist/runtime.cjs +447 -0
  34. package/dist/runtime.cjs.map +1 -0
  35. package/dist/runtime.d.ts +5 -0
  36. package/dist/runtime.d.ts.map +1 -0
  37. package/dist/runtime.js +443 -0
  38. package/dist/runtime.js.map +1 -0
  39. package/dist/supabase-provider.d.ts +33 -0
  40. package/dist/supabase-provider.d.ts.map +1 -0
  41. package/dist/tenant.d.ts +4 -0
  42. package/dist/tenant.d.ts.map +1 -0
  43. package/dist/types.cjs +4 -0
  44. package/dist/types.cjs.map +1 -0
  45. package/dist/types.d.ts +345 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +3 -0
  48. package/dist/types.js.map +1 -0
  49. package/package.json +99 -0
  50. package/src/catalog.ts +3 -0
  51. package/src/commerce.ts +120 -0
  52. package/src/index.ts +34 -0
  53. package/src/mock-catalog.ts +258 -0
  54. package/src/mock-provider.ts +372 -0
  55. package/src/mock.ts +2 -0
  56. package/src/provider.ts +59 -0
  57. package/src/runtime.ts +20 -0
  58. package/src/supabase-provider.ts +499 -0
  59. package/src/tenant.ts +21 -0
  60. package/src/types.ts +401 -0
@@ -0,0 +1,258 @@
1
+ import type { Product, ProductImage, Category, Discount } from './types'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Deterministic, offline product catalog for the mock provider.
5
+ // Product names sourced from the Fayz client repo references
6
+ // (apps/web ShopSection mockups + original shop seeds), expanded to a
7
+ // 16-product / 4-category Shopify-style catalog.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const T0 = '2026-01-01T12:00:00.000Z'
11
+
12
+ /** 600x600 gradient SVG with product initials — no network, e2e-stable. */
13
+ export function svgPlaceholder(label: string, hueA: number, hueB: number): string {
14
+ const initials = label
15
+ .split(/\s+/)
16
+ .map((w) => w[0])
17
+ .slice(0, 2)
18
+ .join('')
19
+ .toUpperCase()
20
+ const svg =
21
+ `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600">` +
22
+ `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
23
+ `<stop offset="0" stop-color="hsl(${hueA},45%,82%)"/>` +
24
+ `<stop offset="1" stop-color="hsl(${hueB},50%,62%)"/>` +
25
+ `</linearGradient></defs>` +
26
+ `<rect width="600" height="600" fill="url(#g)"/>` +
27
+ `<text x="300" y="330" font-family="sans-serif" font-size="120" font-weight="700" ` +
28
+ `fill="rgba(255,255,255,0.85)" text-anchor="middle">${initials}</text>` +
29
+ `</svg>`
30
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
31
+ }
32
+
33
+ function image(productId: string, name: string, hueA: number, hueB: number, idx = 0): ProductImage {
34
+ return {
35
+ id: `img-${productId}-${idx}`,
36
+ productId,
37
+ url: svgPlaceholder(name, hueA, hueB),
38
+ altText: name,
39
+ sortOrder: idx,
40
+ isPrimary: idx === 0,
41
+ createdAt: T0,
42
+ }
43
+ }
44
+
45
+ export const MOCK_CATEGORIES: Category[] = [
46
+ { id: 'cat-apparel', tenantId: 'mock', name: 'Apparel', slug: 'apparel', description: 'Clothing and wearables', parentId: null, sortOrder: 0, createdAt: T0 },
47
+ { id: 'cat-bags', tenantId: 'mock', name: 'Bags & Accessories', slug: 'bags-accessories', description: 'Bags, wallets and carry gear', parentId: null, sortOrder: 1, createdAt: T0 },
48
+ { id: 'cat-home', tenantId: 'mock', name: 'Home & Kitchen', slug: 'home-kitchen', description: 'Everyday home goods', parentId: null, sortOrder: 2, createdAt: T0 },
49
+ { id: 'cat-tech', tenantId: 'mock', name: 'Stationery & Tech', slug: 'stationery-tech', description: 'Desk, audio and paper goods', parentId: null, sortOrder: 3, createdAt: T0 },
50
+ ]
51
+
52
+ interface SeedProduct {
53
+ id: string
54
+ name: string
55
+ slug: string
56
+ description: string
57
+ price: number
58
+ compareAtPrice?: number
59
+ inventoryCount: number
60
+ sku: string
61
+ categoryId: string
62
+ hues: [number, number]
63
+ weight: number
64
+ }
65
+
66
+ const SEEDS: SeedProduct[] = [
67
+ // --- Apparel ---
68
+ { id: 'p-01', name: 'Classic T-Shirt', slug: 'classic-t-shirt', description: 'A timeless crew-neck tee in heavyweight combed cotton. Pre-shrunk, breathable, and built to outlast trends.', price: 59.9, compareAtPrice: 79.9, inventoryCount: 50, sku: 'TSH-001', categoryId: 'cat-apparel', hues: [210, 240], weight: 0.2 },
69
+ { id: 'p-02', name: 'Linen Shirt', slug: 'linen-shirt', description: 'A relaxed-fit shirt in 100% European linen. Naturally breathable and softer with every wash.', price: 189.9, inventoryCount: 24, sku: 'LNS-002', categoryId: 'cat-apparel', hues: [35, 20], weight: 0.3 },
70
+ { id: 'p-03', name: 'Denim Jacket', slug: 'denim-jacket', description: 'A structured trucker jacket in 12oz raw denim. Ages beautifully with hidden interior pockets.', price: 299.9, inventoryCount: 12, sku: 'DNJ-003', categoryId: 'cat-apparel', hues: [215, 200], weight: 0.8 },
71
+ { id: 'p-04', name: 'Wool Beanie', slug: 'wool-beanie', description: 'A double-layer merino wool beanie. Warm without itch, with a low-profile cuffed fit.', price: 79.9, inventoryCount: 0, sku: 'WBN-004', categoryId: 'cat-apparel', hues: [0, 350], weight: 0.1 },
72
+
73
+ // --- Bags & Accessories ---
74
+ { id: 'p-05', name: 'Canvas Tote Bag', slug: 'canvas-tote-bag', description: 'An everyday carryall in 16oz waxed canvas with reinforced straps and an interior zip pocket.', price: 39.9, inventoryCount: 30, sku: 'BAG-001', categoryId: 'cat-bags', hues: [80, 110], weight: 0.3 },
75
+ { id: 'p-06', name: 'Leather Backpack', slug: 'leather-backpack', description: 'Full-grain leather backpack with a padded 15" laptop sleeve, magnetic closures and brass hardware.', price: 549.9, inventoryCount: 8, sku: 'LBP-006', categoryId: 'cat-bags', hues: [25, 15], weight: 1.4 },
76
+ { id: 'p-07', name: 'Leather Card Wallet', slug: 'leather-card-wallet', description: 'A slim six-card wallet in vegetable-tanned leather. RFID-shielded with a center cash slot.', price: 89.9, compareAtPrice: 119.9, inventoryCount: 45, sku: 'LCW-007', categoryId: 'cat-bags', hues: [30, 45], weight: 0.08 },
77
+ { id: 'p-08', name: 'Travel Duffel', slug: 'travel-duffel', description: 'A 40L weekender in ballistic nylon with a trolley sleeve, shoe compartment and detachable strap.', price: 329.9, inventoryCount: 15, sku: 'TDF-008', categoryId: 'cat-bags', hues: [150, 170], weight: 1.1 },
78
+
79
+ // --- Home & Kitchen ---
80
+ { id: 'p-09', name: 'Ceramic Coffee Mug', slug: 'ceramic-coffee-mug', description: 'A 350ml stoneware mug with a matte glaze and comfortable wide handle. Dishwasher and microwave safe.', price: 34.9, inventoryCount: 0, sku: 'MUG-001', categoryId: 'cat-home', hues: [190, 210], weight: 0.4 },
81
+ { id: 'p-10', name: 'Scented Soy Candle', slug: 'scented-soy-candle', description: 'Hand-poured soy wax candle with cedar and bergamot notes. 45-hour burn time in a reusable glass jar.', price: 69.9, inventoryCount: 60, sku: 'SSC-010', categoryId: 'cat-home', hues: [45, 30], weight: 0.5 },
82
+ { id: 'p-11', name: 'Bamboo Cutting Board', slug: 'bamboo-cutting-board', description: 'An end-grain bamboo board that is gentle on knives, with a juice groove and inset handles.', price: 119.9, inventoryCount: 22, sku: 'BCB-011', categoryId: 'cat-home', hues: [90, 70], weight: 1.2 },
83
+ { id: 'p-12', name: 'Stoneware Plate Set', slug: 'stoneware-plate-set', description: 'A set of four reactive-glaze dinner plates. Each piece is unique, oven-safe to 220°C.', price: 249.9, inventoryCount: 10, sku: 'SPS-012', categoryId: 'cat-home', hues: [200, 180], weight: 2.4 },
84
+
85
+ // --- Stationery & Tech ---
86
+ { id: 'p-13', name: 'Wireless Headphones', slug: 'wireless-headphones', description: 'Over-ear headphones with active noise cancelling, 40-hour battery and multipoint Bluetooth 5.3.', price: 899.9, inventoryCount: 18, sku: 'WHP-013', categoryId: 'cat-tech', hues: [260, 280], weight: 0.35 },
87
+ { id: 'p-14', name: 'Notebook Set', slug: 'notebook-set', description: 'Three A5 dot-grid notebooks with 120gsm paper, lay-flat binding and numbered pages.', price: 29.9, inventoryCount: 80, sku: 'NBS-014', categoryId: 'cat-tech', hues: [330, 310], weight: 0.45 },
88
+ { id: 'p-15', name: 'Bluetooth Speaker', slug: 'bluetooth-speaker', description: 'A pocket-size speaker with surprising low end, IPX7 waterproofing and 14-hour playtime.', price: 199.9, compareAtPrice: 259.9, inventoryCount: 26, sku: 'BTS-015', categoryId: 'cat-tech', hues: [170, 190], weight: 0.3 },
89
+ { id: 'p-16', name: 'Felt Desk Mat', slug: 'felt-desk-mat', description: 'A 80x30cm merino felt desk mat with a cork base. Protects the desk and quiets the keyboard.', price: 99.9, inventoryCount: 34, sku: 'FDM-016', categoryId: 'cat-tech', hues: [220, 230], weight: 0.6 },
90
+ ]
91
+
92
+ const CATEGORY_NAME: Record<string, string> = Object.fromEntries(
93
+ MOCK_CATEGORIES.map((c) => [c.id, c.name]),
94
+ )
95
+
96
+ export const MOCK_PRODUCTS: Product[] = SEEDS.map((s, i) => ({
97
+ id: s.id,
98
+ tenantId: 'mock',
99
+ name: s.name,
100
+ slug: s.slug,
101
+ description: s.description,
102
+ price: s.price,
103
+ compareAtPrice: s.compareAtPrice ?? null,
104
+ currency: 'BRL',
105
+ status: 'active',
106
+ inventoryCount: s.inventoryCount,
107
+ sku: s.sku,
108
+ sortOrder: i,
109
+ metadata: {},
110
+ images: [image(s.id, s.name, s.hues[0], s.hues[1])],
111
+ categoryId: s.categoryId,
112
+ categoryName: CATEGORY_NAME[s.categoryId] ?? null,
113
+ isPhysical: true,
114
+ weight: s.weight,
115
+ weightUnit: 'kg',
116
+ // staggered timestamps → deterministic 'newest' sort (p-16 newest)
117
+ createdAt: `2026-01-${String(i + 1).padStart(2, '0')}T12:00:00.000Z`,
118
+ updatedAt: `2026-01-${String(i + 1).padStart(2, '0')}T12:00:00.000Z`,
119
+ }))
120
+
121
+ export const MOCK_DISCOUNTS: Discount[] = [
122
+ {
123
+ id: 'disc-welcome',
124
+ tenantId: 'mock',
125
+ title: 'Welcome 10%',
126
+ code: 'BEMVINDO10',
127
+ type: 'percentage',
128
+ method: 'code',
129
+ value: 10,
130
+ usageLimit: null,
131
+ oncePerCustomer: false,
132
+ startsAt: T0,
133
+ endsAt: null,
134
+ status: 'active',
135
+ timesUsed: 0,
136
+ createdAt: T0,
137
+ updatedAt: T0,
138
+ },
139
+ ]
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // buildMockCatalog — terse per-store catalog definition. Generates ids,
143
+ // slugs, SVG imagery and staggered timestamps so every store gets a
144
+ // deterministic, e2e-stable catalog from a few lines of data.
145
+ // ---------------------------------------------------------------------------
146
+
147
+ export interface CatalogCategoryInput {
148
+ name: string
149
+ description?: string
150
+ image?: string
151
+ }
152
+
153
+ export interface CatalogProductInput {
154
+ name: string
155
+ description: string
156
+ price: number
157
+ compareAtPrice?: number
158
+ inventory: number
159
+ sku: string
160
+ /** Category name — must match one of the categories */
161
+ category: string
162
+ hues?: [number, number]
163
+ weight?: number
164
+ image?: string
165
+ metadata?: Record<string, unknown>
166
+ }
167
+
168
+ export interface CatalogInput {
169
+ categories: CatalogCategoryInput[]
170
+ products: CatalogProductInput[]
171
+ /** Percentage discount codes, e.g. [{ code: 'BEMVINDO10', percent: 10 }] */
172
+ discounts?: Array<{ code: string; percent: number; title?: string }>
173
+ currency?: string
174
+ }
175
+
176
+ function slugifyName(name: string): string {
177
+ return name
178
+ .toLowerCase()
179
+ .normalize('NFD')
180
+ .replace(/[̀-ͯ]/g, '')
181
+ .replace(/[^a-z0-9]+/g, '-')
182
+ .replace(/^-|-$/g, '')
183
+ }
184
+
185
+ export function buildMockCatalog(input: CatalogInput): { products: Product[]; categories: Category[]; discounts: Discount[] } {
186
+ const categories: Category[] = input.categories.map((c, i) => ({
187
+ id: `cat-${slugifyName(c.name)}`,
188
+ tenantId: 'mock',
189
+ name: c.name,
190
+ slug: slugifyName(c.name),
191
+ description: c.description ?? null,
192
+ parentId: null,
193
+ sortOrder: i,
194
+ imageUrl: c.image ?? null,
195
+ createdAt: T0,
196
+ }))
197
+ const byName = Object.fromEntries(categories.map((c) => [c.name, c]))
198
+
199
+ const products: Product[] = input.products.map((p, i) => {
200
+ const cat = byName[p.category]
201
+ const id = `p-${String(i + 1).padStart(2, '0')}`
202
+ const hues = p.hues ?? [(i * 47) % 360, (i * 47 + 50) % 360]
203
+ const stamp = `2026-01-${String((i % 27) + 1).padStart(2, '0')}T12:00:00.000Z`
204
+ return {
205
+ id,
206
+ tenantId: 'mock',
207
+ name: p.name,
208
+ slug: slugifyName(p.name),
209
+ description: p.description,
210
+ price: p.price,
211
+ compareAtPrice: p.compareAtPrice ?? null,
212
+ currency: input.currency ?? 'BRL',
213
+ status: 'active',
214
+ inventoryCount: p.inventory,
215
+ sku: p.sku,
216
+ sortOrder: i,
217
+ metadata: p.metadata ?? {},
218
+ images: [
219
+ {
220
+ id: `img-${id}-0`,
221
+ productId: id,
222
+ url: p.image ?? svgPlaceholder(p.name, hues[0], hues[1]),
223
+ altText: p.name,
224
+ sortOrder: 0,
225
+ isPrimary: true,
226
+ createdAt: T0,
227
+ },
228
+ ],
229
+ categoryId: cat?.id ?? null,
230
+ categoryName: cat?.name ?? null,
231
+ isPhysical: true,
232
+ weight: p.weight ?? 0.5,
233
+ weightUnit: 'kg',
234
+ createdAt: stamp,
235
+ updatedAt: stamp,
236
+ }
237
+ })
238
+
239
+ const discounts: Discount[] = (input.discounts ?? []).map((d, i) => ({
240
+ id: `disc-${slugifyName(d.code)}`,
241
+ tenantId: 'mock',
242
+ title: d.title ?? `${d.percent}% off`,
243
+ code: d.code,
244
+ type: 'percentage',
245
+ method: 'code',
246
+ value: d.percent,
247
+ usageLimit: null,
248
+ oncePerCustomer: false,
249
+ startsAt: T0,
250
+ endsAt: null,
251
+ status: 'active',
252
+ timesUsed: 0,
253
+ createdAt: T0,
254
+ updatedAt: T0,
255
+ }))
256
+
257
+ return { products, categories, discounts }
258
+ }
@@ -0,0 +1,372 @@
1
+ import type { ShopProvider } from './provider'
2
+ import type {
3
+ Product, ProductImage, Category, Order, ShopCustomer, Discount,
4
+ CreateProductInput, UpdateProductInput, ListProductsOptions,
5
+ CreateCategoryInput, UpdateCategoryInput,
6
+ CreateOrderInput, UpdateOrderInput, ListOrdersOptions, PlaceOrderInput,
7
+ CreateCustomerInput, UpdateCustomerInput, ListCustomersOptions, ResolveCustomerInput,
8
+ CreateDiscountInput, UpdateDiscountInput, ListDiscountsOptions,
9
+ } from './types'
10
+ import { MOCK_PRODUCTS, MOCK_CATEGORIES, MOCK_DISCOUNTS } from './mock-catalog'
11
+
12
+ function uid() { return Math.random().toString(36).slice(2) }
13
+ function now() { return new Date().toISOString() }
14
+
15
+ // Orders/customers persist to localStorage so "my purchases" survives a page
16
+ // reload. Products/categories/discounts always reseed from the catalog —
17
+ // the storefront sees a deterministic catalog on every load.
18
+ const ORDERS_KEY = 'fayz.shop.mock.orders.v1'
19
+ const CUSTOMERS_KEY = 'fayz.shop.mock.customers.v1'
20
+
21
+ function loadStored<T>(key: string): T[] {
22
+ try {
23
+ if (typeof localStorage === 'undefined') return []
24
+ const raw = localStorage.getItem(key)
25
+ return raw ? (JSON.parse(raw) as T[]) : []
26
+ } catch {
27
+ return []
28
+ }
29
+ }
30
+
31
+ function store<T>(key: string, value: T[]): void {
32
+ try {
33
+ if (typeof localStorage === 'undefined') return
34
+ localStorage.setItem(key, JSON.stringify(value))
35
+ } catch {
36
+ /* quota/serialization failures are non-fatal in mock mode */
37
+ }
38
+ }
39
+
40
+ export interface MockShopSeed {
41
+ products?: Product[]
42
+ categories?: Category[]
43
+ discounts?: Discount[]
44
+ }
45
+
46
+ export class MockShopProvider implements ShopProvider {
47
+ private products: Product[]
48
+ private categories: Category[]
49
+ private discounts: Discount[]
50
+ private orders: Order[] = loadStored<Order>(ORDERS_KEY)
51
+ private customers: ShopCustomer[] = loadStored<ShopCustomer>(CUSTOMERS_KEY)
52
+
53
+ constructor(seed?: MockShopSeed) {
54
+ this.products = (seed?.products ?? MOCK_PRODUCTS).map(p => ({ ...p }))
55
+ this.categories = (seed?.categories ?? MOCK_CATEGORIES).map(c => ({ ...c }))
56
+ this.discounts = (seed?.discounts ?? MOCK_DISCOUNTS).map(d => ({ ...d }))
57
+ }
58
+
59
+ // Re-read before mutating: HMR can re-instantiate the provider mid-session
60
+ // and a stale in-memory copy must not clobber persisted records.
61
+ private syncOrders() { this.orders = loadStored<Order>(ORDERS_KEY) }
62
+ private syncCustomers() { this.customers = loadStored<ShopCustomer>(CUSTOMERS_KEY) }
63
+ private persistOrders() { store(ORDERS_KEY, this.orders) }
64
+ private persistCustomers() { store(CUSTOMERS_KEY, this.customers) }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Products
68
+ // ---------------------------------------------------------------------------
69
+
70
+ async listProducts(opts?: ListProductsOptions): Promise<Product[]> {
71
+ let list = [...this.products]
72
+ if (opts?.status) list = list.filter(p => p.status === opts.status)
73
+ if (opts?.categoryId) list = list.filter(p => p.categoryId === opts.categoryId)
74
+ if (opts?.slug) list = list.filter(p => p.slug === opts.slug)
75
+ if (opts?.search) {
76
+ const q = opts.search.toLowerCase()
77
+ list = list.filter(p =>
78
+ p.name.toLowerCase().includes(q) ||
79
+ (p.description ?? '').toLowerCase().includes(q) ||
80
+ (p.sku ?? '').toLowerCase().includes(q),
81
+ )
82
+ }
83
+ const orderBy = opts?.orderBy ?? 'sort_order'
84
+ const dir = opts?.order === 'desc' ? -1 : 1
85
+ list.sort((a, b) => {
86
+ switch (orderBy) {
87
+ case 'name': return a.name.localeCompare(b.name) * dir
88
+ case 'price': return (a.price - b.price) * dir
89
+ case 'created_at': return a.createdAt.localeCompare(b.createdAt) * dir
90
+ default: return (a.sortOrder - b.sortOrder) * dir
91
+ }
92
+ })
93
+ const offset = opts?.offset ?? 0
94
+ if (offset || opts?.limit) list = list.slice(offset, opts?.limit ? offset + opts.limit : undefined)
95
+ return list
96
+ }
97
+
98
+ async getProduct(id: string) { return this.products.find(p => p.id === id) ?? null }
99
+
100
+ async createProduct(input: CreateProductInput): Promise<Product> {
101
+ const p: Product = { id: uid(), tenantId: 'mock', name: input.name, slug: input.slug ?? input.name.toLowerCase().replace(/\s+/g, '-'), description: input.description ?? null, price: input.price, compareAtPrice: input.compareAtPrice ?? null, currency: input.currency ?? 'BRL', status: input.status ?? 'active', inventoryCount: input.inventoryCount ?? 0, sku: input.sku ?? null, sortOrder: input.sortOrder ?? 0, metadata: input.metadata ?? {}, images: [], categoryId: input.categoryId ?? null, categoryName: null, isPhysical: input.isPhysical ?? true, weight: input.weight ?? null, weightUnit: input.weightUnit ?? 'kg', createdAt: now(), updatedAt: now() }
102
+ this.products.push(p); return p
103
+ }
104
+
105
+ async updateProduct(id: string, input: UpdateProductInput): Promise<Product> {
106
+ const i = this.products.findIndex(p => p.id === id)
107
+ if (i < 0) throw new Error('Product not found')
108
+ this.products[i] = { ...this.products[i], ...input, updatedAt: now() }
109
+ return this.products[i]
110
+ }
111
+
112
+ async deleteProduct(id: string) { this.products = this.products.filter(p => p.id !== id) }
113
+ async uploadProductImage(_productId: string, _file: File): Promise<ProductImage> { throw new Error('Image upload not available in mock mode') }
114
+ async deleteProductImage(_imageId: string) {}
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Categories
118
+ // ---------------------------------------------------------------------------
119
+
120
+ async listCategories() { return [...this.categories].sort((a, b) => a.sortOrder - b.sortOrder) }
121
+
122
+ async createCategory(input: CreateCategoryInput): Promise<Category> {
123
+ const c: Category = { id: uid(), tenantId: 'mock', name: input.name, slug: input.slug ?? input.name.toLowerCase().replace(/\s+/g, '-'), description: input.description ?? null, parentId: input.parentId ?? null, sortOrder: input.sortOrder ?? 0, createdAt: now() }
124
+ this.categories.push(c); return c
125
+ }
126
+
127
+ async updateCategory(id: string, input: UpdateCategoryInput): Promise<Category> {
128
+ const i = this.categories.findIndex(c => c.id === id)
129
+ if (i < 0) throw new Error('Category not found')
130
+ this.categories[i] = { ...this.categories[i], ...input }
131
+ return this.categories[i]
132
+ }
133
+
134
+ async deleteCategory(id: string) { this.categories = this.categories.filter(c => c.id !== id) }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Orders
138
+ // ---------------------------------------------------------------------------
139
+
140
+ async listOrders(opts?: ListOrdersOptions): Promise<Order[]> {
141
+ this.syncOrders()
142
+ let list = [...this.orders]
143
+ if (opts?.status) list = list.filter(o => o.status === opts.status)
144
+ if (opts?.financialStatus) list = list.filter(o => o.financialStatus === opts.financialStatus)
145
+ if (opts?.fulfillmentStatus) list = list.filter(o => o.fulfillmentStatus === opts.fulfillmentStatus)
146
+ if (opts?.customerId) list = list.filter(o => o.customerId === opts.customerId)
147
+ if (opts?.customerEmail) {
148
+ const email = opts.customerEmail.toLowerCase()
149
+ list = list.filter(o => (o.customerEmail ?? '').toLowerCase() === email)
150
+ }
151
+ if (opts?.search) {
152
+ const q = opts.search.toLowerCase()
153
+ list = list.filter(o =>
154
+ (o.customerName ?? '').toLowerCase().includes(q) ||
155
+ (o.customerEmail ?? '').toLowerCase().includes(q) ||
156
+ String(o.orderNumber).includes(q),
157
+ )
158
+ }
159
+ list.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
160
+ if (opts?.limit) list = list.slice(0, opts.limit)
161
+ return list
162
+ }
163
+
164
+ async getOrder(id: string) {
165
+ this.syncOrders()
166
+ return this.orders.find(o => o.id === id) ?? null
167
+ }
168
+
169
+ async createOrder(input: CreateOrderInput): Promise<Order> {
170
+ this.syncOrders()
171
+ const subtotal = input.items.reduce((s, i) => s + i.quantity * i.unitPrice, 0)
172
+ const discountTotal = input.discountTotal ?? 0
173
+ const shippingTotal = input.shippingTotal ?? 0
174
+ const total = Math.round((subtotal - discountTotal + shippingTotal) * 100) / 100
175
+ const orderId = uid()
176
+ const maxNumber = this.orders.reduce((m, o) => Math.max(m, o.orderNumber), 1000)
177
+ const o: Order = {
178
+ id: orderId,
179
+ tenantId: 'mock',
180
+ orderNumber: maxNumber + 1,
181
+ status: 'open',
182
+ financialStatus: 'pending',
183
+ fulfillmentStatus: 'unfulfilled',
184
+ currency: input.currency ?? 'BRL',
185
+ subtotal: Math.round(subtotal * 100) / 100,
186
+ taxTotal: 0,
187
+ discountTotal,
188
+ shippingTotal,
189
+ total,
190
+ customerId: input.customerId ?? null,
191
+ customerName: input.customerName ?? null,
192
+ customerEmail: input.customerEmail ?? null,
193
+ discountCode: input.discountCode ?? null,
194
+ notes: input.notes ?? null,
195
+ items: input.items.map(i => ({ id: uid(), orderId, productId: i.productId ?? null, name: i.name, sku: i.sku ?? null, quantity: i.quantity, unitPrice: i.unitPrice, total: Math.round(i.quantity * i.unitPrice * 100) / 100, imageUrl: i.imageUrl ?? null })),
196
+ createdAt: now(),
197
+ updatedAt: now(),
198
+ }
199
+ this.orders.push(o)
200
+ this.persistOrders()
201
+ return o
202
+ }
203
+
204
+ async updateOrder(id: string, input: UpdateOrderInput): Promise<Order> {
205
+ this.syncOrders()
206
+ const i = this.orders.findIndex(o => o.id === id)
207
+ if (i < 0) throw new Error('Order not found')
208
+ this.orders[i] = { ...this.orders[i], ...input, updatedAt: now() }
209
+ this.persistOrders()
210
+ return this.orders[i]
211
+ }
212
+
213
+ // Trusted placement (mock parity with the shop_place_order RPC): re-reads
214
+ // prices + stock from the catalog, validates the discount, decrements
215
+ // inventory. The browser-supplied prices are never used.
216
+ async placeOrder(input: PlaceOrderInput): Promise<Order> {
217
+ this.syncOrders()
218
+ this.syncCustomers()
219
+
220
+ // Resolve / create the customer (find-or-create by email).
221
+ let customerId = input.customerId ?? null
222
+ let customerName = input.customer?.name?.trim() || null
223
+ const email = input.customer?.email?.trim().toLowerCase() || null
224
+ if (!customerId && email) {
225
+ const existing = this.customers.find(c => (c.email ?? '').toLowerCase() === email)
226
+ if (existing) {
227
+ customerId = existing.id
228
+ customerName = customerName ?? `${existing.firstName} ${existing.lastName}`.trim()
229
+ } else {
230
+ const [first, ...rest] = (customerName ?? email).split(' ')
231
+ const created = await this.createCustomer({ firstName: first || email, lastName: rest.join(' '), email })
232
+ customerId = created.id
233
+ }
234
+ }
235
+
236
+ // Re-read price + guard stock from the catalog (never trust the client).
237
+ const items: CreateOrderInput['items'] = []
238
+ let subtotal = 0
239
+ for (const line of input.items) {
240
+ const product = this.products.find(p => p.id === line.productId)
241
+ if (!product || product.status !== 'active') throw new Error(`Product unavailable: ${line.productId}`)
242
+ if (product.inventoryCount < line.quantity) throw new Error(`Insufficient stock for ${product.name}`)
243
+ const name = line.optionsLabel ? `${product.name} (${line.optionsLabel})` : product.name
244
+ subtotal += product.price * line.quantity
245
+ items.push({ productId: product.id, name, sku: product.sku ?? undefined, quantity: line.quantity, unitPrice: product.price, imageUrl: product.images[0]?.url })
246
+ }
247
+ subtotal = Math.round(subtotal * 100) / 100
248
+
249
+ // Validate + apply the discount server-side.
250
+ let shippingTotal = Math.max(input.shippingTotal ?? 0, 0)
251
+ let discountTotal = 0
252
+ let appliedCode: string | null = null
253
+ if (input.discountCode) {
254
+ const code = input.discountCode.trim().toLowerCase()
255
+ const d = this.discounts.find(x => (x.code ?? '').toLowerCase() === code)
256
+ const ts = now()
257
+ const valid = !!d && d.status === 'active' && d.startsAt <= ts && (!d.endsAt || d.endsAt >= ts) && (d.usageLimit == null || d.timesUsed < d.usageLimit)
258
+ if (valid && d) {
259
+ appliedCode = d.code
260
+ if (d.type === 'percentage') discountTotal = Math.round(subtotal * d.value) / 100
261
+ else if (d.type === 'fixed_amount') discountTotal = Math.min(d.value, subtotal)
262
+ else if (d.type === 'free_shipping') shippingTotal = 0
263
+ // buy_x_get_y unsupported in v1: code recorded, no monetary effect
264
+ d.timesUsed += 1
265
+ }
266
+ }
267
+ discountTotal = Math.round(Math.min(Math.max(discountTotal, 0), subtotal) * 100) / 100
268
+
269
+ // Persist via createOrder (recomputes total from these trusted values).
270
+ const order = await this.createOrder({
271
+ customerId: customerId ?? undefined,
272
+ customerName: customerName ?? undefined,
273
+ customerEmail: input.customer?.email ?? undefined,
274
+ currency: input.currency,
275
+ notes: input.notes,
276
+ discountCode: appliedCode ?? undefined,
277
+ discountTotal,
278
+ shippingTotal,
279
+ items,
280
+ })
281
+
282
+ // Decrement inventory (in-memory; the mock catalog reseeds on reload).
283
+ for (const line of input.items) {
284
+ const product = this.products.find(p => p.id === line.productId)
285
+ if (product) product.inventoryCount = Math.max(0, product.inventoryCount - line.quantity)
286
+ }
287
+
288
+ return order
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Customers
293
+ // ---------------------------------------------------------------------------
294
+
295
+ async listCustomers(opts?: ListCustomersOptions) {
296
+ this.syncCustomers()
297
+ let list = [...this.customers]
298
+ if (opts?.search) list = list.filter(c => `${c.firstName} ${c.lastName} ${c.email}`.toLowerCase().includes(opts.search!.toLowerCase()))
299
+ if (opts?.limit) list = list.slice(0, opts.limit)
300
+ return list
301
+ }
302
+
303
+ async getCustomer(id: string) {
304
+ this.syncCustomers()
305
+ return this.customers.find(c => c.id === id) ?? null
306
+ }
307
+
308
+ async createCustomer(input: CreateCustomerInput): Promise<ShopCustomer> {
309
+ this.syncCustomers()
310
+ const c: ShopCustomer = { id: uid(), tenantId: 'mock', firstName: input.firstName, lastName: input.lastName ?? '', email: input.email ?? null, phone: input.phone ?? null, notes: input.notes ?? null, ordersCount: 0, totalSpent: 0, createdAt: now(), updatedAt: now() }
311
+ this.customers.push(c)
312
+ this.persistCustomers()
313
+ return c
314
+ }
315
+
316
+ async updateCustomer(id: string, input: UpdateCustomerInput): Promise<ShopCustomer> {
317
+ this.syncCustomers()
318
+ const i = this.customers.findIndex(c => c.id === id)
319
+ if (i < 0) throw new Error('Customer not found')
320
+ this.customers[i] = { ...this.customers[i], ...input, updatedAt: now() }
321
+ this.persistCustomers()
322
+ return this.customers[i]
323
+ }
324
+
325
+ async deleteCustomer(id: string) {
326
+ this.syncCustomers()
327
+ this.customers = this.customers.filter(c => c.id !== id)
328
+ this.persistCustomers()
329
+ }
330
+
331
+ async resolveCustomer(input: ResolveCustomerInput): Promise<ShopCustomer> {
332
+ this.syncCustomers()
333
+ const email = input.email.trim().toLowerCase()
334
+ const existing = this.customers.find(c => (c.email ?? '').toLowerCase() === email)
335
+ if (existing) return existing
336
+ const [first, ...rest] = (input.name?.trim() || email).split(/\s+/)
337
+ return this.createCustomer({ firstName: first || email, lastName: rest.join(' '), email })
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // Discounts
342
+ // ---------------------------------------------------------------------------
343
+
344
+ async listDiscounts(opts?: ListDiscountsOptions) {
345
+ let list = [...this.discounts]
346
+ if (opts?.status) list = list.filter(d => d.status === opts.status)
347
+ if (opts?.type) list = list.filter(d => d.type === opts.type)
348
+ if (opts?.search) list = list.filter(d => d.title.toLowerCase().includes(opts.search!.toLowerCase()))
349
+ if (opts?.limit) list = list.slice(0, opts.limit)
350
+ return list
351
+ }
352
+
353
+ async getDiscount(id: string) { return this.discounts.find(d => d.id === id) ?? null }
354
+
355
+ async createDiscount(input: CreateDiscountInput): Promise<Discount> {
356
+ const d: Discount = { id: uid(), tenantId: 'mock', title: input.title, code: input.code ?? null, type: input.type, method: input.method ?? 'code', value: input.value, usageLimit: input.usageLimit ?? null, oncePerCustomer: input.oncePerCustomer ?? false, startsAt: input.startsAt ?? now(), endsAt: input.endsAt ?? null, status: input.status ?? 'active', timesUsed: 0, createdAt: now(), updatedAt: now() }
357
+ this.discounts.push(d); return d
358
+ }
359
+
360
+ async updateDiscount(id: string, input: UpdateDiscountInput): Promise<Discount> {
361
+ const i = this.discounts.findIndex(d => d.id === id)
362
+ if (i < 0) throw new Error('Discount not found')
363
+ this.discounts[i] = { ...this.discounts[i], ...input, updatedAt: now() }
364
+ return this.discounts[i]
365
+ }
366
+
367
+ async deleteDiscount(id: string) { this.discounts = this.discounts.filter(d => d.id !== id) }
368
+ }
369
+
370
+ export function createMockShopProvider(seed?: MockShopSeed): ShopProvider {
371
+ return new MockShopProvider(seed)
372
+ }
package/src/mock.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { MockShopProvider, createMockShopProvider } from './mock-provider'
2
+ export type { MockShopSeed } from './mock-provider'