@stacksjs/defaults 0.75.38 → 0.75.41

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 (110) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +24 -1
  2. package/ai/skills/stacks-commerce/SKILL.md +1 -1
  3. package/ai/skills/stacks-config/SKILL.md +1 -1
  4. package/ai/skills/stacks-database/SKILL.md +1 -1
  5. package/ai/skills/stacks-dependencies/SKILL.md +35 -3
  6. package/ai/skills/stacks-desktop/SKILL.md +12 -0
  7. package/ai/skills/stacks-env/SKILL.md +4 -1
  8. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  9. package/ai/skills/stacks-models/SKILL.md +2 -2
  10. package/app/Actions/Commerce/Product/ProductStoreAction.ts +9 -1
  11. package/app/Actions/Commerce/Product/ProductUpdateAction.ts +4 -1
  12. package/app/Actions/Commerce/commerce-action.test.ts +30 -1
  13. package/app/Actions/Commerce/commerce-action.ts +44 -0
  14. package/app/Actions/Dashboard/Analytics/sales-analytics.test.ts +19 -0
  15. package/app/Actions/Dashboard/Analytics/sales-analytics.ts +2 -1
  16. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +3 -2
  17. package/app/Actions/Dashboard/Commerce/commerce-dashboard.test.ts +29 -9
  18. package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +5 -12
  19. package/app/Actions/Dashboard/Commerce/commerce-delivery.test.ts +15 -0
  20. package/app/Actions/Dashboard/Commerce/commerce-delivery.ts +8 -10
  21. package/app/Actions/Dashboard/Commerce/commerce-pos.test.ts +26 -6
  22. package/app/Actions/Dashboard/Commerce/commerce-pos.ts +8 -3
  23. package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +69 -0
  24. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +39 -0
  25. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +9 -3
  26. package/app/Actions/Dashboard/Marketing/campaign-records.ts +17 -3
  27. package/app/Actions/Payment/CreatePaymentIntentAction.ts +1 -1
  28. package/app/Actions/Payment/CreateSetupIntentAction.ts +1 -1
  29. package/app/Actions/Storefront/AddToCartAction.ts +4 -1
  30. package/app/Actions/Storefront/GetCartAction.ts +25 -10
  31. package/app/Actions/Storefront/PlaceOrderAction.ts +6 -1
  32. package/app/Actions/Storefront/_shipping.ts +63 -37
  33. package/app/Broadcasts/OrderShipped.ts +1 -1
  34. package/app/Mail/OrderConfirmation.ts +14 -7
  35. package/app/Models/Campaign.ts +9 -4
  36. package/app/Models/commerce/Cart.ts +5 -4
  37. package/app/Models/commerce/CartItem.ts +6 -4
  38. package/app/Models/commerce/Coupon.ts +9 -3
  39. package/app/Models/commerce/Customer.ts +2 -1
  40. package/app/Models/commerce/GiftCard.ts +5 -2
  41. package/app/Models/commerce/Order.ts +6 -5
  42. package/app/Models/commerce/OrderItem.ts +2 -1
  43. package/app/Models/commerce/ProductVariant.ts +71 -4
  44. package/app/Models/commerce/Transaction.ts +3 -2
  45. package/app/Storefront/StorefrontMoney.ts +54 -0
  46. package/ide/vscode/.vscode/settings.json +1 -1
  47. package/ide/vscode/README.md +65 -20
  48. package/ide/vscode/package.json +102 -12
  49. package/ide/vscode/src/extension.ts +59 -0
  50. package/ide/vscode/src/project.ts +440 -0
  51. package/ide/vscode/src/stacks.ts +475 -0
  52. package/ide/vscode/src/vscode.d.ts +181 -0
  53. package/ide/vscode/tests/extension.test.ts +420 -0
  54. package/ide/vscode/tests/project.test.ts +268 -0
  55. package/ide/vscode/tsconfig.json +19 -0
  56. package/package.json +4 -4
  57. package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +8 -8
  58. package/resources/components/Dashboard/Analytics/SalesAnalytics.stx +4 -3
  59. package/resources/components/Dashboard/Billing/BillingPlanSummary.stx +4 -1
  60. package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +4 -1
  61. package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +34 -10
  62. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +3 -1
  63. package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +4 -1
  64. package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +2 -6
  65. package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +6 -6
  66. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +3 -11
  67. package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +2 -6
  68. package/resources/components/Dashboard/Commerce/CommerceGiftCardBreakdown.stx +2 -6
  69. package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +2 -6
  70. package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +20 -17
  71. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +3 -2
  72. package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +2 -6
  73. package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +2 -6
  74. package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +28 -20
  75. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +7 -15
  76. package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +2 -6
  77. package/resources/components/Dashboard/Commerce/CommercePaymentsBreakdown.stx +2 -6
  78. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +2 -6
  79. package/resources/components/Dashboard/Commerce/CommercePosCart.stx +2 -6
  80. package/resources/components/Dashboard/Commerce/CommercePosCatalog.stx +2 -6
  81. package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +2 -6
  82. package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +5 -7
  83. package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +2 -8
  84. package/resources/components/Dashboard/Commerce/CommerceProductDetailDashboard.stx +3 -20
  85. package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +27 -19
  86. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +4 -15
  87. package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +2 -6
  88. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +12 -10
  89. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +3 -4
  90. package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +7 -5
  91. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +3 -2
  92. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +3 -4
  93. package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +2 -6
  94. package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +12 -10
  95. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +3 -10
  96. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +3 -8
  97. package/resources/components/Dashboard/Marketing/CampaignDialog.stx +26 -8
  98. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +4 -2
  99. package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +25 -13
  100. package/resources/components/Dashboard/Transaction/index.stx +6 -3
  101. package/resources/components/Storefront/CartDrawer.stx +11 -7
  102. package/resources/views/cart.stx +19 -23
  103. package/resources/views/checkout/contact.stx +12 -14
  104. package/resources/views/checkout/payment.stx +13 -13
  105. package/resources/views/checkout/shipping.stx +13 -13
  106. package/resources/views/cms/blocks/form.stx +50 -1
  107. package/resources/views/orders/[id].stx +13 -4
  108. package/vcs/github/workflows/buddy-bot.yml +5 -5
  109. package/vcs/github/workflows/ci.yml +3 -3
  110. package/vcs/github/workflows/release.yml +1 -1
