@stacksjs/defaults 0.75.37 → 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 (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-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/Ci/failure-notifier.ts +11 -7
  16. package/app/Actions/Dashboard/Ci/runner-pressure-monitor.ts +13 -7
  17. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +3 -2
  18. package/app/Actions/Dashboard/Commerce/commerce-dashboard.test.ts +29 -9
  19. package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +5 -12
  20. package/app/Actions/Dashboard/Commerce/commerce-delivery.test.ts +15 -0
  21. package/app/Actions/Dashboard/Commerce/commerce-delivery.ts +8 -10
  22. package/app/Actions/Dashboard/Commerce/commerce-pos.test.ts +26 -6
  23. package/app/Actions/Dashboard/Commerce/commerce-pos.ts +8 -3
  24. package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +69 -0
  25. package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +7 -4
  26. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +10 -7
  27. package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +7 -4
  28. package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +9 -5
  29. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +8 -5
  30. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +3 -2
  31. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +7 -4
  32. package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +7 -4
  33. package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +3 -2
  34. package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +7 -3
  35. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +39 -0
  36. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +9 -3
  37. package/app/Actions/Dashboard/Marketing/campaign-records.ts +17 -3
  38. package/app/Actions/Storefront/AddToCartAction.ts +4 -1
  39. package/app/Actions/Storefront/GetCartAction.ts +25 -10
  40. package/app/Actions/Storefront/PlaceOrderAction.ts +6 -1
  41. package/app/Actions/Storefront/_shipping.ts +63 -37
  42. package/app/Broadcasts/OrderShipped.ts +1 -1
  43. package/app/Mail/OrderConfirmation.ts +14 -7
  44. package/app/Models/Campaign.ts +9 -4
  45. package/app/Models/commerce/Cart.ts +5 -4
  46. package/app/Models/commerce/CartItem.ts +6 -4
  47. package/app/Models/commerce/Coupon.ts +9 -3
  48. package/app/Models/commerce/Customer.ts +2 -1
  49. package/app/Models/commerce/GiftCard.ts +5 -2
  50. package/app/Models/commerce/Order.ts +6 -5
  51. package/app/Models/commerce/OrderItem.ts +2 -1
  52. package/app/Models/commerce/ProductVariant.ts +71 -4
  53. package/app/Models/commerce/Transaction.ts +3 -2
  54. package/app/Storefront/StorefrontMoney.ts +54 -0
  55. package/ide/vscode/package.json +1 -1
  56. package/package.json +2 -2
  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
@@ -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>
@@ -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
  shipping_name: string | null
19
20
  shipping_address: string | null
@@ -26,6 +27,7 @@ interface StorefrontLineItem { lineTotal: number }
26
27
 
27
28
  const { verifyCartCookie } = require('../../../app/Storefront/CartCookie')
28
29
  const { readStorefront } = require('../../../app/Storefront/StorefrontDatabase')
30
+ const { storefrontCurrency, storefrontMoney, storefrontSummary } = require('../../../app/Storefront/StorefrontMoney')
29
31
 
30
32
  // Signed by the storefront actions as the token, a dot and its HMAC. An
31
33
  // unsigned or tampered cookie is no cart, and is never looked up.
@@ -37,6 +39,8 @@ const found = token
37
39
  if (!row)
38
40
  return { cart: null, items: [] }
39
41
 
42
+ // Every amount below is integer minor units of the cart's currency.
43
+ const currency = storefrontCurrency(row.currency)
40
44
  const lines = db.query<CartSummaryRow, [number]>(`
41
45
  SELECT product_name, quantity, total_price
42
46
  FROM cart_items WHERE cart_id = ?1 ORDER BY id ASC
@@ -47,7 +51,7 @@ const found = token
47
51
  name: line.product_name,
48
52
  qty,
49
53
  lineTotal,
50
- lineTotalLabel: `$${lineTotal.toFixed(2)}`,
54
+ lineTotalLabel: storefrontMoney(lineTotal, currency),
51
55
  }
52
56
  })
53
57
 
@@ -58,19 +62,15 @@ const found = token
58
62
  const cart = found.cart
59
63
  const items = found.items
60
64
 
61
- const FREE_SHIPPING_THRESHOLD = Number(process.env.STOREFRONT_FREE_SHIPPING_THRESHOLD ?? 40) || 40
62
- const FLAT_SHIPPING = Number(process.env.STOREFRONT_FLAT_SHIPPING ?? 5) || 5
65
+ // The same shipping rule PlaceOrderAction charges with
66
+ // (app/Actions/Storefront/_shipping.ts), in minor units, so the running
67
+ // totals on every checkout step match what gets charged.
63
68
  const subtotal = items.reduce((s: number, i: StorefrontLineItem) => s + i.lineTotal, 0)
64
- const shipping = subtotal <= 0 ? 0 : (subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : FLAT_SHIPPING)
65
- const total = subtotal + shipping
66
- const remainingForFree = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal)
67
-
68
- const subtotalLabel = `$${subtotal.toFixed(2)}`
69
- const shippingLabel = shipping === 0 ? 'Free' : `$${shipping.toFixed(2)}`
70
- const totalLabel = `$${total.toFixed(2)}`
71
- const freeShippingNote = (shipping > 0 && remainingForFree > 0)
72
- ? `Add $${remainingForFree.toFixed(2)} more for free shipping.`
73
- : ''
69
+ const summary = storefrontSummary(subtotal, storefrontCurrency(cart?.currency))
70
+ const subtotalLabel = summary.subtotalLabel
71
+ const shippingLabel = summary.shippingLabel
72
+ const totalLabel = summary.totalLabel
73
+ const freeShippingNote = summary.freeShippingNote
74
74
 
75
75
  const ready = !!cart?.email && !!cart?.shipping_name && !!cart?.shipping_address
76
76
  </script>
@@ -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
  shipping_name: string | null
19
20
  shipping_address: string | null
@@ -26,6 +27,7 @@ interface StorefrontLineItem { lineTotal: number }
26
27
 
27
28
  const { verifyCartCookie } = require('../../../app/Storefront/CartCookie')
28
29
  const { readStorefront } = require('../../../app/Storefront/StorefrontDatabase')
30
+ const { storefrontCurrency, storefrontMoney, storefrontSummary } = require('../../../app/Storefront/StorefrontMoney')
29
31
 
30
32
  // Signed by the storefront actions as the token, a dot and its HMAC. An
31
33
  // unsigned or tampered cookie is no cart, and is never looked up.
@@ -37,6 +39,8 @@ const found = token
37
39
  if (!row)
38
40
  return { cart: null, items: [] }
39
41
 
42
+ // Every amount below is integer minor units of the cart's currency.
43
+ const currency = storefrontCurrency(row.currency)
40
44
  const lines = db.query<CartSummaryRow, [number]>(`
41
45
  SELECT product_name, quantity, total_price
42
46
  FROM cart_items WHERE cart_id = ?1 ORDER BY id ASC
@@ -47,7 +51,7 @@ const found = token
47
51
  name: line.product_name,
48
52
  qty,
49
53
  lineTotal,
50
- lineTotalLabel: `$${lineTotal.toFixed(2)}`,
54
+ lineTotalLabel: storefrontMoney(lineTotal, currency),
51
55
  }
52
56
  })
