@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
|
@@ -52,7 +52,8 @@ export default defineModel({
|
|
|
52
52
|
validation: {
|
|
53
53
|
rule: schema.number().required().min(0),
|
|
54
54
|
},
|
|
55
|
-
|
|
55
|
+
// Integer minor units, in the same range as Product.price.
|
|
56
|
+
factory: faker => faker.number.int({ min: 100, max: 10000 }),
|
|
56
57
|
},
|
|
57
58
|
|
|
58
59
|
totalPrice: {
|
|
@@ -61,7 +62,7 @@ export default defineModel({
|
|
|
61
62
|
validation: {
|
|
62
63
|
rule: schema.number().required().min(0),
|
|
63
64
|
},
|
|
64
|
-
factory: faker => faker.number.int({ min:
|
|
65
|
+
factory: faker => faker.number.int({ min: 100, max: 50000 }),
|
|
65
66
|
},
|
|
66
67
|
|
|
67
68
|
taxRate: {
|
|
@@ -79,7 +80,7 @@ export default defineModel({
|
|
|
79
80
|
validation: {
|
|
80
81
|
rule: schema.number().min(0),
|
|
81
82
|
},
|
|
82
|
-
factory: faker => faker.number.int({ min: 0, max:
|
|
83
|
+
factory: faker => faker.number.int({ min: 0, max: 1000 }),
|
|
83
84
|
},
|
|
84
85
|
|
|
85
86
|
discountPercentage: {
|
|
@@ -97,7 +98,8 @@ export default defineModel({
|
|
|
97
98
|
validation: {
|
|
98
99
|
rule: schema.number().min(0),
|
|
99
100
|
},
|
|
100
|
-
|
|
101
|
+
// At most $1.00, so it never exceeds the smallest unit price above.
|
|
102
|
+
factory: faker => faker.number.int({ min: 0, max: 100 }),
|
|
101
103
|
},
|
|
102
104
|
|
|
103
105
|
productName: {
|
|
@@ -106,7 +106,11 @@ export default defineModel({
|
|
|
106
106
|
min: 'Discount value must be greater than 0.01',
|
|
107
107
|
},
|
|
108
108
|
},
|
|
109
|
-
|
|
109
|
+
// A percentage stays a plain number; a fixed amount is integer minor units
|
|
110
|
+
// ($5 to $50). discountType is declared above, so the seeder has drawn it.
|
|
111
|
+
factory: (faker, { discountType }) => discountType === 'fixed_amount'
|
|
112
|
+
? faker.helpers.arrayElement([500, 1000, 1500, 2000, 2500, 5000])
|
|
113
|
+
: faker.number.int({ min: 5, max: 50 }),
|
|
110
114
|
},
|
|
111
115
|
|
|
112
116
|
minOrderAmount: {
|
|
@@ -118,7 +122,8 @@ export default defineModel({
|
|
|
118
122
|
min: 'Min order amount must be greater than 0',
|
|
119
123
|
},
|
|
120
124
|
},
|
|
121
|
-
|
|
125
|
+
// Integer minor units: no minimum, or $25 to $100.
|
|
126
|
+
factory: faker => faker.helpers.arrayElement([0, 2500, 5000, 7500, 10000]),
|
|
122
127
|
},
|
|
123
128
|
|
|
124
129
|
maxDiscountAmount: {
|
|
@@ -130,7 +135,8 @@ export default defineModel({
|
|
|
130
135
|
min: 'Max discount amount must be greater than 0',
|
|
131
136
|
},
|
|
132
137
|
},
|
|
133
|
-
|
|
138
|
+
// Integer minor units: $10 to $100.
|
|
139
|
+
factory: faker => faker.number.int({ min: 1000, max: 10000 }),
|
|
134
140
|
},
|
|
135
141
|
|
|
136
142
|
freeProductId: {
|
|
@@ -82,7 +82,8 @@ export default defineModel({
|
|
|
82
82
|
min: 'Total spent cannot be negative',
|
|
83
83
|
},
|
|
84
84
|
},
|
|
85
|
-
|
|
85
|
+
// Integer minor units: $0 to $5,000.
|
|
86
|
+
factory: faker => faker.number.int({ min: 0, max: 500000 }),
|
|
86
87
|
},
|
|
87
88
|
|
|
88
89
|
lastOrder: {
|
|
@@ -57,7 +57,8 @@ export default defineModel({
|
|
|
57
57
|
validation: {
|
|
58
58
|
rule: schema.number().required().min(1),
|
|
59
59
|
},
|
|
60
|
-
|
|
60
|
+
// Integer minor units, in the denominations cards are actually sold in: $25 to $250.
|
|
61
|
+
factory: faker => faker.helpers.arrayElement([2500, 5000, 10000, 15000, 20000, 25000]),
|
|
61
62
|
},
|
|
62
63
|
|
|
63
64
|
currentBalance: {
|
|
@@ -66,7 +67,9 @@ export default defineModel({
|
|
|
66
67
|
validation: {
|
|
67
68
|
rule: schema.number().required().min(0),
|
|
68
69
|
},
|
|
69
|
-
|
|
70
|
+
// Drawn below the smallest initial balance above, so a seeded card never
|
|
71
|
+
// holds more than it was issued with.
|
|
72
|
+
factory: faker => faker.number.int({ min: 0, max: 2500 }),
|
|
70
73
|
},
|
|
71
74
|
|
|
72
75
|
currency: {
|
|
@@ -64,7 +64,8 @@ export default defineModel({
|
|
|
64
64
|
validation: {
|
|
65
65
|
rule: schema.number().required().min(0),
|
|
66
66
|
},
|
|
67
|
-
|
|
67
|
+
// Integer minor units, like every commerce amount: 1999 is $19.99.
|
|
68
|
+
factory: faker => faker.number.int({ min: 1000, max: 50000 }),
|
|
68
69
|
},
|
|
69
70
|
|
|
70
71
|
currency: {
|
|
@@ -84,7 +85,7 @@ export default defineModel({
|
|
|
84
85
|
validation: {
|
|
85
86
|
rule: schema.number().min(0),
|
|
86
87
|
},
|
|
87
|
-
factory: faker => faker.number.int({ min:
|
|
88
|
+
factory: faker => faker.number.int({ min: 0, max: 4000 }),
|
|
88
89
|
},
|
|
89
90
|
|
|
90
91
|
discountAmount: {
|
|
@@ -94,7 +95,7 @@ export default defineModel({
|
|
|
94
95
|
validation: {
|
|
95
96
|
rule: schema.number().min(0),
|
|
96
97
|
},
|
|
97
|
-
factory: faker => faker.number.int({ min: 0, max:
|
|
98
|
+
factory: faker => faker.number.int({ min: 0, max: 2000 }),
|
|
98
99
|
},
|
|
99
100
|
|
|
100
101
|
deliveryFee: {
|
|
@@ -104,7 +105,7 @@ export default defineModel({
|
|
|
104
105
|
validation: {
|
|
105
106
|
rule: schema.number().min(0),
|
|
106
107
|
},
|
|
107
|
-
factory: faker => faker.
|
|
108
|
+
factory: faker => faker.helpers.arrayElement([0, 299, 499, 799]),
|
|
108
109
|
},
|
|
109
110
|
|
|
110
111
|
tipAmount: {
|
|
@@ -114,7 +115,7 @@ export default defineModel({
|
|
|
114
115
|
validation: {
|
|
115
116
|
rule: schema.number().min(0),
|
|
116
117
|
},
|
|
117
|
-
factory: faker => faker.number.int({ min: 0, max:
|
|
118
|
+
factory: faker => faker.number.int({ min: 0, max: 1500 }),
|
|
118
119
|
},
|
|
119
120
|
|
|
120
121
|
orderType: {
|
|
@@ -36,7 +36,8 @@ export default defineModel({
|
|
|
36
36
|
min: 'Price cannot be negative',
|
|
37
37
|
},
|
|
38
38
|
},
|
|
39
|
-
|
|
39
|
+
// Integer minor units, in the same range as Product.price.
|
|
40
|
+
factory: faker => faker.number.int({ min: 100, max: 10000 }),
|
|
40
41
|
},
|
|
41
42
|
|
|
42
43
|
special_instructions: {
|
|
@@ -13,14 +13,22 @@ export default defineModel({
|
|
|
13
13
|
// tell "considered" from "nobody thought about it" (stacksjs/stacks#2375).
|
|
14
14
|
ownership: false,
|
|
15
15
|
|
|
16
|
+
// A SKU identifies a variant within its product. Unique per product rather
|
|
17
|
+
// than globally, because two imported stores (or two products of one) can
|
|
18
|
+
// legitimately reuse a code. NULLs never collide, so variants without a SKU
|
|
19
|
+
// are unconstrained on every dialect.
|
|
20
|
+
indexes: [
|
|
21
|
+
{ name: 'product_variants_product_sku_unique', columns: ['product_id', 'sku'], unique: true },
|
|
22
|
+
],
|
|
23
|
+
|
|
16
24
|
traits: {
|
|
17
25
|
useUuid: true,
|
|
18
26
|
useTimestamps: true,
|
|
19
27
|
useSearch: {
|
|
20
|
-
displayable: ['id', 'productId', 'variant', 'type', 'description', 'options', 'status'],
|
|
21
|
-
searchable: ['variant', 'type', 'description', 'options'],
|
|
22
|
-
sortable: ['createdAt', 'updatedAt', 'variant', 'type', 'status'],
|
|
23
|
-
filterable: ['productId', 'type', 'status'],
|
|
28
|
+
displayable: ['id', 'productId', 'variant', 'type', 'sku', 'price', 'compareAtPrice', 'inventoryCount', 'description', 'options', 'status'],
|
|
29
|
+
searchable: ['variant', 'type', 'sku', 'description', 'options'],
|
|
30
|
+
sortable: ['createdAt', 'updatedAt', 'variant', 'type', 'status', 'price', 'inventoryCount'],
|
|
31
|
+
filterable: ['productId', 'type', 'status', 'sku'],
|
|
24
32
|
},
|
|
25
33
|
|
|
26
34
|
useSeeder: {
|
|
@@ -109,6 +117,65 @@ export default defineModel({
|
|
|
109
117
|
},
|
|
110
118
|
factory: faker => faker.helpers.arrayElement(['active', 'inactive', 'draft']),
|
|
111
119
|
},
|
|
120
|
+
|
|
121
|
+
sku: {
|
|
122
|
+
order: 8,
|
|
123
|
+
fillable: true,
|
|
124
|
+
nullable: true,
|
|
125
|
+
validation: {
|
|
126
|
+
rule: schema.string().max(100),
|
|
127
|
+
message: {
|
|
128
|
+
max: 'SKU must have a maximum of 100 characters',
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
factory: faker => faker.string.alphanumeric(10).toUpperCase(),
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
// Money is integer minor units (cents), never a float. NULL means the
|
|
135
|
+
// variant has no price of its own and inherits `products.price`.
|
|
136
|
+
price: {
|
|
137
|
+
order: 9,
|
|
138
|
+
fillable: true,
|
|
139
|
+
nullable: true,
|
|
140
|
+
validation: {
|
|
141
|
+
rule: schema.number().integer().min(0),
|
|
142
|
+
message: {
|
|
143
|
+
min: 'Price must be at least 0',
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
factory: faker => faker.number.int({ min: 100, max: 10000 }),
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
// The "was" price shown struck through, in minor units. NULL when the
|
|
150
|
+
// variant is not on sale.
|
|
151
|
+
compareAtPrice: {
|
|
152
|
+
order: 10,
|
|
153
|
+
fillable: true,
|
|
154
|
+
nullable: true,
|
|
155
|
+
validation: {
|
|
156
|
+
rule: schema.number().integer().min(0),
|
|
157
|
+
message: {
|
|
158
|
+
min: 'Compare-at price must be at least 0',
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
// Above the factory's highest `price`, so a seeded sale is always a discount.
|
|
162
|
+
factory: faker => faker.datatype.boolean() ? faker.number.int({ min: 10001, max: 20000 }) : null,
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
// Units in stock. NULL means stock is not tracked for this variant,
|
|
166
|
+
// which is different from 0 (tracked and sold out).
|
|
167
|
+
inventoryCount: {
|
|
168
|
+
order: 11,
|
|
169
|
+
fillable: true,
|
|
170
|
+
nullable: true,
|
|
171
|
+
validation: {
|
|
172
|
+
rule: schema.number().integer().min(0),
|
|
173
|
+
message: {
|
|
174
|
+
min: 'Inventory count must be at least 0',
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
factory: faker => faker.number.int({ min: 0, max: 100 }),
|
|
178
|
+
},
|
|
112
179
|
},
|
|
113
180
|
|
|
114
181
|
dashboard: {
|
|
@@ -41,9 +41,10 @@ export default defineModel({
|
|
|
41
41
|
order: 2,
|
|
42
42
|
fillable: true,
|
|
43
43
|
validation: {
|
|
44
|
-
|
|
44
|
+
// Integer minor units: the smallest amount is one cent, not 0.01 of one.
|
|
45
|
+
rule: schema.number().integer().required().min(1),
|
|
45
46
|
},
|
|
46
|
-
factory: faker => faker.number.int({ min:
|
|
47
|
+
factory: faker => faker.number.int({ min: 500, max: 50000 }),
|
|
47
48
|
},
|
|
48
49
|
|
|
49
50
|
status: {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Money on the storefront's server-rendered pages.
|
|
3
|
+
*
|
|
4
|
+
* `cart_items.unit_price`, `cart_items.total_price`, `order_items.price`
|
|
5
|
+
* and `orders.total_amount` hold integer minor units (1999 is $19.99), as
|
|
6
|
+
* every commerce amount does. The cart, checkout and order pages used to
|
|
7
|
+
* print them with a hardcoded `$` and `.toFixed(2)`, which showed a 1999
|
|
8
|
+
* cent product as $1999.00 and ignored the cart's currency. They format
|
|
9
|
+
* through `@stacksjs/commerce/money` here instead.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { formatCurrency } from '@stacksjs/commerce/money'
|
|
13
|
+
import { totalsFor } from '../Actions/Storefront/_shipping'
|
|
14
|
+
|
|
15
|
+
/** The currency a cart or order row was priced in, or USD when it has none. */
|
|
16
|
+
export function storefrontCurrency(stored: unknown): string {
|
|
17
|
+
const code = String(stored ?? '').trim().toUpperCase()
|
|
18
|
+
return /^[A-Z]{3}$/.test(code) ? code : 'USD'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A stored minor-unit amount, formatted for the shopper: `1999, 'USD'` is `"$19.99"`. */
|
|
22
|
+
export function storefrontMoney(minor: number, currency: string): string {
|
|
23
|
+
return formatCurrency(minor, currency)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface StorefrontSummaryLabels {
|
|
27
|
+
subtotal: number
|
|
28
|
+
shipping: number
|
|
29
|
+
total: number
|
|
30
|
+
subtotalLabel: string
|
|
31
|
+
shippingLabel: string
|
|
32
|
+
totalLabel: string
|
|
33
|
+
freeShippingNote: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The running totals the cart and checkout pages show, from the lines'
|
|
38
|
+
* subtotal in minor units. Shipping comes from the same rule the
|
|
39
|
+
* place-order action charges with, so the two cannot disagree.
|
|
40
|
+
*/
|
|
41
|
+
export function storefrontSummary(subtotal: number, currency: string): StorefrontSummaryLabels {
|
|
42
|
+
const totals = totalsFor(subtotal, currency)
|
|
43
|
+
return {
|
|
44
|
+
subtotal: totals.subtotal,
|
|
45
|
+
shipping: totals.shipping,
|
|
46
|
+
total: totals.total,
|
|
47
|
+
subtotalLabel: storefrontMoney(totals.subtotal, currency),
|
|
48
|
+
shippingLabel: totals.shipping === 0 ? 'Free' : storefrontMoney(totals.shipping, currency),
|
|
49
|
+
totalLabel: storefrontMoney(totals.total, currency),
|
|
50
|
+
freeShippingNote: totals.remainingForFree > 0
|
|
51
|
+
? `Add ${storefrontMoney(totals.remainingForFree, currency)} more for free shipping.`
|
|
52
|
+
: '',
|
|
53
|
+
}
|
|
54
|
+
}
|
package/ide/vscode/package.json
CHANGED
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.40",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@iconify-json/f7": "^1.2.2",
|
|
57
57
|
"@iconify-json/hugeicons": "^1.2.27",
|
|
58
|
-
"@stacksjs/mobile": "^0.75.
|
|
58
|
+
"@stacksjs/mobile": "^0.75.40",
|
|
59
59
|
"@stacksjs/sanitizer": "^0.2.113",
|
|
60
60
|
"ts-qr-codes": "^0.1.8"
|
|
61
61
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script client>
|
|
2
2
|
import type { AnalyticsRange, CampaignAnalyticsResponse } from '../../../../functions/analytics'
|
|
3
3
|
import { fetchCampaignAnalytics } from '../../../../functions/analytics'
|
|
4
|
+
import { formatCurrency } from '@stacksjs/commerce/money'
|
|
4
5
|
import { pushToast } from '../../../../functions/toasts'
|
|
5
6
|
|
|
6
7
|
const analytics = state<CampaignAnalyticsResponse | null>(null)
|
|
@@ -15,10 +16,9 @@ function formatNumber(value: number | null | undefined): string {
|
|
|
15
16
|
: new Intl.NumberFormat().format(value)
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
: new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value)
|
|
19
|
+
/** Campaign budgets and spend are integer minor units of `currency` (1999 is $19.99). */
|
|
20
|
+
function formatMoney(value: number | null, currency: string): string {
|
|
21
|
+
return value === null ? 'Not recorded' : formatCurrency(value, currency)
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function formatRate(value: number | null | undefined): string {
|
|
@@ -165,7 +165,7 @@ onMount(() => {
|
|
|
165
165
|
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatNumber(campaign.opens) }}</td>
|
|
166
166
|
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatNumber(campaign.clicks) }}</td>
|
|
167
167
|
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatNumber(campaign.conversions) }}</td>
|
|
168
|
-
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{
|
|
168
|
+
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatMoney(campaign.spent, campaign.currency) }}</td>
|
|
169
169
|
</tr>
|
|
170
170
|
</template>
|
|
171
171
|
</tbody>
|
|
@@ -186,8 +186,8 @@ onMount(() => {
|
|
|
186
186
|
<p class="text-gray-500 text-xs dark:text-neutral-400">{{ spend.campaigns }} campaigns</p>
|
|
187
187
|
</div>
|
|
188
188
|
<div class="text-right">
|
|
189
|
-
<p class="font-medium text-gray-900 text-sm dark:text-white">{{
|
|
190
|
-
<p class="text-gray-500 text-xs dark:text-neutral-400">of {{
|
|
189
|
+
<p class="font-medium text-gray-900 text-sm dark:text-white">{{ formatMoney(spend.spent, spend.currency) }}</p>
|
|
190
|
+
<p class="text-gray-500 text-xs dark:text-neutral-400">of {{ formatMoney(spend.budget, spend.currency) }}</p>
|
|
191
191
|
</div>
|
|
192
192
|
</div>
|
|
193
193
|
</template>
|
|
@@ -233,7 +233,7 @@ onMount(() => {
|
|
|
233
233
|
<td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-300">{{ channel.currency }}</td>
|
|
234
234
|
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ channel.campaigns }}</td>
|
|
235
235
|
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatNumber(channel.sent) }}</td>
|
|
236
|
-
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{
|
|
236
|
+
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatMoney(channel.spent, channel.currency) }}</td>
|
|
237
237
|
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatRate(channel.clickRate) }}</td>
|
|
238
238
|
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatRate(channel.conversionRate) }}</td>
|
|
239
239
|
</tr>
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
<script client>
|
|
2
2
|
import type { AnalyticsRange, SalesAnalyticsResponse } from '../../../../functions/analytics'
|
|
3
|
+
import { formatCurrency } from '@stacksjs/commerce/money'
|
|
3
4
|
import { fetchSalesAnalytics } from '../../../../functions/analytics'
|
|
4
5
|
import { pushToast } from '../../../../functions/toasts'
|
|
5
6
|
|
|
7
|
+
// Every amount on this page is a sum of order, payment and line-item columns,
|
|
8
|
+
// which hold integer minor units. formatCurrency converts them for display
|
|
9
|
+
// (stacksjs/stacks#2851).
|
|
6
10
|
const analytics = state<SalesAnalyticsResponse | null>(null)
|
|
7
11
|
const analyticsRange = state<AnalyticsRange>('month')
|
|
8
12
|
const dateRangeDisplay = state('Last 30 days')
|
|
@@ -13,9 +17,6 @@ function formatNumber(value: number): string {
|
|
|
13
17
|
return new Intl.NumberFormat().format(value)
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
function formatCurrency(value: number, currency: string): string {
|
|
17
|
-
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value)
|
|
18
|
-
}
|
|
19
20
|
|
|
20
21
|
function formatDateRange(start: string, end: string): string {
|
|
21
22
|
const formatter = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script client>
|
|
2
2
|
import type { BillingSubscription } from '../../../../functions/billing/dashboard'
|
|
3
|
+
import { formatCurrency } from '@stacksjs/commerce/money'
|
|
3
4
|
|
|
4
5
|
const subscription = useReactiveProp<BillingSubscription | null>('subscription', null)
|
|
5
6
|
|
|
@@ -16,7 +17,9 @@ function amount(): string {
|
|
|
16
17
|
return 'Not reported'
|
|
17
18
|
if (!currency)
|
|
18
19
|
return `${new Intl.NumberFormat().format(value)} minor units`
|
|
19
|
-
|
|
20
|
+
// Provider amounts are integer minor units: dividing by 100 was a hundred
|
|
21
|
+
// times too small for a zero-decimal currency such as JPY.
|
|
22
|
+
return formatCurrency(value, currency)
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
function date(value: string): string {
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
<script client>
|
|
2
2
|
import type { CouponRecord } from '../../../../app/Actions/Dashboard/Commerce/coupon-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 CouponRecord | null)
|
|
7
|
+
const currency = useReactiveProp('currency', 'USD')
|
|
6
8
|
const emit = defineEmits()
|
|
7
9
|
|
|
10
|
+
// Fixed discounts and order thresholds are integer minor units.
|
|
8
11
|
function money(value: number): string {
|
|
9
|
-
return
|
|
12
|
+
return formatCurrency(value, currency())
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
function discount(current: CouponRecord | null): string {
|
|
@@ -4,10 +4,12 @@ import type {
|
|
|
4
4
|
CouponRecord,
|
|
5
5
|
CouponStatus,
|
|
6
6
|
} from '../../../../app/Actions/Dashboard/Commerce/coupon-records'
|
|
7
|
+
import { minorToInput, moneyInputError, moneyInputStep, parseMoneyInput } from '@stacksjs/commerce/money'
|
|
7
8
|
|
|
8
9
|
const open = useReactiveProp('open', false)
|
|
9
10
|
const mode = useReactiveProp('mode', 'add')
|
|
10
11
|
const record = useReactiveProp('record', null as CouponRecord | null)
|
|
12
|
+
const currency = useReactiveProp('currency', 'USD')
|
|
11
13
|
const busy = useReactiveProp('busy', false)
|
|
12
14
|
const errors = useReactiveProp('errors', {} as Record<string, string>)
|
|
13
15
|
const emit = defineEmits()
|
|
@@ -40,9 +42,13 @@ effect(() => {
|
|
|
40
42
|
status.set(current?.status || 'Active')
|
|
41
43
|
isActive.set(current?.isActive ?? true)
|
|
42
44
|
discountType.set(current?.discountType || 'percentage')
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
// A percentage is shown as stored; money fields are stored as integer
|
|
46
|
+
// minor units and shown as the decimal.
|
|
47
|
+
discountValue.set(current
|
|
48
|
+
? current.discountType === 'fixed_amount' ? minorToInput(current.discountValue, currency()) : String(current.discountValue)
|
|
49
|
+
: '10')
|
|
50
|
+
minOrderAmount.set(current?.minOrderAmount ? minorToInput(current.minOrderAmount, currency()) : '')
|
|
51
|
+
maxDiscountAmount.set(current?.maxDiscountAmount ? minorToInput(current.maxDiscountAmount, currency()) : '')
|
|
46
52
|
freeProductId.set(current?.freeProductId || '')
|
|
47
53
|
usageLimit.set(current?.usageLimit ? String(current.usageLimit) : '')
|
|
48
54
|
startDate.set(current?.startDate || '')
|
|
@@ -56,8 +62,24 @@ function optionalNumber(value: string): number | undefined {
|
|
|
56
62
|
return Number.isFinite(result) ? result : undefined
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
function optionalMoney(value: string): number | undefined {
|
|
66
|
+
return parseMoneyInput(value, currency(), { required: false }) ?? undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function discountValueValid(): boolean {
|
|
70
|
+
if (discountType() === 'fixed_amount')
|
|
71
|
+
return !moneyInputError(discountValue(), currency(), { min: 1 })
|
|
72
|
+
const rate = Number(discountValue())
|
|
73
|
+
return Number.isFinite(rate) && rate >= 0.01 && rate <= 100
|
|
74
|
+
}
|
|
75
|
+
|
|
59
76
|
function valid(): boolean {
|
|
60
|
-
return Boolean(
|
|
77
|
+
return Boolean(
|
|
78
|
+
code().trim()
|
|
79
|
+
&& discountValueValid()
|
|
80
|
+
&& !moneyInputError(minOrderAmount(), currency(), { required: false })
|
|
81
|
+
&& !moneyInputError(maxDiscountAmount(), currency(), { required: false }),
|
|
82
|
+
)
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
function submit(): void {
|
|
@@ -69,9 +91,11 @@ function submit(): void {
|
|
|
69
91
|
status: status(),
|
|
70
92
|
isActive: isActive(),
|
|
71
93
|
discountType: discountType(),
|
|
72
|
-
discountValue:
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
discountValue: discountType() === 'fixed_amount'
|
|
95
|
+
? parseMoneyInput(discountValue(), currency(), { min: 1 })
|
|
96
|
+
: Number(discountValue()),
|
|
97
|
+
minOrderAmount: optionalMoney(minOrderAmount()),
|
|
98
|
+
maxDiscountAmount: optionalMoney(maxDiscountAmount()),
|
|
75
99
|
freeProductId: freeProductId().trim() || undefined,
|
|
76
100
|
usageLimit: optionalNumber(usageLimit()),
|
|
77
101
|
startDate: startDate() || undefined,
|
|
@@ -103,9 +127,9 @@ function errorMessages(): string[] {
|
|
|
103
127
|
<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="Scheduled">Scheduled</option><option value="Expired">Expired</option></select></label>
|
|
104
128
|
<label class="block sm:col-span-2"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Description</span><textarea x-model="description" rows="3" 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"></textarea></label>
|
|
105
129
|
<label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Discount type</span><select x-model="discountType" 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="percentage">Percentage</option><option value="fixed_amount">Fixed amount</option></select></label>
|
|
106
|
-
<label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Discount value</span><input x-model="discountValue" type="number" min="0.01" 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>
|
|
107
|
-
<label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Minimum order amount</span><input x-model="minOrderAmount" type="number" min="0" step="
|
|
108
|
-
<label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Maximum discount amount</span><input x-model="maxDiscountAmount" type="number" min="0" step="
|
|
130
|
+
<label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Discount value</span><input x-model="discountValue" type="number" :min="discountType() === 'fixed_amount' ? '0' : '0.01'" :step="discountType() === 'fixed_amount' ? moneyInputStep(currency()) : '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>
|
|
131
|
+
<label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Minimum order amount</span><input x-model="minOrderAmount" type="number" min="0" :step="moneyInputStep(currency())" placeholder="No minimum" 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
|
+
<label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Maximum discount amount</span><input x-model="maxDiscountAmount" type="number" min="0" :step="moneyInputStep(currency())" placeholder="No maximum" 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>
|
|
109
133
|
<label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Usage limit</span><input x-model="usageLimit" type="number" min="1" step="1" placeholder="No limit" 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>
|
|
110
134
|
<label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Free product ID</span><input x-model="freeProductId" type="text" placeholder="Optional" 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>
|
|
111
135
|
<label class="block"><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Start date</span><input x-model="startDate" 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>
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from '../../../../app/Actions/Dashboard/Commerce/coupon-records'
|
|
8
8
|
import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
|
|
9
9
|
import { pushToast } from '../../../../functions/toasts'
|
|
10
|
+
import { minorToInput } from '@stacksjs/commerce/money'
|
|
10
11
|
|
|
11
12
|
interface CouponIndexResponse {
|
|
12
13
|
records: CouponRecord[]
|
|
@@ -104,7 +105,8 @@ function exportHref(): string {
|
|
|
104
105
|
record.status,
|
|
105
106
|
record.isActive ? 'Enabled' : 'Disabled',
|
|
106
107
|
record.discountType,
|
|
107
|
-
|
|
108
|
+
// A fixed amount is stored in minor units; the CSV carries the decimal.
|
|
109
|
+
record.discountType === 'fixed_amount' ? minorToInput(record.discountValue, 'USD') : String(record.discountValue),
|
|
108
110
|
String(record.usageCount),
|
|
109
111
|
record.usageLimit === null ? '' : String(record.usageLimit),
|
|
110
112
|
record.startDate,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<script client>
|
|
2
2
|
import type { CouponRecord } from '../../../../app/Actions/Dashboard/Commerce/coupon-records'
|
|
3
|
+
import { formatCurrency } from '@stacksjs/commerce/money'
|
|
3
4
|
|
|
4
5
|
const records = useReactiveProp('records', [] as CouponRecord[])
|
|
5
6
|
const loading = useReactiveProp('loading', false)
|
|
7
|
+
const currency = useReactiveProp('currency', 'USD')
|
|
6
8
|
const emit = defineEmits()
|
|
7
9
|
|
|
8
10
|
function statusClass(status: string): string {
|
|
@@ -13,10 +15,11 @@ function statusClass(status: string): string {
|
|
|
13
15
|
return 'bg-gray-100 text-gray-700 dark:bg-neutral-700 dark:text-neutral-300'
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
// A percentage is a rate, not money; a fixed amount is integer minor units.
|
|
16
19
|
function discount(record: CouponRecord): string {
|
|
17
20
|
return record.discountType === 'percentage'
|
|
18
21
|
? `${record.discountValue}%`
|
|
19
|
-
:
|
|
22
|
+
: formatCurrency(record.discountValue, currency())
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
function date(value: string): string {
|
|
@@ -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 open = useReactiveProp('open', false)
|
|
5
6
|
const record = useReactiveProp('record', null as CommerceCustomerRecord | null)
|
|
@@ -7,12 +8,7 @@ const currency = useReactiveProp('currency', 'USD')
|
|
|
7
8
|
const emit = defineEmits()
|
|
8
9
|
|
|
9
10
|
function formatMoney(value: number): string {
|
|
10
|
-
|
|
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 {
|