@stacksjs/defaults 0.75.38 → 0.75.40

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 (98) 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-env/SKILL.md +4 -1
  7. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  8. package/ai/skills/stacks-models/SKILL.md +2 -2
  9. package/app/Actions/Commerce/Product/ProductStoreAction.ts +9 -1
  10. package/app/Actions/Commerce/Product/ProductUpdateAction.ts +4 -1
  11. package/app/Actions/Commerce/commerce-action.test.ts +30 -1
  12. package/app/Actions/Commerce/commerce-action.ts +44 -0
  13. package/app/Actions/Dashboard/Analytics/sales-analytics.test.ts +19 -0
  14. package/app/Actions/Dashboard/Analytics/sales-analytics.ts +2 -1
  15. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +3 -2
  16. package/app/Actions/Dashboard/Commerce/commerce-dashboard.test.ts +29 -9
  17. package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +5 -12
  18. package/app/Actions/Dashboard/Commerce/commerce-delivery.test.ts +15 -0
  19. package/app/Actions/Dashboard/Commerce/commerce-delivery.ts +8 -10
  20. package/app/Actions/Dashboard/Commerce/commerce-pos.test.ts +26 -6
  21. package/app/Actions/Dashboard/Commerce/commerce-pos.ts +8 -3
  22. package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +69 -0
  23. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +39 -0
  24. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +9 -3
  25. package/app/Actions/Dashboard/Marketing/campaign-records.ts +17 -3
  26. package/app/Actions/Storefront/AddToCartAction.ts +4 -1
  27. package/app/Actions/Storefront/GetCartAction.ts +25 -10
  28. package/app/Actions/Storefront/PlaceOrderAction.ts +6 -1
  29. package/app/Actions/Storefront/_shipping.ts +63 -37
  30. package/app/Broadcasts/OrderShipped.ts +1 -1
  31. package/app/Mail/OrderConfirmation.ts +14 -7
  32. package/app/Models/Campaign.ts +9 -4
  33. package/app/Models/commerce/Cart.ts +5 -4
  34. package/app/Models/commerce/CartItem.ts +6 -4
  35. package/app/Models/commerce/Coupon.ts +9 -3
  36. package/app/Models/commerce/Customer.ts +2 -1
  37. package/app/Models/commerce/GiftCard.ts +5 -2
  38. package/app/Models/commerce/Order.ts +6 -5
  39. package/app/Models/commerce/OrderItem.ts +2 -1
  40. package/app/Models/commerce/ProductVariant.ts +71 -4
  41. package/app/Models/commerce/Transaction.ts +3 -2
  42. package/app/Storefront/StorefrontMoney.ts +54 -0
  43. package/ide/vscode/package.json +1 -1
  44. package/package.json +2 -2
  45. package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +8 -8
  46. package/resources/components/Dashboard/Analytics/SalesAnalytics.stx +4 -3
  47. package/resources/components/Dashboard/Billing/BillingPlanSummary.stx +4 -1
  48. package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +4 -1
  49. package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +34 -10
  50. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +3 -1
  51. package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +4 -1
  52. package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +2 -6
  53. package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +6 -6
  54. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +3 -11
  55. package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +2 -6
  56. package/resources/components/Dashboard/Commerce/CommerceGiftCardBreakdown.stx +2 -6
  57. package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +2 -6
  58. package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +20 -17
  59. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +3 -2
  60. package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +2 -6
  61. package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +2 -6
  62. package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +28 -20
  63. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +7 -15
  64. package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +2 -6
  65. package/resources/components/Dashboard/Commerce/CommercePaymentsBreakdown.stx +2 -6
  66. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +2 -6
  67. package/resources/components/Dashboard/Commerce/CommercePosCart.stx +2 -6
  68. package/resources/components/Dashboard/Commerce/CommercePosCatalog.stx +2 -6
  69. package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +2 -6
  70. package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +5 -7
  71. package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +2 -8
  72. package/resources/components/Dashboard/Commerce/CommerceProductDetailDashboard.stx +3 -20
  73. package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +27 -19
  74. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +4 -15
  75. package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +2 -6
  76. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +12 -10
  77. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +3 -4
  78. package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +7 -5
  79. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +3 -2
  80. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +3 -4
  81. package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +2 -6
  82. package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +12 -10
  83. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +3 -10
  84. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +3 -8
  85. package/resources/components/Dashboard/Marketing/CampaignDialog.stx +26 -8
  86. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +4 -2
  87. package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +25 -13
  88. package/resources/components/Dashboard/Transaction/index.stx +6 -3
  89. package/resources/components/Storefront/CartDrawer.stx +11 -7
  90. package/resources/views/cart.stx +19 -23
  91. package/resources/views/checkout/contact.stx +12 -14
  92. package/resources/views/checkout/payment.stx +13 -13
  93. package/resources/views/checkout/shipping.stx +13 -13
  94. package/resources/views/cms/blocks/form.stx +50 -1
  95. package/resources/views/orders/[id].stx +13 -4
  96. package/vcs/github/workflows/buddy-bot.yml +5 -5
  97. package/vcs/github/workflows/ci.yml +3 -3
  98. package/vcs/github/workflows/release.yml +1 -1
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { CommerceCustomerRecord } from '../../../../app/Actions/Dashboard/Commerce/commerce-customer-records'
3
+ import { minorToInput, moneyInputError, moneyInputStep, parseMoneyInput } from '@stacksjs/commerce/money'
3
4
 