53
57
 
@@ -58,19 +62,15 @@ const found = token
58
62
  const cart = found.cart
59
63
  const items = found.items
60
64
 
61
- const FREE_SHIPPING_THRESHOLD = Number(process.env.STOREFRONT_FREE_SHIPPING_THRESHOLD ?? 40) || 40
62
- const FLAT_SHIPPING = Number(process.env.STOREFRONT_FLAT_SHIPPING ?? 5) || 5
65
+ // The same shipping rule PlaceOrderAction charges with
66
+ // (app/Actions/Storefront/_shipping.ts), in minor units, so the running
67
+ // totals on every checkout step match what gets charged.
63
68
  const subtotal = items.reduce((s: number, i: StorefrontLineItem) => s + i.lineTotal, 0)
64
- const shipping = subtotal <= 0 ? 0 : (subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : FLAT_SHIPPING)
65
- const total = subtotal + shipping
66
- const remainingForFree = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal)
67
-
68
- const subtotalLabel = `$${subtotal.toFixed(2)}`
69
- const shippingLabel = shipping === 0 ? 'Free' : `$${shipping.toFixed(2)}`
70
- const totalLabel = `$${total.toFixed(2)}`
71
- const freeShippingNote = (shipping > 0 && remainingForFree > 0)
72
- ? `Add $${remainingForFree.toFixed(2)} more for free shipping.`
73
- : ''
69
+ const summary = storefrontSummary(subtotal, storefrontCurrency(cart?.currency))
70
+ const subtotalLabel = summary.subtotalLabel
71
+ const shippingLabel = summary.shippingLabel
72
+ const totalLabel = summary.totalLabel
73
+ const freeShippingNote = summary.freeShippingNote
74
74
 
