@fayz-ai/shop 0.8.3 → 0.8.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.
@@ -7,6 +7,7 @@ import type {
7
7
  CreateOrderInput, UpdateOrderInput, ListOrdersOptions, PlaceOrderInput,
8
8
  CreateCustomerInput, UpdateCustomerInput, ListCustomersOptions, ResolveCustomerInput,
9
9
  CreateDiscountInput, UpdateDiscountInput, ListDiscountsOptions,
10
+ SalesChannel, CreateSalesChannelInput, UpdateSalesChannelInput,
10
11
  } from './types'
11
12
  import { MOCK_PRODUCTS, MOCK_CATEGORIES, MOCK_DISCOUNTS } from './mock-catalog'
12
13
 
@@ -19,6 +20,11 @@ function now() { return new Date().toISOString() }
19
20
  const ORDERS_KEY = 'fayz.shop.mock.orders.v1'
20
21
  const CUSTOMERS_KEY = 'fayz.shop.mock.customers.v1'
21
22
  const ENQUIRIES_KEY = 'fayz.shop.mock.enquiries.v1'
23
+ const CHANNELS_KEY = 'fayz.shop.mock.channels.v1'
24
+
25
+ const DEFAULT_CHANNELS: SalesChannel[] = [
26
+ { id: 'online-store', tenantId: 'mock', kind: 'online_store', label: 'Loja Online', url: null, enabled: true, position: 0, createdAt: now(), updatedAt: now() },
27
+ ]
22
28
 
