@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.
- package/ai/skills/stacks-ai/SKILL.md +24 -1
- package/ai/skills/stacks-commerce/SKILL.md +1 -1
- package/ai/skills/stacks-config/SKILL.md +1 -1
- package/ai/skills/stacks-database/SKILL.md +1 -1
- package/ai/skills/stacks-dependencies/SKILL.md +35 -3
- package/ai/skills/stacks-env/SKILL.md +4 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +2 -2
- package/app/Actions/Commerce/Product/ProductStoreAction.ts +9 -1
- package/app/Actions/Commerce/Product/ProductUpdateAction.ts +4 -1
- package/app/Actions/Commerce/commerce-action.test.ts +30 -1
- package/app/Actions/Commerce/commerce-action.ts +44 -0
- package/app/Actions/Dashboard/Analytics/sales-analytics.test.ts +19 -0
- package/app/Actions/Dashboard/Analytics/sales-analytics.ts +2 -1
- package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +3 -2
- package/app/Actions/Dashboard/Commerce/commerce-dashboard.test.ts +29 -9
- package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +5 -12
- package/app/Actions/Dashboard/Commerce/commerce-delivery.test.ts +15 -0
- package/app/Actions/Dashboard/Commerce/commerce-delivery.ts +8 -10
- package/app/Actions/Dashboard/Commerce/commerce-pos.test.ts +26 -6
- package/app/Actions/Dashboard/Commerce/commerce-pos.ts +8 -3
- package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +69 -0
- package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +39 -0
- package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +9 -3
- package/app/Actions/Dashboard/Marketing/campaign-records.ts +17 -3
- package/app/Actions/Storefront/AddToCartAction.ts +4 -1
- package/app/Actions/Storefront/GetCartAction.ts +25 -10
- package/app/Actions/Storefront/PlaceOrderAction.ts +6 -1
- package/app/Actions/Storefront/_shipping.ts +63 -37
- package/app/Broadcasts/OrderShipped.ts +1 -1
- package/app/Mail/OrderConfirmation.ts +14 -7
- package/app/Models/Campaign.ts +9 -4
- package/app/Models/commerce/Cart.ts +5 -4
- package/app/Models/commerce/CartItem.ts +6 -4
- package/app/Models/commerce/Coupon.ts +9 -3
- package/app/Models/commerce/Customer.ts +2 -1
- package/app/Models/commerce/GiftCard.ts +5 -2
- package/app/Models/commerce/Order.ts +6 -5
- package/app/Models/commerce/OrderItem.ts +2 -1
- package/app/Models/commerce/ProductVariant.ts +71 -4
- package/app/Models/commerce/Transaction.ts +3 -2
- package/app/Storefront/StorefrontMoney.ts +54 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +8 -8
- package/resources/components/Dashboard/Analytics/SalesAnalytics.stx +4 -3
- package/resources/components/Dashboard/Billing/BillingPlanSummary.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +34 -10
- package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +3 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +6 -6
- package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +3 -11
- package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardBreakdown.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +20 -17
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +3 -2
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +28 -20
- package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +7 -15
- package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePaymentsBreakdown.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCart.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCatalog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +5 -7
- package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +2 -8
- package/resources/components/Dashboard/Commerce/CommerceProductDetailDashboard.stx +3 -20
- package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +27 -19
- package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +4 -15
- package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +12 -10
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +3 -4
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +7 -5
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +3 -2
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +3 -4
- package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +12 -10
- package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +3 -10
- package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +3 -8
- package/resources/components/Dashboard/Marketing/CampaignDialog.stx +26 -8
- package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +4 -2
- package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +25 -13
- package/resources/components/Dashboard/Transaction/index.stx +6 -3
- package/resources/components/Storefront/CartDrawer.stx +11 -7
- package/resources/views/cart.stx +19 -23
- package/resources/views/checkout/contact.stx +12 -14
- package/resources/views/checkout/payment.stx +13 -13
- package/resources/views/checkout/shipping.stx +13 -13
- package/resources/views/cms/blocks/form.stx +50 -1
- package/resources/views/orders/[id].stx +13 -4
- package/vcs/github/workflows/buddy-bot.yml +5 -5
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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="
|
|
105
|
-
<span class="block mt-1 text-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
163
|
-
const
|
|
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 + ' ·
|
|
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"
|
|
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 =
|
|
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.
|
|
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)
|
package/resources/views/cart.stx
CHANGED
|
@@ -15,15 +15,17 @@ interface CartItemRow {
|
|
|
15
15
|
weight_grams: number | null
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
// The
|
|
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:
|
|
59
|
+
priceLabel: storefrontMoney(price, currency),
|
|
56
60
|
lineTotal,
|
|
57
|
-
lineTotalLabel:
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
const
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const
|
|
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:
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
const
|
|
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:
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
const
|
|
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 ?? '' }}"
|
|
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"
|
|
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"
|
|
102
|
-
<div class="flex justify-between"><dt class="text-stone-600">Shipping</dt><dd class="font-semibold text-stone-900">{{
|
|
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"
|
|
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@
|
|
86
|
+
uses: pantry-pm/pantry/packages/action@bc2f37441f1a7ed160c201bbe6e0552b81ca931e # v0.11.65
|
|
87
87
|
with:
|
|
88
|
-
version: '0.11.
|
|
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
|
|
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
|
|
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
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
24
|
+
uses: pantry-pm/pantry/packages/action@bc2f37441f1a7ed160c201bbe6e0552b81ca931e # v0.11.65
|
|
25
25
|
with:
|
|
26
26
|
install: 'false'
|
|
27
27
|
|