75
75
  const ship = {
76
76
  name: cart?.shipping_name || '',
@@ -8,6 +8,7 @@
8
8
  <div class="cms-form mx-auto max-w-xl px-6 py-8" data-form-uuid="{{ props.formUuid }}">
9
9
  <script client>
10
10
  import type { PublicFormDefinition, PublicFormField, SubmissionErrors } from '@stacksjs/forms'
11
+ import { moneyInputError, moneyInputStep, parseMoneyInput } from '@stacksjs/commerce/money'
11
12
 
12
13
  // `_form` carries a whole-form failure; the rest are keyed by field name.
13
14
  type FormErrors = SubmissionErrors & { _form?: string }
@@ -114,6 +115,54 @@
114
115
  }
115
116
  }
116
117
 
118
+ /**
119
+ * The currency a `currency` field is typed in: the form's payment currency,
120
+ * or USD, which is what the server's public definition defaults to.
121
+ */
122
+ function formCurrency(): string {
123
+ return definition()?.payment?.currency ?? 'USD'
124
+ }
125
+
126
+ /** What a text-like input records: the typed text, or minor units for a `currency` field. */
127
+ function setInput(field: PublicFormField, raw: string): void {
128
+ if (field.type === 'currency')
129
+ setAmount(field, raw)
130
+ else
131
+ values[field.name] = raw
132
+ }
133
+
134
+ /**
135
+ * Record what was typed into a `currency` field as integer minor units.
136
+ *
137
+ * The server takes a currency field as a whole number of minor units
138
+ * (`"19.99"` in USD is `1999`). This used to multiply the typed float by
139
+ * a hundred and round, which is float math (`1.005` came out as 100 cents,
140
+ * not 101), assumed two decimals for every currency, and turned an empty
141
+ * field into 0. The conversion is `@stacksjs/commerce/money`'s, at the
142
+ * form currency's precision.
143
+ *
144
+ * An amount the currency cannot hold (`19.999` in USD) is kept as typed and
145
+ * shown as an error, rather than rounded into a different amount, and the
146
+ * server rejects it on submit as well.
147
+ */
148
+ function setAmount(field: PublicFormField, raw: string): void {
149
+ const currency = formCurrency()
150
+ const error = moneyInputError(raw, currency, { required: false })
151
+ if (error) {
152
+ values[field.name] = raw
153
+ errors.set({ ...errors(), [field.name]: error })
154
+ return
155
+ }
156
+
157
+ const minor = parseMoneyInput(raw, currency, { required: false })
158
+ if (minor === null)
159
+ delete values[field.name]
160
+ else
161
+ values[field.name] = minor
162
+ if (errors()[field.name])
163
+ errors.set({ ...errors(), [field.name]: '' })
164
+ }
165
+
117
166
  /**
118
167
  * The `accept` attribute for a field's picker.
119
168
  *
@@ -210,7 +259,7 @@
210
259
  <p :if="uploading[field.name]" class="mt-1.5 text-[13px] text-gray-500">Uploading...</p>
211
260
  <p :else-if="attached[field.name]" class="mt-1.5 text-[13px] text-green-700">Attached {{ attached[field.name] }}</p>
212
261
  </div>
213
- <input :else type="{{ field.type === 'email' ? 'email' : (field.type === 'phone' ? 'tel' : (field.type === 'date' ? 'date' : (field.type === 'currency' ? 'number' : 'text'))) }}" :required="field.required" placeholder="{{ field.options.placeholder ?? '' }}" @input="values[field.name] = field.type === 'currency' ? Math.round(Number($event.target.value) * 100) : $event.target.value" class="mt-1.5 w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-[15px]">
262
+ <input :else type="{{ field.type === 'email' ? 'email' : (field.type === 'phone' ? 'tel' : (field.type === 'date' ? 'date' : (field.type === 'currency' ? 'number' : 'text'))) }}" :required="field.required" placeholder="{{ field.options.placeholder ?? '' }}" :step="field.type === 'currency' ? moneyInputStep(formCurrency()) : null" @input="setInput(field, $event.target.value)" class="mt-1.5 w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-[15px]">
214
263
  <p :if="errors()[field.name]" class="mt-1.5 text-[13px] text-red-600">{{ errors()[field.name] }}</p>
215
264
  </template>
216
265
  </div>
@@ -18,6 +18,7 @@ interface StorefrontLineItem { lineTotal: number }
18
18
 
19
19
  const Database = require('bun:sqlite').Database
20
20
  const path = require('path')
21
+ const { storefrontCurrency, storefrontMoney } = require('../../../app/Storefront/StorefrontMoney')
21
22
 
22
23
  const dbPath = path.resolve(process.cwd(), process.env.DB_DATABASE_PATH || 'database/stacks.sqlite')
23
24
  const db = new Database(dbPath, { readonly: true })
@@ -36,6 +37,10 @@ const order = orderId
36
37
  `).get(orderId)
37
38
  : null
38
39
 
40
+ // Every amount is integer minor units of the order's currency (1999 is
41
+ // $19.99): `order_items.price`, `orders.delivery_fee` and `orders.total_amount`.
42
+ const currency = storefrontCurrency(order?.currency)
43
+
39
44
  const items = order
40
45
  ? db.query(`
41
46
  SELECT oi.quantity, oi.price, p.name, p.image_url, p.slug, p.weight_grams
@@ -51,12 +56,16 @@ const items = order
51
56
  qty: Number(row.quantity),
52
57
  price: Number(row.price),
53
58
  lineTotal: Number(row.quantity) * Number(row.price),
59
+ lineTotalLabel: storefrontMoney(Number(row.quantity) * Number(row.price), currency),
54
60
  }))
55
61
  : []
56
62
 
57
63
  const subtotal = items.reduce((s: number, i: StorefrontLineItem) => s + i.lineTotal, 0)
58
64
  const shipping = order ? Number(order.delivery_fee || 0) : 0
59
65
  const total = order ? Number(order.total_amount || 0) : 0
66
+ const subtotalLabel = storefrontMoney(subtotal, currency)
67
+ const shippingLabel = shipping === 0 ? 'Free' : storefrontMoney(shipping, currency)
68
+ const totalLabel = storefrontMoney(total, currency)
60
69
  </script>
61
70
 
62
71
  @section('content')
@@ -90,7 +99,7 @@ const total = order ? Number(order.total_amount || 0) : 0
90
99
  <p class="font-semibold text-stone-900">{{ item.name }}</p>
91
100
  <p class="text-stone-500 text-xs">{{ item.weight }} · qty {{ item.qty }}</p>
92
101
  </div>
93
- <p class="font-semibold text-stone-900">${{ item.lineTotal.toFixed(2) }}</p>
102
+ <p class="font-semibold text-stone-900">{{ item.lineTotalLabel }}</p>
94
103
  </li>
95
104
  @endforeach
96
105
  </ul>
@@ -98,9 +107,9 @@ const total = order ? Number(order.total_amount || 0) : 0
98
107
  <hr class="my-6 border-stone-200">
99
108
 
100
109
  <dl class="space-y-2 text-sm">
101
- <div class="flex justify-between"><dt class="text-stone-600">Subtotal</dt><dd class="font-semibold text-stone-900">${{ subtotal.toFixed(2) }}</dd></div>
102
- <div class="flex justify-between"><dt class="text-stone-600">Shipping</dt><dd class="font-semibold text-stone-900">{{ shipping === 0 ? 'Free' : `$${shipping.toFixed(2)}` }}</dd></div>
103
- <div class="flex justify-between pt-2 text-base border-stone-200 border-t"><dt class="font-semibold text-stone-900">Total</dt><dd class="font-bold text-stone-900">${{ total.toFixed(2) }}</dd></div>
110
+ <div class="flex justify-between"><dt class="text-stone-600">Subtotal</dt><dd class="font-semibold text-stone-900">{{ subtotalLabel }}</dd></div>
111
+ <div class="flex justify-between"><dt class="text-stone-600">Shipping</dt><dd class="font-semibold text-stone-900">{{ shippingLabel }}</dd></div>
112
+ <div class="flex justify-between pt-2 text-base border-stone-200 border-t"><dt class="font-semibold text-stone-900">Total</dt><dd class="font-bold text-stone-900">{{ totalLabel }}</dd></div>
104
113
  </dl>
105
114
 
106
115
  <div class="mt-6 p-4 text-sm bg-stone-100 rounded-xl">
@@ -83,9 +83,9 @@ jobs:
83
83
  persist-credentials: true
84
84
 
85
85
  - name: Setup Pantry
86
- uses: pantry-pm/pantry/packages/action@fa35547da630b14beda5b26a2625490864d5c963 # v0.11.60
86
+ uses: pantry-pm/pantry/packages/action@bc2f37441f1a7ed160c201bbe6e0552b81ca931e # v0.11.65
87
87
  with:
88
- version: '0.11.60'
88
+ version: '0.11.65'
89
89
  install: 'false'
90
90
 
91
91
  - name: Install Dependencies
@@ -98,12 +98,12 @@ jobs:
98
98
 
99
99
  - name: Open and refresh update pull requests
100
100
  if: ${{ github.event.schedule == '0 */2 * * *' || (github.event_name == 'workflow_dispatch' && contains(fromJSON('["all", "update"]'), inputs.job)) }}