@@ -1,4 +1,5 @@
1
1
  <script client>
2
+ import { minorToInput, moneyInputError, parseMoneyInput } from '@stacksjs/commerce/money'
2
3
  interface ShippingResourceOption {
3
4
  id: number
4
5
  name: string
@@ -42,7 +43,7 @@ effect(() => {
42
43
  zoneId.set(current ? String(current.shipping_zone_id) : '')
43
44
  weightFrom.set(current ? String(current.weight_from) : '')
44
45
  weightTo.set(current ? String(current.weight_to) : '')
45
- amount.set(current ? (current.rate / 100).toFixed(2) : '')
46
+ amount.set(current ? minorToInput(current.rate, 'USD') : '')
46
47
  })
47
48
 
48
49
  function submit(): void {
@@ -50,7 +51,6 @@ function submit(): void {
50
51
  const selectedZoneId = Number(zoneId())
51
52
  const from = Number(weightFrom())
52
53
  const to = Number(weightTo())
53
- const rate = Number(amount())
54
54
 
55
55
  if (!Number.isInteger(selectedMethodId) || selectedMethodId < 1) {
56
56
  emit('invalid', 'Select a shipping method.')
@@ -68,8 +68,10 @@ function submit(): void {
68
68
  emit('invalid', 'Ending weight must be greater than or equal to the starting weight.')
69
69
  return
70
70
  }
71
- if (!Number.isFinite(rate) || rate < 0) {
72
- emit('invalid', 'Rate must be a non-negative amount.')
71
+ // Typed in dollars, stored as integer cents.
72
+ const rateError = moneyInputError(amount(), 'USD')
73
+ if (rateError) {
74
+ emit('invalid', `Rate: ${rateError}`)
73
75
  return
74
76
  }
75
77
 
@@ -78,7 +80,7 @@ function submit(): void {
78
80
  shippingZoneId: selectedZoneId,
79
81
  weightFrom: from,
80
82
  weightTo: to,
81
- rate: Math.round(rate * 100),
83
+ rate: parseMoneyInput(amount(), 'USD'),
82
84
  })
83
85
  }
84
86
  </script>
@@ -1,6 +1,7 @@
1
1
  <script client>
2
2
  import { dashboardApi } from '../../../../../functions/dashboard-api'
3
3
  import { pushToast } from '../../../../../functions/toasts'
4
+ import { formatCurrency, minorToInput } from '@stacksjs/commerce/money'
4
5
 
5
6
  interface ShippingResourceOption {
6
7
  id: number
@@ -69,7 +70,7 @@ function filteredRecords(): ShippingRateRecord[] {
69
70
  record.shipping_zone?.name,
70
71
  record.weight_from,
71
72
  record.weight_to,
72
- record.rate / 100,
73
+ minorToInput(record.rate, 'USD'),
73
74
  ].some(value => String(value ?? '').toLowerCase().includes(query)))
74
75
  }
75
76
 
@@ -86,7 +87,7 @@ function averageRate(): string {
86
87
  if (!records().length)
87
88
  return '$0.00'
88
89
  const average = records().reduce((sum, record) => sum + record.rate, 0) / records().length
89
- return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(average / 100)
90
+ return formatCurrency(average, 'USD', 'en-US')
90
91
  }
91
92
 
92
93
  function metricCards(): Array<{ label: string, value: string, detail: string }> {
@@ -1,4 +1,5 @@
1
1
  <script client>
2
+ import { formatCurrency as formatMinorUnits } from '@stacksjs/commerce/money'
2
3
  interface ResourceSummary {
3
4
  id: number
4
5
  name: string
@@ -18,11 +19,9 @@ interface ShippingRateRow {
18
19
  const rates = useReactiveProp('rates', [] as ShippingRateRow[])
19
20
  const emit = defineEmits()
20
21
 
22
+ // Shipping amounts are stored as integer cents, in USD.
21
23
  function formatCurrency(cents: number): string {
22
- return new Intl.NumberFormat('en-US', {
23
- style: 'currency',
24
- currency: 'USD',
25
- }).format(cents / 100)
24
+ return formatMinorUnits(cents, 'USD', 'en-US')
26
25
  }
27
26
 
28
27
  function formatWeight(weight: number): string {
@@ -1,6 +1,7 @@
1
1
  <script client>
2
2
  import type { PaymentRecord } from '../../../../app/Actions/Dashboard/Commerce/payment-records'
3
3
  import { isRefundablePayment } from '../../../../app/Actions/Dashboard/Commerce/payment-records'
4
+ import { formatCurrency } from '@stacksjs/commerce/money'
4
5
 
5
6
  const open = useReactiveProp('open', false)
6
7
  const record = useReactiveProp('record', null as PaymentRecord | null)
@@ -11,12 +12,7 @@ function label(value: string): string {
11
12
  }
12
13
 
13
14
  function money(value: number, currency: string): string {
14
- try {
15
- return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value / 100)
16
- }
17
- catch {
18
- return `${currency} ${(value / 100).toFixed(2)}`
19
- }
15
+ return formatCurrency(value, currency)
20
16
  }
21
17
 
22
18
  function detailRows(): Array<{ label: string, value: string }> {
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { PaymentRecord } from '../../../../app/Actions/Dashboard/Commerce/payment-records'
3
+ import { formatCurrency, minorToInput, moneyInputError, moneyInputStep, parseMoneyInput } from '@stacksjs/commerce/money'
3
4
 
4
5
  const open = useReactiveProp('open', false)
5
6
  const record = useReactiveProp('record', null as PaymentRecord | null)
@@ -18,7 +19,7 @@ effect(() => {
18
19
  if (!payment || hydrationKey === key)
19
20
  return
20
21
  hydrationKey = key
21
- amount.set(((payment.amount - payment.refundAmount) / 100).toFixed(2))
22
+ amount.set(minorToInput(payment.amount - payment.refundAmount, payment.currency))
22
23
  })
23
24
 
24
25
  function remaining(): number {
@@ -26,9 +27,15 @@ function remaining(): number {
26
27
  return payment ? Math.max(0, payment.amount - payment.refundAmount) : 0
27
28
  }
28
29
 
30
+ function paymentCurrency(): string {
31
+ return record()?.currency || 'USD'
32
+ }
33
+
34
+ // What was typed, in integer minor units, or 0 while it is not a valid amount.
29
35
  function amountCents(): number {
30
- const parsed = Number(amount())
31
- return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0
36
+ if (moneyInputError(amount(), paymentCurrency(), { min: 1 }))
37
+ return 0
38
+ return parseMoneyInput(amount(), paymentCurrency(), { min: 1 })
32
39
  }
33
40
 
34
41
  function valid(): boolean {
@@ -37,12 +44,7 @@ function valid(): boolean {
37
44
  }
38
45
 
39
46
  function money(value: number, currency: string): string {
40
- try {
41
- return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value / 100)
42
- }
43
- catch {
44
- return `${currency} ${(value / 100).toFixed(2)}`
45
- }
47
+ return formatCurrency(value, currency)
46
48
  }
47
49
 
48
50
  function submit(): void {
@@ -59,7 +61,7 @@ function submit(): void {
59
61
  @close="emit('close')"
60
62
  >
61
63
  <form id="payment-refund-form" @submit.prevent="submit">
62
- <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Refund amount</span><input x-model="amount" type="number" min="0.01" :max="remaining() / 100" step="0.01" required class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" /></label>
64
+ <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Refund amount</span><input x-model="amount" type="number" :min="minorToInput(1, paymentCurrency())" :max="minorToInput(remaining(), paymentCurrency())" :step="moneyInputStep(paymentCurrency())" required class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" /></label>
63
65
  <p class="mt-2 text-amber-700 text-xs dark:text-amber-300">This records an already confirmed processor refund. It does not initiate money movement.</p>
64
66
  </form>
65
67
  <template #footer>
@@ -5,6 +5,7 @@ import type {
5
5
  AbandonedCartSummary,
6
6
  RecoveryCampaignRecord,
7
7
  } from '../../../../app/Actions/Dashboard/Marketing/abandoned-cart-records'
8
+ import { formatCurrency } from '@stacksjs/commerce/money'
8
9
  import { dashboardApi } from '../../../../functions/dashboard-api'
9
10
  import { pushToast } from '../../../../functions/toasts'
10
11
 
@@ -88,17 +89,9 @@ effect(() => {
88
89
  page.set(1)
89
90
  })
90
91
 
92
+ /** Cart totals and campaign thresholds are integer minor units (stacksjs/stacks#2851). */
91
93
  function money(value: number): string {
92
- try {
93
- return new Intl.NumberFormat(undefined, {
94
- style: 'currency',
95
- currency: summary().currency || defaultCurrency() || 'USD',
96
- maximumFractionDigits: 0,
97
- }).format(value)
98
- }
99
- catch {
100
- return `${Math.round(value)} ${defaultCurrency()}`
101
- }
94
+ return formatCurrency(value, summary().currency || defaultCurrency())
102
95
  }
103
96
 
104
97
  function percentage(value: number): string {
@@ -1,18 +1,13 @@
1
1
  <script client>
2
2
  import type { AbandonedCartRecord } from '../../../../app/Actions/Dashboard/Marketing/abandoned-cart-records'
3
+ import { formatCurrency } from '@stacksjs/commerce/money'
3
4
 
4
5
  const records = useReactiveProp('records', [] as AbandonedCartRecord[])
5
6
  const loading = useReactiveProp('loading', false)
6
7
 
8
+ /** A cart's total is integer minor units, like every commerce amount (stacksjs/stacks#2851). */
7
9
  function money(value: number, currency: string): string {
8
- try {
9
- return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD' }).format(value)
10
- }
11
- catch {
12
- // A currency the browser does not know is not a reason to render nothing
13
- // where a number belongs.
14
- return `${value.toFixed(2)} ${currency}`
15
- }
10
+ return formatCurrency(value, currency)
16
11
  }
17
12
 
18
13
  /**
@@ -5,6 +5,7 @@ import type {
5
5
  CampaignStatus,
6
6
  CampaignType,
7
7
  } from '../../../../app/Actions/Dashboard/Marketing/campaign-records'
8
+ import { minorToInput, moneyInputError, moneyInputStep, parseMoneyInput } from '@stacksjs/commerce/money'
8
9
 
9
10
  const open = useReactiveProp('open', false)
10
11
  const mode = useReactiveProp('mode', 'add')
@@ -25,6 +26,9 @@ const fromName = state('')
25
26
  const fromAddress = state('')
26
27
  const emailListId = state('')
27
28
  const scheduledAt = state('')
29
+ // Budget and spend as typed, in major units of `currency`. The record and
30
+ // the API hold integer minor units (1999 is $19.99), converted at this
31
+ // boundary through @stacksjs/commerce/money.
28
32
  const budget = state('')
29
33
  const spent = state('0')
30
34
  const currency = state('USD')
@@ -33,6 +37,8 @@ const endDate = state('')
33
37
  const canSubmit = derived(() => {
34
38
  if (!name().trim())
35
39
  return false
40
+ if (budgetError() || spentError())
41
+ return false
36
42
  return type() !== 'email'
37
43
  || Boolean(subject().trim() && template().trim() && emailListId())
38
44
  })
@@ -67,13 +73,22 @@ effect(() => {
67
73
  fromAddress.set(current?.fromAddress || '')
68
74
  emailListId.set(current?.emailListId || '')
69
75
  scheduledAt.set(localDateTime(current?.scheduledAt || ''))
70
- budget.set(current?.budget ? String(current.budget) : '')
71
- spent.set(String(current?.spent || 0))
72
- currency.set(current?.currency || defaultCurrency())
76
+ const recordCurrency = current?.currency || defaultCurrency()
77
+ budget.set(current?.budget ? minorToInput(current.budget, recordCurrency) : '')
78
+ spent.set(minorToInput(current?.spent || 0, recordCurrency))
79
+ currency.set(recordCurrency)
73
80
  startDate.set(localDateTime(current?.startDate || ''))
74
81
  endDate.set(localDateTime(current?.endDate || ''))
75
82
  })
76
83
 
84
+ function budgetError(): string {
85
+ return moneyInputError(budget(), currency(), { required: false })
86
+ }
87
+
88
+ function spentError(): string {
89
+ return moneyInputError(spent(), currency(), { required: false })
90
+ }
91
+
77
92
  function normalizeType(event: Event): void {
78
93
  const nextType = (event.target as HTMLSelectElement).value as CampaignType
79
94
  if (nextType === 'email')
@@ -85,6 +100,7 @@ function normalizeType(event: Event): void {
85
100
  function submit(): void {
86
101
  if (!canSubmit())
87
102
  return
103
+ const code = currency().trim().toUpperCase()
88
104
  emit('submit', {
89
105
  name: name().trim(),
90
106
  description: description().trim(),
@@ -97,9 +113,9 @@ function submit(): void {
97
113
  fromAddress: fromAddress().trim(),
98
114
  emailListId: emailListId() || null,
99
115
  scheduledAt: databaseDateTime(scheduledAt()) || null,
100
- budget: budget(),
101
- spent: spent(),
102
- currency: currency().trim().toUpperCase(),
116
+ budget: parseMoneyInput(budget(), code, { required: false }),
117
+ spent: parseMoneyInput(spent(), code, { required: false }) ?? 0,
118
+ currency: code,
103
119
  startDate: databaseDateTime(startDate()),
104
120
  endDate: databaseDateTime(endDate()),
105
121
  })
@@ -200,12 +216,14 @@ function submit(): void {
200
216
  </label>
201
217
  <label class="block">
202
218
  <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Budget</span>
203
- <input x-model="budget" type="number" min="0" step="0.01" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
219
+ <input x-model="budget" type="number" min="0" :step="moneyInputStep(currency())" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
220
+ <span :if="budgetError()" class="block mt-1 text-red-600 text-xs dark:text-red-400">{{ budgetError() }}</span>
204
221
  </label>
205
222
  <div class="grid gap-3 grid-cols-[1fr_7rem]">
206
223
  <label class="block">
207
224
  <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Spent</span>
208
- <input x-model="spent" type="number" min="0" step="0.01" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
225
+ <input x-model="spent" type="number" min="0" :step="moneyInputStep(currency())" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
226
+ <span :if="spentError()" class="block mt-1 text-red-600 text-xs dark:text-red-400">{{ spentError() }}</span>
209
227
  </label>
210
228
  <label class="block">
211
229
  <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Currency</span>
@@ -27,8 +27,10 @@ interface CampaignPayload {
27
27
  fromAddress: string
28
28
  emailListId: string | null
29
29
  scheduledAt: string | null
30
- budget: string
31
- spent: string
30
+ /** Integer minor units of `currency`, or null for no budget. */
31
+ budget: number | null
32
+ /** Integer minor units of `currency`. */
33
+ spent: number
32
34
  currency: string
33
35
  startDate: string
34
36
  endDate: string
@@ -1,6 +1,7 @@
1
1
  <script client>
2
2
  import type { AbandonedCartRecord } from '../../../../app/Actions/Dashboard/Marketing/abandoned-cart-records'
3
3
  import { reachOf } from '../../../../app/Actions/Dashboard/Marketing/abandoned-cart-records'
4
+ import { formatCurrency, moneyInputError, moneyInputStep, parseMoneyInput } from '@stacksjs/commerce/money'
4
5
 
5
6
  const open = useReactiveProp('open', false)
6
7
  const records = useReactiveProp('records', [] as AbandonedCartRecord[])
@@ -16,7 +17,9 @@ const text = state('')
16
17
  const fromName = state('')
17
18
  const fromAddress = state('')
18
19
  const idleHours = state(4)
19
- const minimumValue = state(0)
20
+ // The threshold as typed, in major units. Carts and the stored rule hold
21
+ // integer minor units (stacksjs/stacks#2851).
22
+ const minimumValue = state('0')
20
23
  const scheduledAt = state('')
21
24
  let hydrated = false
22
25
 
@@ -34,7 +37,7 @@ effect(() => {
34
37
  fromName.set('')
35
38
  fromAddress.set('')
36
39
  idleHours.set(defaultIdleHours())
37
- minimumValue.set(0)
40
+ minimumValue.set('0')
38
41
  scheduledAt.set('')
39
42
  })
40
43
 
@@ -46,22 +49,30 @@ effect(() => {
46
49
  * still choosing it, not after they have scheduled a send to an empty room.
47
50
  */
48
51
  function reach(): { carts: number, value: number } {
49
- return reachOf(records(), Number(idleHours()) || 0, Number(minimumValue()) || 0)
52
+ return reachOf(records(), Number(idleHours()) || 0, minimumMinor())
53
+ }
54
+
55
+ function minimumError(): string {
56
+ return moneyInputError(minimumValue(), defaultCurrency(), { required: false })
57
+ }
58
+
59
+ /** The typed threshold in minor units, or 0 while it is empty or unreadable. */
60
+ function minimumMinor(): number {
61
+ if (minimumError())
62
+ return 0
63
+ return parseMoneyInput(minimumValue(), defaultCurrency(), { required: false }) ?? 0
50
64
  }
51
65
 
52
66
  function money(value: number): string {
53
- try {
54
- return new Intl.NumberFormat(undefined, { style: 'currency', currency: defaultCurrency() || 'USD' }).format(value)
55
- }
56
- catch {
57
- return `${value.toFixed(2)} ${defaultCurrency()}`
58
- }
67
+ return formatCurrency(value, defaultCurrency())
59
68
  }
60
69
 
61
70
  function submit(): void {
62
71
  const when = scheduledAt() ? new Date(scheduledAt()) : null
63
72
  if (when && !Number.isFinite(when.getTime()))
64
73
  return
74
+ if (minimumError())
75
+ return
65
76
 
66
77
  emit('submit', {
67
78
  name: name(),
@@ -70,7 +81,7 @@ function submit(): void {
70
81
  fromName: fromName(),
71
82
  fromAddress: fromAddress(),
72
83
  idleHours: Number(idleHours()) || 0,
73
- minimumValue: Number(minimumValue()) || 0,
84
+ minimumValue: minimumMinor(),
74
85
  scheduledAt: when ? when.toISOString() : null,
75
86
  currency: defaultCurrency(),
76
87
  })
@@ -101,8 +112,9 @@ function submit(): void {
101
112
  </label>
102
113
  <label class="block">
103
114
  <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Minimum cart value</span>
104
- <input x-model="minimumValue" type="number" min="0" step="1" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
105
- <span class="block mt-1 text-gray-500 text-xs dark:text-neutral-400">Leave at zero to chase every cart.</span>
115
+ <input x-model="minimumValue" type="number" min="0" :step="moneyInputStep(defaultCurrency())" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
116
+ <span :if="minimumError()" class="block mt-1 text-red-600 text-xs dark:text-red-400">{{ minimumError() }}</span>
117
+ <span :if="!minimumError()" class="block mt-1 text-gray-500 text-xs dark:text-neutral-400">Leave at zero to chase every cart.</span>
106
118
  </label>
107
119
  </div>
108
120
 
@@ -139,7 +151,7 @@ function submit(): void {
139
151
  <template #footer>
140
152
  <div class="flex gap-2 justify-end w-full">
141
153
  <Button :disabled="busy()" variant="secondary" @click="emit('close')">Close</Button>
142
- <Button :disabled="busy() || !name() || !subject()" :loading="busy()" @click="submit()">{{ scheduledAt() ? 'Schedule campaign' : 'Save as draft' }}</Button>
154
+ <Button :disabled="busy() || !name() || !subject() || !!minimumError()" :loading="busy()" @click="submit()">{{ scheduledAt() ? 'Schedule campaign' : 'Save as draft' }}</Button>
143
155
  </div>
144
156
  </template>
145
157
  </Modal>
@@ -1,4 +1,6 @@
1
1
  <script server>
2
+ import { formatCurrency } from '@stacksjs/commerce/money'
3
+
2
4
  interface indexProps {
3
5
  transactionHistory?: any[]
4
6
  }
@@ -15,8 +17,9 @@ function formatTimestampDate(timestamp: number) {
15
17
  return lineData?.[0]?.plan?.interval || ''
16
18
  }
17
19
 
18
- function getUnitPrice(lineData: Array<{ price?: { unit_amount?: number | string } }> | undefined) {
19
- return (Number(lineData?.[0]?.price?.unit_amount || 0) / 100).toFixed(2)
20
+ // Stripe reports `unit_amount` in the currency's minor unit.
21
+ function getUnitPrice(lineData: Array<{ price?: { unit_amount?: number | string, currency?: string } }> | undefined) {
22
+ return formatCurrency(lineData?.[0]?.price?.unit_amount || 0, lineData?.[0]?.price?.currency || 'USD')
20
23
  }
21
24
 
22
25
  function getLastDigits(paymentIntent: { payment_method?: { card?: { last4?: string } } } | undefined) {
@@ -66,7 +69,7 @@ function formatTimestampDate(timestamp: number) {
66
69
  {{ formatTimestampDate(transaction.created_at) }}
67
70
  </td>
68
71
  <td class="px-3 py-4 text-gray-500 text-sm whitespace-nowrap">
69
- ${{ getUnitPrice(transaction.unit_price) }}
72
+ {{ getUnitPrice(transaction.unit_price) }}
70
73
  </td>
71
74
  <td class="flex items-center px-3 py-4 space-x-2 text-gray-500 text-sm whitespace-nowrap">
72
75
  <slot name="cardBrand" />
@@ -143,7 +143,11 @@
143
143
  return String(s ?? '').replace(/[&<>"']/g, c => entities[c] ?? c)
144
144
  }
145
145
 
146
- const render = (items: Array<{ slug?: string, name?: string, image?: string, qty?: number | string, price?: number | string, lineTotal?: number | string }> | null | undefined, subtotal: number | string) => {
146
+ // Amounts arrive from /api/cart already formatted (`priceLabel`,
147
+ // `lineTotalLabel`, `subtotalLabel`): the stored values are integer minor
148
+ // units of the cart's currency, and only the server formats them, through
149
+ // @stacksjs/commerce/money.
150
+ const render = (items: Array<{ slug?: string, name?: string, image?: string, qty?: number | string, priceLabel?: string, lineTotalLabel?: string }> | null | undefined, subtotalLabel: string) => {
147
151
  if (!list || !empty || !footer) return
148
152
  if (!items || items.length === 0) {
149
153
  list.classList.add('hidden')
@@ -159,8 +163,8 @@
159
163
  const name = escapeHtml(it.name || '')
160
164
  const image = escapeHtml(it.image || '')
161
165
  const qty = Number(it.qty) || 0
162
- const price = Number(it.price) || 0
163
- const lineTotal = Number(it.lineTotal) || 0
166
+ const priceLabel = escapeHtml(it.priceLabel || '')
167
+ const lineTotalLabel = escapeHtml(it.lineTotalLabel || '')
164
168
  return [
165
169
  '<li class="flex gap-3 items-start">',
166
170
  ' <a href="/products/' + slug + '" class="block overflow-hidden flex-shrink-0 h-16 w-16 bg-stone-200 rounded-xl">',
@@ -168,13 +172,13 @@
168
172
  ' </a>',
169
173
  ' <div class="flex-1 min-w-0">',
170
174
  ' <p class="font-semibold text-sm text-stone-900 truncate">' + name + '</p>',
171
- ' <p class="mt-0.5 text-stone-500 text-xs">Qty ' + qty + ' · $' + price.toFixed(2) + '</p>',
175
+ ' <p class="mt-0.5 text-stone-500 text-xs">Qty ' + qty + ' · ' + priceLabel + '</p>',
172
176
  ' </div>',
173
- ' <p class="font-semibold text-sm text-stone-900">$' + lineTotal.toFixed(2) + '</p>',
177
+ ' <p class="font-semibold text-sm text-stone-900">' + lineTotalLabel + '</p>',
174
178
  '</li>',
175
179
  ].join('')
176
180
  }).join('')
177
- if (subtotalEl) subtotalEl.textContent = '$' + (Number(subtotal) || 0).toFixed(2)
181
+ if (subtotalEl) subtotalEl.textContent = subtotalLabel
178
182
  }
179
183
 
180
184
  let inflightRefresh: Promise<void> | null = null
@@ -193,7 +197,7 @@
193
197
  })
194
198
  if (!res.ok) return
195
199
  const data = await res.json()
196
- render(data.items || [], data.subtotal || 0)
200
+ render(data.items || [], data.subtotalLabel || '')
197
201
  const badge = document.querySelector('[data-stx-cart-count]')
198
202
  if (badge) {
199
203
  const total = (data.items || []).reduce((sum: number, item: { qty?: number | string }) => sum + (Number(item.qty) || 0), 0)
@@ -15,15 +15,17 @@ interface CartItemRow {
15
15
  weight_grams: number | null
16
16
  }
17
17
 
18
- // The column of `carts` this page reads. The query selects `*`.
18
+ // The columns of `carts` this page reads. The query selects `*`.
19
19
  interface CartRow {
20
20
  id: number
21
+ currency: string | null
21
22
  }
22
23
 
23
24
  interface StorefrontLineItem { lineTotal: number }
24
25
 
25
26
  const { verifyCartCookie } = require('../../app/Storefront/CartCookie')
26
27
  const { readStorefront } = require('../../app/Storefront/StorefrontDatabase')
28
+ const { storefrontCurrency, storefrontMoney, storefrontSummary } = require('../../app/Storefront/StorefrontMoney')
27
29
 
28
30
  // Signed by the storefront actions as the token, a dot and its HMAC. An
29
31
  // unsigned or tampered cookie is no cart, and is never looked up.
@@ -35,6 +37,8 @@ const found = token
35
37
  if (!row)
36
38
  return { cart: null, items: [] }
37
39
 
40
+ // Every amount below is integer minor units of the cart's currency.
41
+ const currency = storefrontCurrency(row.currency)
38
42
  const lines = db.query<CartItemRow, [number]>(`
39
43
  SELECT ci.id, ci.quantity, ci.unit_price, ci.total_price, ci.product_name,
40
44
  ci.product_image, ci.product_sku, p.weight_grams
@@ -52,9 +56,9 @@ const found = token
52
56
  name: line.product_name,
53
57
  weight: line.weight_grams ? `${line.weight_grams}g` : '',
54
58
  qty,
55
- priceLabel: `$${price.toFixed(2)}`,
59
+ priceLabel: storefrontMoney(price, currency),
56
60
  lineTotal,
57
- lineTotalLabel: `$${lineTotal.toFixed(2)}`,
61
+ lineTotalLabel: storefrontMoney(lineTotal, currency),
58
62
  image: line.product_image,
59
63
  }
60
64
  })
@@ -66,27 +70,19 @@ const found = token
66
70
  const cart = found.cart
67
71
  const items = found.items
68
72
 
69
- // Mirror the rule used by PlaceOrderAction so the price the shopper
70
- // sees in their cart matches the total they're actually charged. Both
71
- // sides read STOREFRONT_FREE_SHIPPING_THRESHOLD / STOREFRONT_FLAT_SHIPPING
72
- // from the environment with the same defaults — keep them in sync via
73
- // app/Actions/Storefront/_shipping.ts when changing.
74
- const FREE_SHIPPING_THRESHOLD = Number(process.env.STOREFRONT_FREE_SHIPPING_THRESHOLD ?? 40) || 40
75
- const FLAT_SHIPPING = Number(process.env.STOREFRONT_FLAT_SHIPPING ?? 5) || 5
73
+ // The same shipping rule PlaceOrderAction charges with
74
+ // (app/Actions/Storefront/_shipping.ts), in minor units, so the price the
75
+ // shopper sees in their cart matches the total they're actually charged.
76
76
  const subtotal = items.reduce((sum: number, i: StorefrontLineItem) => sum + i.lineTotal, 0)
77
- const shipping = subtotal <= 0 ? 0 : (subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : FLAT_SHIPPING)
78
- const total = subtotal + shipping
79
- const remainingForFree = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal)
80
-
81
- // Pre-format every numeric so the OrderSummary component template doesn't
82
- // have to call .toFixed() on a defineProps local — STX's SSR pass doesn't
83
- // reliably expose those locals, so callers do the formatting.
84
- const subtotalLabel = `$${subtotal.toFixed(2)}`
85
- const shippingLabel = shipping === 0 ? 'Free' : `$${shipping.toFixed(2)}`
86
- const totalLabel = `$${total.toFixed(2)}`
87
- const freeShippingNote = (shipping > 0 && remainingForFree > 0)
88
- ? `Add $${remainingForFree.toFixed(2)} more for free shipping.`
89
- : ''
77
+
78
+ // Pre-format every amount so the OrderSummary component template doesn't
79
+ // have to format a defineProps local — STX's SSR pass doesn't reliably
80
+ // expose those locals, so callers do the formatting.
81
+ const summary = storefrontSummary(subtotal, storefrontCurrency(cart?.currency))
82
+ const subtotalLabel = summary.subtotalLabel
83
+ const shippingLabel = summary.shippingLabel
84
+ const totalLabel = summary.totalLabel
85
+ const freeShippingNote = summary.freeShippingNote
90
86
  </script>
91
87
 
92
88
  @section('content')
@@ -14,6 +14,7 @@ interface CartSummaryRow {
14
14
  // migration creates any but `id` (see app/Storefront/StorefrontDatabase.ts).
15
15
  interface CartRow {
16
16
  id: number
17
+ currency: string | null
17
18
  email: string | null
18
19
  }
19
20
 
@@ -21,6 +22,7 @@ interface StorefrontLineItem { lineTotal: number }
21
22
 
22
23
  const { verifyCartCookie } = require('../../../app/Storefront/CartCookie')
23
24
  const { readStorefront } = require('../../../app/Storefront/StorefrontDatabase')
25
+ const { storefrontCurrency, storefrontMoney, storefrontSummary } = require('../../../app/Storefront/StorefrontMoney')
24
26
 
25
27
  // Signed by the storefront actions as the token, a dot and its HMAC. An
26
28
  // unsigned or tampered cookie is no cart, and is never looked up.
@@ -32,6 +34,8 @@ const found = token
32
34
  if (!row)
33
35
  return { cart: null, items: [] }
34
36
 
37
+ // Every amount below is integer minor units of the cart's currency.
38
+ const currency = storefrontCurrency(row.currency)
35
39
  const lines = db.query<CartSummaryRow, [number]>(`
36
40
  SELECT product_name, quantity, total_price
37
41
  FROM cart_items WHERE cart_id = ?1 ORDER BY id ASC
@@ -42,7 +46,7 @@ const found = token
42
46
  name: line.product_name,
43
47
  qty,
44
48
  lineTotal,
45
- lineTotalLabel: `$${lineTotal.toFixed(2)}`,
49
+ lineTotalLabel: storefrontMoney(lineTotal, currency),
46
50
  }
47
51
  })
48
52
 
@@ -53,21 +57,15 @@ const found = token
53
57
  const cart = found.cart
54
58
  const items = found.items
55
59
 
56
- // Mirror the rule in PlaceOrderAction's _shipping helper so the running
60
+ // The same shipping rule PlaceOrderAction charges with
61
+ // (app/Actions/Storefront/_shipping.ts), in minor units, so the running
57
62
  // totals on every checkout step match what gets charged.
58
- const FREE_SHIPPING_THRESHOLD = Number(process.env.STOREFRONT_FREE_SHIPPING_THRESHOLD ?? 40) || 40
59
- const FLAT_SHIPPING = Number(process.env.STOREFRONT_FLAT_SHIPPING ?? 5) || 5
60
63
  const subtotal = items.reduce((s: number, i: StorefrontLineItem) => s + i.lineTotal, 0)
61
- const shipping = subtotal <= 0 ? 0 : (subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : FLAT_SHIPPING)
62
- const total = subtotal + shipping
63
- const remainingForFree = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal)
64
-
65
- const subtotalLabel = `$${subtotal.toFixed(2)}`
66
- const shippingLabel = shipping === 0 ? 'Free' : `$${shipping.toFixed(2)}`
67
- const totalLabel = `$${total.toFixed(2)}`
68
- const freeShippingNote = (shipping > 0 && remainingForFree > 0)
69
- ? `Add $${remainingForFree.toFixed(2)} more for free shipping.`
70
- : ''
64
+ const summary = storefrontSummary(subtotal, storefrontCurrency(cart?.currency))
65
+ const subtotalLabel = summary.subtotalLabel
66
+ const shippingLabel = summary.shippingLabel
67
+ const totalLabel = summary.totalLabel
68
+ const freeShippingNote = summary.freeShippingNote
71
69
 
72
70
  const prefilledEmail = cart?.email || ''
73
71
  </script>