4
5
  const open = useReactiveProp('open', false)
5
6
  const mode = useReactiveProp('mode', 'add')
@@ -30,7 +31,8 @@ effect(() => {
30
31
  name.set(current?.name || '')
31
32
  email.set(current?.email || '')
32
33
  phone.set(current?.phone || '')
33
- totalSpent.set(String(current?.totalSpent ?? 0))
34
+ // Stored as integer minor units; the field shows the decimal.
35
+ totalSpent.set(minorToInput(current?.totalSpent ?? 0, currency()))
34
36
  lastOrder.set(current?.lastOrder.slice(0, 10) || '')
35
37
  status.set(current?.status === 'Inactive' ? 'Inactive' : 'Active')
36
38
  avatar.set(current?.avatar || '')
@@ -47,12 +49,10 @@ function validAvatar(): boolean {
47
49
  }
48
50
 
49
51
  function valid(): boolean {
50
- const spend = Number(totalSpent())
51
52
  return Boolean(
52
53
  name().trim().length >= 2
53
54
  && email().trim()
54
- && Number.isFinite(spend)
55
- && spend >= 0
55
+ && !moneyInputError(totalSpent(), currency())
56
56
  && validAvatar()
57
57
  )
58
58
  }
@@ -64,7 +64,7 @@ function submit(): void {
64
64
  name: name().trim(),
65
65
  email: email().trim(),
66
66
  phone: phone().trim(),
67
- totalSpent: Number(totalSpent()),
67
+ totalSpent: parseMoneyInput(totalSpent(), currency()),
68
68
  lastOrder: lastOrder() || undefined,
69
69
  status: status(),
70
70
  avatar: avatar().trim(),
@@ -95,7 +95,7 @@ function errorMessages(): string[] {
95
95
  <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Email</span><input x-model="email" type="email" 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>
96
96
  <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Phone <span class="font-normal text-gray-400 dark:text-neutral-500">(optional)</span></span><input x-model="phone" type="tel" maxlength="50" 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>
97
97
  <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Status</span><select x-model="status" 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"><option value="Active">Active</option><option value="Inactive">Inactive</option></select></label>
98
- <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Recorded spend ({{ currency() }})</span><input x-model="totalSpent" type="number" min="0" 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>
98
+ <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Recorded spend ({{ currency() }})</span><input x-model="totalSpent" type="number" min="0" :step="moneyInputStep(currency())" 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>
99
99
  <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Last order</span><input x-model="lastOrder" type="date" 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>
100
100
  <label class="block sm:col-span-2"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Avatar URL</span><input x-model="avatar" type="url" placeholder="https://cdn.example.com/customers/avatar.jpg" 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" /><span class="block mt-1 text-gray-400 text-xs dark:text-neutral-500">Use a persisted HTTP asset URL. The dashboard uses initials when rendering the table.</span></label>
101
101
  </div>
@@ -5,6 +5,7 @@ import type {
5
5
  } from '../../../../app/Actions/Dashboard/Commerce/commerce-customer-records'
6
6
  import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
7
7
  import { pushToast } from '../../../../functions/toasts'
8
+ import { formatCurrency, minorToInput } from '@stacksjs/commerce/money'
8
9
 
9
10
  interface CustomerIndexResponse {
10
11
  records: CommerceCustomerRecord[]
@@ -59,16 +60,7 @@ function timestamp(value: string): number {
59
60
  }
60
61
 
61
62
  function formatMoney(value: number): string {
62
- try {
63
- return new Intl.NumberFormat(undefined, {
64
- style: 'currency',
65
- currency: currency(),
66
- maximumFractionDigits: 2,
67
- }).format(value)
68
- }
69
- catch {
70
- return `${value.toFixed(2)} ${currency()}`
71
- }
63
+ return formatCurrency(value, currency())
72
64
  }
73
65
 
74
66
  function filteredRecords(): CommerceCustomerRecord[] {
@@ -122,7 +114,7 @@ function exportHref(): string {
122
114
  record.email,
123
115
  record.phone,
124
116
  record.status,
125
- String(record.totalSpent),
117
+ minorToInput(record.totalSpent, currency()),
126
118
  record.lastOrder,
127
119
  record.avatar,
128
120
  ])
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { CommerceCustomerRecord } from '../../../../app/Actions/Dashboard/Commerce/commerce-customer-records'
3
+ import { formatCurrency } from '@stacksjs/commerce/money'
3
4
 
4
5
  const records = useReactiveProp('records', [] as CommerceCustomerRecord[])
5
6
  const loading = useReactiveProp('loading', false)
@@ -7,12 +8,7 @@ const currency = useReactiveProp('currency', 'USD')
7
8
  const emit = defineEmits()
8
9
 
9
10
  function formatMoney(value: number): string {
10
- try {
11
- return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency() }).format(value)
12
- }
13
- catch {
14
- return `${value.toFixed(2)} ${currency()}`
15
- }
11
+ return formatCurrency(value, currency())
16
12
  }
17
13
 
18
14
  function formatDate(value: string): string {
@@ -1,15 +1,11 @@
1
1
  <script client>
2
2
  import type { GiftCardSummary } from '../../../../app/Actions/Dashboard/Commerce/gift-card-records'
3
+ import { formatCurrency } from '@stacksjs/commerce/money'
3
4
 
4
5
  const { summary } = defineProps<{ summary: GiftCardSummary }>()
5
6
 
6
7
  function money(value: number, currency: string): string {
7
- try {
8
- return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value)
9
- }
10
- catch {
11
- return `${currency} ${value.toFixed(2)}`
12
- }
8
+ return formatCurrency(value, currency)
13
9
  }
14
10
  </script>
15
11
 
@@ -1,17 +1,13 @@
1
1
  <script client>
2
2
  import type { GiftCardRecord } from '../../../../app/Actions/Dashboard/Commerce/gift-card-records'
3
+ import { formatCurrency } from '@stacksjs/commerce/money'
3
4
 
4
5
  const open = useReactiveProp('open', false)
5
6
  const record = useReactiveProp('record', null as GiftCardRecord | null)
6
7
  const emit = defineEmits()
7
8
 
8
9
  function money(value: number, currency: string): string {
9
- try {
10
- return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value)
11
- }
12
- catch {
13
- return `${currency} ${value.toFixed(2)}`
14
- }
10
+ return formatCurrency(value, currency)
15
11
  }
16
12
 
17
13
  function dateTime(value: string): string {
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { GiftCardRecord, GiftCardStatus } from '../../../../app/Actions/Dashboard/Commerce/gift-card-records'
3
+ import { minorToInput, moneyInputError, moneyInputStep, parseMoneyInput } from '@stacksjs/commerce/money'
3
4
 
4
5
  const open = useReactiveProp('open', false)
5
6
  const mode = useReactiveProp('mode', 'add')
@@ -8,8 +9,9 @@ const busy = useReactiveProp('busy', false)
8
9
  const errors = useReactiveProp('errors', {} as Record<string, string>)
9
10
  const emit = defineEmits()
10
11
  const code = state('')
11
- const initialBalance = state('50')
12
- const currentBalance = state('50')
12
+ // Balances as typed, in major units. Stored as integer minor units.
13
+ const initialBalance = state('50.00')
14
+ const currentBalance = state('50.00')
13
15
  const currency = state('USD')
14
16
  const status = state<GiftCardStatus>('ACTIVE')
15
17
  const purchaserId = state('')
@@ -24,7 +26,7 @@ const lastUsedDate = state('')
24
26
  const templateId = state('')
25
27
  const customerId = state('')
26
28
  let hydrationKey = ''
27
- let previousInitialBalance = '50'
29
+ let previousInitialBalance = '50.00'
28
30
 
29
31
  function generatedCode(): string {
30
32
  const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
@@ -61,11 +63,12 @@ effect(() => {
61
63
  return
62
64
  hydrationKey = key
63
65
  code.set(current?.code || generatedCode())
64
- const hydratedInitialBalance = String(current?.initialBalance || 50)
66
+ const cardCurrency = current?.currency || 'USD'
67
+ const hydratedInitialBalance = current?.initialBalance ? minorToInput(current.initialBalance, cardCurrency) : '50.00'
65
68
  initialBalance.set(hydratedInitialBalance)
66
- currentBalance.set(String(current?.currentBalance ?? current?.initialBalance ?? 50))
69
+ currentBalance.set(current ? minorToInput(current.currentBalance ?? current.initialBalance, cardCurrency) : hydratedInitialBalance)
67
70
  previousInitialBalance = hydratedInitialBalance
68
- currency.set(current?.currency || 'USD')
71
+ currency.set(cardCurrency)
69
72
  status.set(current?.status || 'ACTIVE')
70
73
  purchaserId.set(current?.purchaserId || '')
71
74
  recipientEmail.set(current?.recipientEmail || '')
@@ -102,13 +105,13 @@ function optionalCustomerId(): number | undefined {
102
105
  }
103
106
 
104
107
  function balanceError(): string {
105
- const initial = Number(initialBalance())
106
- const current = Number(currentBalance())
107
- if (!Number.isFinite(initial) || initial < 1)
108
- return 'Initial balance must be at least 1.'
109
- if (!Number.isFinite(current) || current < 0)
110
- return 'Current balance cannot be negative.'
111
- if (current > initial)
108
+ const initialError = moneyInputError(initialBalance(), currency(), { min: 1 })
109
+ if (initialError)
110
+ return `Initial balance: ${initialError}`
111
+ const currentError = moneyInputError(currentBalance(), currency())
112
+ if (currentError)
113
+ return `Current balance: ${currentError}`
114
+ if (parseMoneyInput(currentBalance(), currency()) > parseMoneyInput(initialBalance(), currency()))
112
115
  return 'Current balance cannot exceed the initial balance.'
113
116
  return ''
114
117
  }
@@ -126,8 +129,8 @@ function submit(): void {
126
129
  return
127
130
  emit('submit', {
128
131
  code: code().trim().toUpperCase(),
129
- initialBalance: Number(initialBalance()),
130
- currentBalance: Number(currentBalance()),
132
+ initialBalance: parseMoneyInput(initialBalance(), currency()),
133
+ currentBalance: parseMoneyInput(currentBalance(), currency()),
131
134
  currency: currency().trim().toUpperCase(),
132
135
  status: status(),
133
136
  purchaser_id: purchaserId().trim() || undefined,
@@ -165,8 +168,8 @@ function errorMessages(): string[] {
165
168
  </ul>
166
169
  <div class="grid gap-4 grid-cols-1 sm:grid-cols-2">
167
170
  <label class="block sm:col-span-2"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Code</span><input x-model="code" type="text" maxlength="50" required spellcheck="false" class="mt-1 px-3 py-2 w-full font-mono text-gray-900 text-sm uppercase dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" /></label>
168
- <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Initial balance</span><input x-model="initialBalance" type="number" min="1" 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" @input="syncCurrentBalance($event)" /></label>
169
- <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Current balance</span><input x-model="currentBalance" :max="initialBalance()" type="number" min="0" 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>
171
+ <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Initial balance</span><input x-model="initialBalance" type="number" min="0" :step="moneyInputStep(currency())" 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" @input="syncCurrentBalance($event)" /></label>
172
+ <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Current balance</span><input x-model="currentBalance" :max="initialBalance()" type="number" min="0" :step="moneyInputStep(currency())" 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>
170
173
  <p :if="balanceError()" class="sm:col-span-2 -mt-2 text-red-600 text-xs dark:text-red-400">{{ balanceError() }}</p>
171
174
  <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Currency</span><input x-model="currency" type="text" minlength="3" maxlength="3" required spellcheck="false" class="mt-1 px-3 py-2 w-full font-mono text-gray-900 text-sm uppercase dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" /></label>
172
175
  <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Status</span><select x-model="status" 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"><option value="ACTIVE">Active</option><option value="USED">Used</option><option value="EXPIRED">Expired</option><option value="DEACTIVATED">Deactivated</option></select></label>
@@ -6,6 +6,7 @@ import type {
6
6
  } from '../../../../app/Actions/Dashboard/Commerce/gift-card-records'
7
7
  import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
8
8
  import { pushToast } from '../../../../functions/toasts'
9
+ import { minorToInput } from '@stacksjs/commerce/money'
9
10
 
10
11
  interface GiftCardIndexResponse {
11
12
  records: GiftCardRecord[]
@@ -125,8 +126,8 @@ function exportHref(): string {
125
126
  record.isDigital ? 'Digital' : 'Physical',
126
127
  record.isReloadable ? 'Reloadable' : 'Single use',
127
128
  record.currency,
128
- String(record.initialBalance),
129
- String(record.currentBalance),
129
+ minorToInput(record.initialBalance, record.currency),
130
+ minorToInput(record.currentBalance, record.currency),
130
131
  record.expiryDate,
131
132
  record.lastUsedDate,
132
133
  ])
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { GiftCardRecord, GiftCardStatus } from '../../../../app/Actions/Dashboard/Commerce/gift-card-records'
3
+ import { formatCurrency } from '@stacksjs/commerce/money'
3
4
 
4
5
  const { records = [], loading = false } = defineProps<{ records: GiftCardRecord[], loading?: boolean }>()
5
6
  const emit = defineEmits<{
@@ -9,12 +10,7 @@ const emit = defineEmits<{
9
10
  }>()
10
11
 
11
12
  function money(value: number, currency: string): string {
12
- try {
13
- return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value)
14
- }
15
- catch {
16
- return `${currency} ${value.toFixed(2)}`
17
- }
13
+ return formatCurrency(value, currency)
18
14
  }
19
15
 
20
16
  function date(value: string): string {
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { CommerceOrderRecord } from '../../../../app/Actions/Dashboard/Commerce/commerce-order-records'
3
+ import { formatCurrency } from '@stacksjs/commerce/money'
3
4
 
4
5
  const open = useReactiveProp('open', false)
5
6
  const record = useReactiveProp('record', null as CommerceOrderRecord | null)
@@ -10,12 +11,7 @@ function label(value: string): string {
10
11
  }
11
12
 
12
13
  function formatMoney(value: number, currency: string): string {
13
- try {
14
- return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value)
15
- }
16
- catch {
17
- return `${value.toFixed(2)} ${currency}`
18
- }
14
+ return formatCurrency(value, currency)
19
15
  }
20
16
 
21
17
  function formatDate(value: string): string {
@@ -3,6 +3,7 @@ import type {
3
3
  CommerceOrderRecord,
4
4
  OrderCustomerOption,
5
5
  } from '../../../../app/Actions/Dashboard/Commerce/commerce-order-records'
6
+ import { minorToInput, moneyInputError, moneyInputStep, parseMoneyInput } from '@stacksjs/commerce/money'
6
7
 
7
8
  const open = useReactiveProp('open', false)
8
9
  const mode = useReactiveProp('mode', 'add')
@@ -43,12 +44,14 @@ effect(() => {
43
44
  customerId.set(current?.customerId || '')
44
45
  couponId.set(current?.couponId || '')
45
46
  status.set(current?.status || statuses()[0] || 'PENDING')
46
- totalAmount.set(String(current?.totalAmount ?? 0))
47
- currency.set(current?.currency || defaultCurrency())
48
- taxAmount.set(String(current?.taxAmount ?? 0))
49
- discountAmount.set(String(current?.discountAmount ?? 0))
50
- deliveryFee.set(String(current?.deliveryFee ?? 0))
51
- tipAmount.set(String(current?.tipAmount ?? 0))
47
+ // Stored amounts are integer minor units; the fields show the decimal.
48
+ const orderCurrency = current?.currency || defaultCurrency()
49
+ currency.set(orderCurrency)
50
+ totalAmount.set(minorToInput(current?.totalAmount ?? 0, orderCurrency))
51
+ taxAmount.set(minorToInput(current?.taxAmount ?? 0, orderCurrency))
52
+ discountAmount.set(minorToInput(current?.discountAmount ?? 0, orderCurrency))
53
+ deliveryFee.set(minorToInput(current?.deliveryFee ?? 0, orderCurrency))
54
+ tipAmount.set(minorToInput(current?.tipAmount ?? 0, orderCurrency))
52
55
  orderType.set(current?.orderType || orderTypes()[0] || 'DELIVERY')
53
56
  deliveryAddress.set(current?.deliveryAddress || '')
54
57
  specialInstructions.set(current?.specialInstructions || '')
@@ -60,9 +63,14 @@ function label(value: string): string {
60
63
  return value.replaceAll('_', ' ').toLowerCase().replace(/\b\w/g, letter => letter.toUpperCase())
61
64
  }
62
65
 
63
- function validNumber(value: string): boolean {
64
- const result = Number(value)
65
- return Number.isFinite(result) && result >= 0
66
+ // An empty optional amount means zero, as it did before amounts were
67
+ // converted to minor units.
68
+ function validNumber(value: string, required = false): boolean {
69
+ return !moneyInputError(value, currency(), { required })
70
+ }
71
+
72
+ function minorAmount(value: string): number {
73
+ return parseMoneyInput(value, currency(), { required: false }) ?? 0
66
74
  }
67
75
 
68
76
  function valid(): boolean {
@@ -70,7 +78,7 @@ function valid(): boolean {
70
78
  status().trim()
71
79
  && orderType().trim()
72
80
  && currency().trim().length === 3
73
- && validNumber(totalAmount())
81
+ && validNumber(totalAmount(), true)
74
82
  && validNumber(taxAmount())
75
83
  && validNumber(discountAmount())
76
84
  && validNumber(deliveryFee())
@@ -85,12 +93,12 @@ function submit(): void {
85
93
  customerId: customerId() ? Number(customerId()) : undefined,
86
94
  couponId: couponId() ? Number(couponId()) : undefined,
87
95
  status: status().trim(),
88
- totalAmount: Number(totalAmount()),
96
+ totalAmount: parseMoneyInput(totalAmount(), currency()),
89
97
  currency: currency().trim().toUpperCase(),
90
- taxAmount: Number(taxAmount()),
91
- discountAmount: Number(discountAmount()),
92
- deliveryFee: Number(deliveryFee()),
93
- tipAmount: Number(tipAmount()),
98
+ taxAmount: minorAmount(taxAmount()),
99
+ discountAmount: minorAmount(discountAmount()),
100
+ deliveryFee: minorAmount(deliveryFee()),
101
+ tipAmount: minorAmount(tipAmount()),
94
102
  orderType: orderType().trim(),
95
103
  deliveryAddress: deliveryAddress().trim() || undefined,
96
104
  specialInstructions: specialInstructions().trim() || undefined,
@@ -123,11 +131,11 @@ function errorMessages(): string[] {
123
131
  <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Status</span><select x-model="status" 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"><template :for="option in statuses()" :key="option"><option :value="option">{{ label(option) }}</option></template></select></label>
124
132
  <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Order type</span><select x-model="orderType" 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"><template :for="option in orderTypes()" :key="option"><option :value="option">{{ label(option) }}</option></template></select></label>
125
133
  <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Currency</span><input x-model="currency" type="text" minlength="3" maxlength="3" required spellcheck="false" class="mt-1 px-3 py-2 w-full font-mono text-gray-900 text-sm uppercase dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" /></label>
126
- <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Total amount</span><input x-model="totalAmount" type="number" min="0" 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>
127
- <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Tax amount</span><input x-model="taxAmount" 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" /></label>
128
- <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Discount amount</span><input x-model="discountAmount" 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" /></label>
129
- <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Delivery fee</span><input x-model="deliveryFee" 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" /></label>
130
- <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Tip amount</span><input x-model="tipAmount" 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" /></label>
134
+ <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Total amount</span><input x-model="totalAmount" type="number" min="0" :step="moneyInputStep(currency())" 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>
135
+ <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Tax amount</span><input x-model="taxAmount" 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" /></label>
136
+ <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Discount amount</span><input x-model="discountAmount" 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" /></label>
137
+ <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Delivery fee</span><input x-model="deliveryFee" 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" /></label>
138
+ <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Tip amount</span><input x-model="tipAmount" 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" /></label>
131
139
  <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Estimated delivery time</span><input x-model="estimatedDeliveryTime" type="datetime-local" 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>
132
140
  <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Coupon relation ID</span><input x-model="couponId" type="number" min="1" 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" /></label>
133
141
  <label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Applied coupon reference</span><input x-model="appliedCouponId" type="text" class="mt-1 px-3 py-2 w-full font-mono 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>
@@ -6,6 +6,7 @@ import type {
6
6
  } from '../../../../app/Actions/Dashboard/Commerce/commerce-order-records'
7
7
  import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
8
8
  import { pushToast } from '../../../../functions/toasts'
9
+ import { formatCurrency, minorToInput } from '@stacksjs/commerce/money'
9
10
 
10
11
  interface OrderIndexResponse {
11
12
  records: CommerceOrderRecord[]
@@ -75,16 +76,7 @@ function timestamp(value: string): number {
75
76
  }
76
77
 
77
78
  function formatMoney(value: number, currency: string): string {
78
- try {
79
- return new Intl.NumberFormat(undefined, {
80
- style: 'currency',
81
- currency,
82
- maximumFractionDigits: 2,
83
- }).format(value)
84
- }
85
- catch {
86
- return `${value.toFixed(2)} ${currency}`
87
- }
79
+ return formatCurrency(value, currency)
88
80
  }
89
81
 
90
82
  function label(value: string): string {
@@ -154,12 +146,12 @@ function exportHref(): string {
154
146
  record.status,
155
147
  record.orderType,
156
148
  String(record.itemCount),
157
- String(record.totalAmount),
149
+ minorToInput(record.totalAmount, record.currency),
158
150
  record.currency,
159
- String(record.taxAmount),
160
- String(record.discountAmount),
161
- String(record.deliveryFee),
162
- String(record.tipAmount),
151
+ minorToInput(record.taxAmount, record.currency),
152
+ minorToInput(record.discountAmount, record.currency),
153
+ minorToInput(record.deliveryFee, record.currency),
154
+ minorToInput(record.tipAmount, record.currency),
163
155
  record.deliveryAddress,
164
156
  record.createdAt,
165
157
  ])
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { CommerceOrderRecord } from '../../../../app/Actions/Dashboard/Commerce/commerce-order-records'
3
+ import { formatCurrency } from '@stacksjs/commerce/money'
3
4
 
4
5
  const records = useReactiveProp('records', [] as CommerceOrderRecord[])
5
6
  const loading = useReactiveProp('loading', false)
@@ -10,12 +11,7 @@ function label(value: string): string {
10
11
  }
11
12
 
12
13
  function formatMoney(value: number, currency: string): string {
13
- try {
14
- return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value)
15
- }
16
- catch {
17
- return `${value.toFixed(2)} ${currency}`
18
- }
14
+ return formatCurrency(value, currency)
19
15
  }
20
16
 
21
17
  function formatDate(value: string): string {
@@ -3,6 +3,7 @@ import type {
3
3
  PaymentRecord,
4
4
  } from '../../../../app/Actions/Dashboard/Commerce/payment-records'
5
5
  import { isSuccessfulPayment } from '../../../../app/Actions/Dashboard/Commerce/payment-records'
6
+ import { formatCurrency } from '@stacksjs/commerce/money'
6
7
 
7
8
  interface CountRow {
8
9
  key: string
@@ -55,12 +56,7 @@ function label(value: string): string {
55
56
  }
56
57
 
57
58
  function money(value: number, currency: string): string {
58
- try {
59
- return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value / 100)
60
- }
61
- catch {
62
- return `${currency} ${(value / 100).toFixed(2)}`
63
- }
59
+ return formatCurrency(value, currency)
64
60
  }
65
61
  </script>
66
62
 
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { PaymentRecord } from '../../../../app/Actions/Dashboard/Commerce/payment-records'
3
+ import { formatCurrency } from '@stacksjs/commerce/money'
3
4
 
4
5
  const records = useReactiveProp('records', [] as PaymentRecord[])
5
6
  const loading = useReactiveProp('loading', false)
@@ -10,12 +11,7 @@ function label(value: string): string {
10
11
  }
11
12
 
12
13
  function money(value: number, currency: string): string {
13
- try {
14
- return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value / 100)
15
- }
16
- catch {
17
- return `${currency} ${(value / 100).toFixed(2)}`
18
- }
14
+ return formatCurrency(value, currency)
19
15
  }
20
16
 
21
17
  function date(value: string): string {
@@ -1,4 +1,5 @@
1
1
  <script client>
2
+ import { formatCurrency } from '@stacksjs/commerce/money'
2
3
  interface PosCartLine {
3
4
  productId: string
4
5
  name: string
@@ -17,12 +18,7 @@ const totalAmount = useReactiveProp('totalAmount', 0)
17
18
  const emit = defineEmits()
18
19
 
19
20
  function formatMoney(value: number): string {
20
- try {
21
- return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency() }).format(value)
22
- }
23
- catch {
24
- return `${value.toFixed(2)} ${currency()}`
25
- }
21
+ return formatCurrency(value, currency())
26
22
  }
27
23
  </script>
28
24
 
@@ -1,6 +1,7 @@
1
1
  <script client>
2
2
  import type { CommercePosProduct } from '../../../../app/Actions/Dashboard/Commerce/commerce-pos'
3
3
  import type { ProductOption } from '../../../../app/Actions/Dashboard/Commerce/commerce-product-records'
4
+ import { formatCurrency } from '@stacksjs/commerce/money'
4
5
 
5
6
  const products = useReactiveProp('products', [] as CommercePosProduct[])
6
7
  const categories = useReactiveProp('categories', [] as ProductOption[])
@@ -16,12 +17,7 @@ function initials(name: string): string {
16
17
  }
17
18
 
18
19
  function formatMoney(value: number): string {
19
- try {
20
- return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency() }).format(value)
21
- }
22
- catch {
23
- return `${value.toFixed(2)} ${currency()}`
24
- }
20
+ return formatCurrency(value, currency())
25
21
  }
26
22
 
27
23
  function sellable(product: CommercePosProduct): boolean {
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { CommercePosCustomer } from '../../../../app/Actions/Dashboard/Commerce/commerce-pos'
3
+ import { formatCurrency } from '@stacksjs/commerce/money'
3
4
 
4
5
  interface PosPaymentMethod {
5
6
  id: string
@@ -38,12 +39,7 @@ effect(() => {
38
39
  })
39
40
 
40
41
  function formatMoney(value: number): string {
41
- try {
42
- return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency() }).format(value)
43
- }
44
- catch {
45
- return `${value.toFixed(2)} ${currency()}`
46
- }
42
+ return formatCurrency(value, currency())
47
43
  }
48
44
 
49
45
  function submit(): void {
@@ -6,6 +6,7 @@ import type {
6
6
  import type { ProductOption } from '../../../../app/Actions/Dashboard/Commerce/commerce-product-records'
7
7
  import { dashboardApi } from '../../../../functions/dashboard-api'
8
8
  import { pushToast } from '../../../../functions/toasts'
9
+ import { formatCurrency } from '@stacksjs/commerce/money'
9
10
 
10
11
  interface PosPaymentMethod {
11
12
  id: string
@@ -91,8 +92,10 @@ const checkoutKey = state('')
91
92
  const receipt = state<PosReceipt | null>(null)
92
93
  const receiptOpen = state(false)
93
94
 
95
+ // Amounts are integer minor units (cents for USD). Only tax can produce a
96
+ // fraction of one, and it rounds to the nearest unit as the server does.
94
97
  function money(value: number): number {
95
- return Math.round((value + Number.EPSILON) * 100) / 100
98
+ return Math.round(value)
96
99
  }
97
100
 
98
101
  function cartUnits(): number {
@@ -112,12 +115,7 @@ function totalAmount(): number {
112
115
  }
113
116
 
114
117
  function formatMoney(value: number): string {
115
- try {
116
- return new Intl.NumberFormat(undefined, { style: 'currency', currency: defaultCurrency() }).format(value)
117
- }
118
- catch {
119
- return `${value.toFixed(2)} ${defaultCurrency()}`
120
- }
118
+ return formatCurrency(value, defaultCurrency())
121
119
  }
122
120
 
123
121
  function visibleProducts(): CommercePosProduct[] {
@@ -1,4 +1,5 @@
1
1
  <script client>
2
+ import { formatCurrency } from '@stacksjs/commerce/money'
2
3
  interface PosReceiptLine {
3
4
  productId: number
4
5
  name: string
@@ -25,14 +26,7 @@ const receipt = useReactiveProp('receipt', null as PosReceipt | null)
25
26
  const emit = defineEmits()
26
27
 
27
28
  function formatMoney(value: number): string {
28
- const current = receipt()
29
- const currency = current?.currency || 'USD'
30
- try {
31
- return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value)
32
- }
33
- catch {
34
- return `${value.toFixed(2)} ${currency}`
35
- }
29
+ return formatCurrency(value, receipt()?.currency || 'USD')
36
30
  }
37
31
 
38
32
  function formatDate(value: string): string {