101
- run: bunx buddy-bot update --strategy "${{ inputs.strategy || 'patch' }}" ${{ inputs.dry_run && '--dry-run' || '' }}
101
+ run: bunx @buddysh/buddy update --strategy "${{ inputs.strategy || 'patch' }}" ${{ inputs.dry_run && '--dry-run' || '' }}
102
102
 
103
103
  - name: Update the dependency dashboard
104
104
  if: ${{ github.event.schedule == '15 */2 * * *' || (github.event_name == 'workflow_dispatch' && contains(fromJSON('["all", "dashboard"]'), inputs.job)) }}
105
- run: bunx buddy-bot dashboard ${{ inputs.dry_run && '--dry-run' || '' }}
105
+ run: bunx @buddysh/buddy dashboard ${{ inputs.dry_run && '--dry-run' || '' }}
106
106
 
107
107
  - name: Close landed pull requests and clean up branches
108
108
  if: ${{ github.event.schedule == '0 4 * * *' || (github.event_name == 'workflow_dispatch' && contains(fromJSON('["all", "check"]'), inputs.job)) }}
109
- run: bunx buddy-bot update-check ${{ inputs.dry_run && '--dry-run' || '' }}
109
+ run: bunx @buddysh/buddy update-check ${{ inputs.dry_run && '--dry-run' || '' }}
@@ -30,7 +30,7 @@ jobs:
30
30
  uses: actions/checkout@v4