23
29
  function loadStored<T>(key: string): T[] {
24
30
  try {
@@ -52,6 +58,10 @@ export class MockShopProvider implements ShopProvider {
52
58
  private orders: Order[] = loadStored<Order>(ORDERS_KEY)
53
59
  private customers: ShopCustomer[] = loadStored<ShopCustomer>(CUSTOMERS_KEY)
54
60
  private enquiries: ProductEnquiry[] = loadStored<ProductEnquiry>(ENQUIRIES_KEY)
61
+ private channels: SalesChannel[] = (() => {
62
+ const stored = loadStored<SalesChannel>(CHANNELS_KEY)
63
+ return stored.length ? stored : DEFAULT_CHANNELS.map(c => ({ ...c }))
64
+ })()
55
65
 
56
66
  constructor(seed?: MockShopSeed) {
57
67
  this.products = (seed?.products ?? MOCK_PRODUCTS).map(p => ({ ...p }))
@@ -64,9 +74,14 @@ export class MockShopProvider implements ShopProvider {
64
74
  private syncOrders() { this.orders = loadStored<Order>(ORDERS_KEY) }
65
75
  private syncCustomers() { this.customers = loadStored<ShopCustomer>(CUSTOMERS_KEY) }
66
76
  private syncEnquiries() { this.enquiries = loadStored<ProductEnquiry>(ENQUIRIES_KEY) }
77
+ private syncChannels() {
78
+ const stored = loadStored<SalesChannel>(CHANNELS_KEY)
79
+ if (stored.length) this.channels = stored
80
+ }
67
81
  private persistOrders() { store(ORDERS_KEY, this.orders) }
68
82
  private persistCustomers() { store(CUSTOMERS_KEY, this.customers) }
69
83
  private persistEnquiries() { store(ENQUIRIES_KEY, this.enquiries) }
84
+ private persistChannels() { store(CHANNELS_KEY, this.channels) }
70
85
 
71
86
  // ---------------------------------------------------------------------------
72
87
  // Products
@@ -285,13 +300,17 @@ export class MockShopProvider implements ShopProvider {
285
300
 
286
301
  // Re-read price + guard stock from the catalog (never trust the client).
287
302
  const items: CreateOrderInput['items'] = []
303
+ // Per-line facts for coupon scoping (productId, categoryId, line total).
304
+ const lineDetails: Array<{ productId: string; categoryId: string | null; lineTotal: number }> = []
288
305
  let subtotal = 0
289
306
  for (const line of input.items) {
290
307
  const product = this.products.find(p => p.id === line.productId)
291
308
  if (!product || product.status !== 'active') throw new Error(`Product unavailable: ${line.productId}`)
292
309
  if (product.inventoryCount < line.quantity) throw new Error(`Insufficient stock for ${product.name}`)
293
310
  const name = line.optionsLabel ? `${product.name} (${line.optionsLabel})` : product.name
294
- subtotal += product.price * line.quantity
311
+ const lineTotal = product.price * line.quantity
312
+ subtotal += lineTotal
313
+ lineDetails.push({ productId: product.id, categoryId: product.categoryId, lineTotal })
295
314
  items.push({ productId: product.id, name, sku: product.sku ?? undefined, quantity: line.quantity, unitPrice: product.price, imageUrl: product.images[0]?.url })
296
315
  }
297
316
  subtotal = Math.round(subtotal * 100) / 100
@@ -304,11 +323,21 @@ export class MockShopProvider implements ShopProvider {
304
323
  const code = input.discountCode.trim().toLowerCase()
305
324
  const d = this.discounts.find(x => (x.code ?? '').toLowerCase() === code)
306
325
  const ts = now()
307
- const valid = !!d && d.status === 'active' && d.startsAt <= ts && (!d.endsAt || d.endsAt >= ts) && (d.usageLimit == null || d.timesUsed < d.usageLimit)
326
+ const meetsMin = !d?.minSubtotal || subtotal >= d.minSubtotal
327
+ const valid = !!d && d.status === 'active' && d.startsAt <= ts && (!d.endsAt || d.endsAt >= ts) && (d.usageLimit == null || d.timesUsed < d.usageLimit) && meetsMin
308
328
  if (valid && d) {
309
329
  appliedCode = d.code
310
- if (d.type === 'percentage') discountTotal = Math.round(subtotal * d.value) / 100
311
- else if (d.type === 'fixed_amount') discountTotal = Math.min(d.value, subtotal)
330
+ // Eligible subtotal = the part of the cart the coupon is scoped to.
331
+ // applies_to='all' → whole subtotal (unchanged for legacy coupons).
332
+ let eligible = subtotal
333
+ if (d.appliesTo === 'products') {
334
+ eligible = lineDetails.filter(l => d.productIds.includes(l.productId)).reduce((s, l) => s + l.lineTotal, 0)
335
+ } else if (d.appliesTo === 'categories') {
336
+ eligible = lineDetails.filter(l => l.categoryId && d.categoryIds.includes(l.categoryId)).reduce((s, l) => s + l.lineTotal, 0)
337
+ }
338
+ eligible = Math.round(eligible * 100) / 100
339
+ if (d.type === 'percentage') discountTotal = Math.round(eligible * d.value) / 100
340
+ else if (d.type === 'fixed_amount') discountTotal = Math.min(d.value, eligible)
312
341
  else if (d.type === 'free_shipping') shippingTotal = 0
313
342
  // buy_x_get_y unsupported in v1: code recorded, no monetary effect
314
343
  d.timesUsed += 1
@@ -403,7 +432,7 @@ export class MockShopProvider implements ShopProvider {
403
432
  async getDiscount(id: string) { return this.discounts.find(d => d.id === id) ?? null }
404
433
 
405
434
  async createDiscount(input: CreateDiscountInput): Promise<Discount> {
406
- 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() }
435
+ 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, minSubtotal: input.minSubtotal ?? null, appliesTo: input.appliesTo ?? 'all', productIds: input.productIds ?? [], categoryIds: input.categoryIds ?? [], startsAt: input.startsAt ?? now(), endsAt: input.endsAt ?? null, status: input.status ?? 'active', timesUsed: 0, createdAt: now(), updatedAt: now() }
407
436
  this.discounts.push(d); return d
408
437
  }
409
438
 
@@ -415,6 +444,35 @@ export class MockShopProvider implements ShopProvider {
415
444
  }
416
445
 
417
446
  async deleteDiscount(id: string) { this.discounts = this.discounts.filter(d => d.id !== id) }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Sales channels
450
+ // ---------------------------------------------------------------------------
451
+
452
+ async listSalesChannels(): Promise<SalesChannel[]> {
453
+ this.syncChannels()
454
+ return [...this.channels].sort((a, b) => a.position - b.position)
455
+ }
456
+
457
+ async createSalesChannel(input: CreateSalesChannelInput): Promise<SalesChannel> {
458
+ this.syncChannels()
459
+ const c: SalesChannel = { id: uid(), tenantId: 'mock', kind: input.kind, label: input.label, url: input.url ?? null, enabled: input.enabled ?? true, position: input.position ?? this.channels.length, createdAt: now(), updatedAt: now() }
460
+ this.channels.push(c); this.persistChannels(); return c
461
+ }
462
+
463
+ async updateSalesChannel(id: string, input: UpdateSalesChannelInput): Promise<SalesChannel> {
464
+ this.syncChannels()
465
+ const i = this.channels.findIndex(c => c.id === id)
466
+ if (i < 0) throw new Error('Sales channel not found')
467
+ this.channels[i] = { ...this.channels[i], ...input, updatedAt: now() }
468
+ this.persistChannels(); return this.channels[i]
469
+ }
470
+
471
+ async deleteSalesChannel(id: string): Promise<void> {
472
+ this.syncChannels()
473
+ this.channels = this.channels.filter(c => c.id !== id)
474
+ this.persistChannels()
475
+ }
418
476
  }
419
477
 
420
478
  export function createMockShopProvider(seed?: MockShopSeed): ShopProvider {
package/src/provider.ts CHANGED
@@ -9,6 +9,7 @@ import type {
9
9
  CustomerAddress, ShippingQuoteOption,
10
10
  ShippingZone, CreateShippingZoneInput, UpdateShippingZoneInput,
11
11
  CreateDiscountInput, UpdateDiscountInput, ListDiscountsOptions,
12
+ SalesChannel, CreateSalesChannelInput, UpdateSalesChannelInput,
12
13
  } from './types'
13
14
 
14
15
  export interface ShopProvider {
@@ -106,4 +107,11 @@ export interface ShopProvider {
106
107
  createDiscount(input: CreateDiscountInput): Promise<Discount>
107
108
  updateDiscount(id: string, input: UpdateDiscountInput): Promise<Discount>
108
109
  deleteDiscount(id: string): Promise<void>
110
+
111
+ // Sales channels — the store's points of sale (Loja Online, WhatsApp, …).
112
+ // Optional: a provider without them yields no "Canais de venda" section.
113
+ listSalesChannels?(): Promise<SalesChannel[]>
114
+ createSalesChannel?(input: CreateSalesChannelInput): Promise<SalesChannel>
115
+ updateSalesChannel?(id: string, input: UpdateSalesChannelInput): Promise<SalesChannel>
116
+ deleteSalesChannel?(id: string): Promise<void>
109
117
  }
@@ -10,6 +10,7 @@ import type {
10
10
  CreateOrderInput, UpdateOrderInput, ListOrdersOptions, PlaceOrderInput,
11
11
  CreateCustomerInput, UpdateCustomerInput, ListCustomersOptions, ResolveCustomerInput,
12
12
  CreateDiscountInput, UpdateDiscountInput, ListDiscountsOptions, OrderItem,
13
+ SalesChannel, CreateSalesChannelInput, UpdateSalesChannelInput,
13
14
  ShippingZone, CreateShippingZoneInput, UpdateShippingZoneInput } from './types'
14
15
 
15
16
  function getDb(): any {
@@ -195,6 +196,10 @@ function rowToDiscount(row: any): Discount {
195
196
  value: row.value ?? 0,
196
197
  usageLimit: row.usage_limit,
197
198
  oncePerCustomer: row.once_per_customer ?? false,
199
+ minSubtotal: row.min_subtotal ?? null,
200
+ appliesTo: row.applies_to ?? 'all',
201
+ productIds: row.product_ids ?? [],
202
+ categoryIds: row.category_ids ?? [],
198
203
  startsAt: row.starts_at,
199
204
  endsAt: row.ends_at,
200
205
  status: row.status ?? 'active',
@@ -204,6 +209,20 @@ function rowToDiscount(row: any): Discount {
204
209
  }
205
210
  }
206
211
 
212
+ function rowToSalesChannel(row: any): SalesChannel {
213
+ return {
214
+ id: row.id,
215
+ tenantId: row.tenant_id,
216
+ kind: row.kind,
217
+ label: row.label,
218
+ url: row.url,
219
+ enabled: row.enabled ?? true,
220
+ position: row.position ?? 0,
221
+ createdAt: row.created_at,
222
+ updatedAt: row.updated_at,
223
+ }
224
+ }
225
+
207
226
  export class SupabaseShopProvider implements ShopProvider {
208
227
  // ---------------------------------------------------------------------------
209
228
  // Products
@@ -660,7 +679,7 @@ export class SupabaseShopProvider implements ShopProvider {
660
679
  async createDiscount(input: CreateDiscountInput): Promise<Discount> {
661
680
  const { data, error } = await getDb()
662
681
  .from(T.discounts)
663
- .insert({ tenant_id: getTenantId(), title: input.title, code: input.code ?? null, type: input.type, method: input.method ?? 'code', value: input.value, usage_limit: input.usageLimit ?? null, once_per_customer: input.oncePerCustomer ?? false, starts_at: input.startsAt ?? new Date().toISOString(), ends_at: input.endsAt ?? null, status: input.status ?? 'active', times_used: 0 })
682
+ .insert({ tenant_id: getTenantId(), title: input.title, code: input.code ?? null, type: input.type, method: input.method ?? 'code', value: input.value, usage_limit: input.usageLimit ?? null, once_per_customer: input.oncePerCustomer ?? false, min_subtotal: input.minSubtotal ?? null, applies_to: input.appliesTo ?? 'all', product_ids: input.productIds ?? [], category_ids: input.categoryIds ?? [], starts_at: input.startsAt ?? new Date().toISOString(), ends_at: input.endsAt ?? null, status: input.status ?? 'active', times_used: 0 })
664
683
  .select().single()
665
684
  if (error) throw error
666
685
  return rowToDiscount(data)
@@ -673,6 +692,10 @@ export class SupabaseShopProvider implements ShopProvider {
673
692
  if (input.type !== undefined) updates.type = input.type
674
693
  if (input.value !== undefined) updates.value = input.value
675
694
  if (input.usageLimit !== undefined) updates.usage_limit = input.usageLimit
695
+ if (input.minSubtotal !== undefined) updates.min_subtotal = input.minSubtotal
696
+ if (input.appliesTo !== undefined) updates.applies_to = input.appliesTo
697
+ if (input.productIds !== undefined) updates.product_ids = input.productIds
698
+ if (input.categoryIds !== undefined) updates.category_ids = input.categoryIds
676
699
  if (input.status !== undefined) updates.status = input.status
677
700
  if (input.endsAt !== undefined) updates.ends_at = input.endsAt
678
701
  const { data, error } = await getDb().from(T.discounts).update(updates).eq('id', id).select().single()
@@ -684,6 +707,46 @@ export class SupabaseShopProvider implements ShopProvider {
684
707
  const { error } = await getDb().from(T.discounts).delete().eq('id', id)
685
708
  if (error) throw error
686
709
  }
710
+
711
+ // ---------------------------------------------------------------------------
712
+ // Sales channels
713
+ // ---------------------------------------------------------------------------
714
+
715
+ async listSalesChannels(): Promise<SalesChannel[]> {
716
+ const db = getDb()
717
+ const tenantId = getTenantId()
718
+ let q = db.from(T.salesChannels).select('*').order('position', { ascending: true })
719
+ if (tenantId) q = q.eq('tenant_id', tenantId)
720
+ const { data, error } = await q
721
+ if (error) throw error
722
+ return (data ?? []).map(rowToSalesChannel)
723
+ }
724
+
725
+ async createSalesChannel(input: CreateSalesChannelInput): Promise<SalesChannel> {
726
+ const { data, error } = await getDb()
727
+ .from(T.salesChannels)
728
+ .insert({ tenant_id: getTenantId(), kind: input.kind, label: input.label, url: input.url ?? null, enabled: input.enabled ?? true, position: input.position ?? 0 })
729
+ .select().single()
730
+ if (error) throw error
731
+ return rowToSalesChannel(data)
732
+ }
733
+
734
+ async updateSalesChannel(id: string, input: UpdateSalesChannelInput): Promise<SalesChannel> {
735
+ const updates: Record<string, unknown> = {}
736
+ if (input.kind !== undefined) updates.kind = input.kind
737
+ if (input.label !== undefined) updates.label = input.label
738
+ if (input.url !== undefined) updates.url = input.url
739
+ if (input.enabled !== undefined) updates.enabled = input.enabled
740
+ if (input.position !== undefined) updates.position = input.position
741
+ const { data, error } = await getDb().from(T.salesChannels).update(updates).eq('id', id).select().single()
742
+ if (error) throw error
743
+ return rowToSalesChannel(data)
744
+ }
745
+
746
+ async deleteSalesChannel(id: string): Promise<void> {
747
+ const { error } = await getDb().from(T.salesChannels).delete().eq('id', id)
748
+ if (error) throw error
749
+ }
687
750
  }
688
751
 
689
752
  export function createSupabaseShopProvider(): ShopProvider {
package/src/tables.ts CHANGED
@@ -9,6 +9,7 @@ export const T = {
9
9
  orderItems: 'plg_shop_order_items',
10
10
  customers: 'plg_shop_customers',
11
11
  discounts: 'plg_shop_discounts',
12
+ salesChannels: 'plg_shop_channels',
12
13
  // Core, not plg_shop_*: a delivery zone is a fact about the business, and
13
14
  // fulfilment and financial read it too. Same reasoning as addresses and
14
15
  // orders — see migration 0021.
package/src/types.ts CHANGED
@@ -453,6 +453,9 @@ export type DiscountType = 'percentage' | 'fixed_amount' | 'free_shipping' | 'bu
453
453
  export type DiscountMethod = 'code' | 'automatic'
454
454
  export type DiscountStatus = 'active' | 'expired' | 'scheduled' | 'draft'
455
455
 
456
+ /** What subset of the cart a discount applies to. 'all' = order-wide (default). */
457
+ export type DiscountScope = 'all' | 'products' | 'categories'
458
+
456
459
  export interface Discount {
457
460
  id: string
458
461
  tenantId: string
@@ -463,6 +466,15 @@ export interface Discount {
463
466
  value: number
464
467
  usageLimit: number | null
465
468
  oncePerCustomer: boolean
469
+ /** Minimum order subtotal for the coupon to apply (pedido mínimo). Null = none. */
470
+ minSubtotal: number | null
471
+ /** Scope of the discount. 'products'/'categories' restrict it to the eligible
472
+ * line items; the %/valor then applies to that eligible subtotal only. */
473
+ appliesTo: DiscountScope
474
+ /** When appliesTo='products', the eligible product ids. */
475
+ productIds: string[]
476
+ /** When appliesTo='categories', the eligible category ids. */
477
+ categoryIds: string[]
466
478
  startsAt: string
467
479
  endsAt: string | null
468
480
  status: DiscountStatus
@@ -479,6 +491,10 @@ export interface CreateDiscountInput {
479
491
  value: number
480
492
  usageLimit?: number
481
493
  oncePerCustomer?: boolean
494
+ minSubtotal?: number | null
495
+ appliesTo?: DiscountScope
496
+ productIds?: string[]
497
+ categoryIds?: string[]
482
498
  startsAt?: string
483
499
  endsAt?: string
484
500
  status?: DiscountStatus
@@ -492,6 +508,10 @@ export interface UpdateDiscountInput {
492
508
  value?: number
493
509
  usageLimit?: number | null
494
510
  oncePerCustomer?: boolean
511
+ minSubtotal?: number | null
512
+ appliesTo?: DiscountScope
513
+ productIds?: string[]
514
+ categoryIds?: string[]
495
515
  startsAt?: string
496
516
  endsAt?: string | null
497
517
  status?: DiscountStatus
@@ -505,6 +525,53 @@ export interface ListDiscountsOptions {
505
525
  offset?: number
506
526
  }
507
527
 
528
+ // ---------------------------------------------------------------------------
529
+ // Sales channels — the store's points of sale (Loja Online, WhatsApp, TikTok…).
530
+ // A channel is per-tenant (the store IS the tenant). The 'online_store' channel
531
+ // points at the merchant's fayz storefront; the rest are external links the
532
+ // merchant maintains. Surfaced as a "Canais de venda" group in the admin nav.
533
+ // ---------------------------------------------------------------------------
534
+
535
+ export type SalesChannelKind =
536
+ | 'online_store'
537
+ | 'whatsapp'
538
+ | 'instagram'
539
+ | 'tiktok_shop'
540
+ | 'marketplace'
541
+ | 'physical'
542
+ | 'custom'
543
+
544
+ export interface SalesChannel {
545
+ id: string
546
+ tenantId: string
547
+ kind: SalesChannelKind
548
+ label: string
549
+ /** External URL opened from the sidebar / channels page. Null while unconfigured
550
+ * (e.g. the Loja Online channel before the storefront address is set). */
551
+ url: string | null
552
+ enabled: boolean
553
+ /** Sidebar/list ordering (lower first). */
554
+ position: number
555
+ createdAt: string
556
+ updatedAt: string
557
+ }
558
+
559
+ export interface CreateSalesChannelInput {
560
+ kind: SalesChannelKind
561
+ label: string
562
+ url?: string | null
563
+ enabled?: boolean
564
+ position?: number
565
+ }
566
+
567
+ export interface UpdateSalesChannelInput {
568
+ kind?: SalesChannelKind
569
+ label?: string
570
+ url?: string | null
571
+ enabled?: boolean
572
+ position?: number
573
+ }
574
+
508
575
  // ---------------------------------------------------------------------------
509
576
  // Commerce primitives (FAY-1193) — provider-agnostic helpers for custom workflows
510
577
  // ---------------------------------------------------------------------------