31
31
 
32
32
  - name: Setup Pantry
33
- uses: pantry-pm/pantry/packages/action@fa35547da630b14beda5b26a2625490864d5c963 # v0.11.60
33
+ uses: pantry-pm/pantry/packages/action@bc2f37441f1a7ed160c201bbe6e0552b81ca931e # v0.11.65
34
34
  with:
35
35
  install: 'false'
36
36
 
@@ -63,7 +63,7 @@ jobs:
63
63
  uses: actions/checkout@v4
64
64
 
65
65
  - name: Setup Pantry
66
- uses: pantry-pm/pantry/packages/action@fa35547da630b14beda5b26a2625490864d5c963 # v0.11.60
66
+ uses: pantry-pm/pantry/packages/action@bc2f37441f1a7ed160c201bbe6e0552b81ca931e # v0.11.65
67
67
  with:
68
68
  install: 'false'
69
69
 
@@ -99,7 +99,7 @@ jobs:
99
99
  uses: actions/checkout@v4
100
100
 
101
101
  - name: Setup Pantry
102
- uses: pantry-pm/pantry/packages/action@fa35547da630b14beda5b26a2625490864d5c963 # v0.11.60
102
+ uses: pantry-pm/pantry/packages/action@bc2f37441f1a7ed160c201bbe6e0552b81ca931e # v0.11.65
103
103
  with:
104
104
  install: 'false'
105
105
 
@@ -21,7 +21,7 @@ jobs:
21
21
  fetch-depth: 0
22
22
 
23
23
  - name: Setup Pantry
24
- uses: pantry-pm/pantry/packages/action@fa35547da630b14beda5b26a2625490864d5c963 # v0.11.60
24
+ uses: pantry-pm/pantry/packages/action@bc2f37441f1a7ed160c201bbe6e0552b81ca931e # v0.11.65
25
25
  with:
26
26
  install: 'false'
27
27