@stacksjs/defaults 0.72.98 → 0.72.99
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-auth/SKILL.md +35 -5
- package/ai/skills/stacks-config/SKILL.md +11 -1
- package/ai/skills/stacks-desktop/SKILL.md +46 -0
- package/ai/skills/stacks-env/SKILL.md +26 -0
- package/ai/skills/stacks-events/SKILL.md +76 -60
- package/ai/skills/stacks-jobs/SKILL.md +3 -0
- package/ai/skills/stacks-listeners/SKILL.md +39 -9
- package/ai/skills/stacks-middleware/SKILL.md +50 -12
- package/ai/skills/stacks-queue/SKILL.md +5 -0
- package/ai/skills/stacks-router/SKILL.md +17 -7
- package/ai/skills/stacks-scheduler/SKILL.md +1 -1
- package/app/Actions/Dashboard/Commerce/CommerceCustomersAction.ts +1 -1
- package/app/Actions/Dashboard/Commerce/CommerceDeliveryAction.ts +1 -1
- package/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts +1 -1
- package/app/Actions/Dashboard/Commerce/CommercePosAction.ts +1 -1
- package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +1 -1
- package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +1 -1
- package/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts +1 -1
- package/app/Actions/Dashboard/Content/content-input.ts +2 -2
- package/app/Actions/Dashboard/Content/post-input.ts +4 -4
- package/app/Actions/Dashboard/Email/inbox-request.ts +1 -1
- package/app/Actions/Dashboard/Email/mail-preference.ts +1 -1
- package/app/Actions/Dashboard/Marketing/CampaignIndexAction.ts +1 -1
- package/app/Actions/Dashboard/Marketing/CampaignStoreAction.ts +1 -1
- package/app/Actions/Dashboard/Marketing/CampaignUpdateAction.ts +1 -1
- package/app/Actions/Dashboard/Teams/TeamInvitationDestroyAction.ts +2 -2
- package/app/Actions/Dashboard/Teams/TeamInvitationResendAction.ts +2 -2
- package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +5 -5
- package/app/Actions/Dashboard/Teams/TeamMemberDestroyAction.ts +2 -2
- package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +2 -2
- package/app/Actions/Dashboard/Teams/TeamPeopleIndexAction.ts +2 -2
- package/app/Actions/Dashboard/Teams/team-invitation-delivery.ts +2 -2
- package/app/Events.ts +9 -4
- package/app/Gates.ts +83 -85
- package/app/Listener.ts +34 -81
- package/app/Middleware.ts +22 -7
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/project/storage/framework/server/tsconfig.docker.json +1 -2
- package/resources/components/Buttons/Counter.stx +30 -21
- package/resources/components/Dashboard/Analytics/WebAnalyticsDashboard.stx +1 -3
- package/resources/components/Dashboard/Auth/ForgotPasswordDashboard.stx +1 -1
- package/resources/components/Dashboard/Auth/LoginDashboard.stx +2 -1
- package/resources/components/Dashboard/Auth/RegisterDashboard.stx +2 -1
- package/resources/components/Dashboard/Auth/ResetPasswordDashboard.stx +1 -1
- package/resources/components/Dashboard/Ci/CiDashboard.stx +9 -9
- package/resources/components/Dashboard/Ci/CiRunHistoryDrawer.stx +7 -7
- package/resources/components/Dashboard/Commerce/CommerceOverviewCharts.stx +2 -1
- package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +1 -1
- package/resources/components/Dashboard/Content/BlogDashboard.stx +37 -15
- package/resources/components/Dashboard/Content/ContentDashboard.stx +3 -26
- package/resources/components/Dashboard/Email/EmailActivityDashboard.stx +2 -15
- package/resources/components/Dashboard/Environment/EnvironmentDashboard.stx +9 -1
- package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +15 -13
- package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +10 -8
- package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +10 -10
- package/resources/components/Dashboard/Management/PermissionsDashboard.stx +3 -3
- package/resources/components/Dashboard/Modals/Popups/Alert.stx +4 -1
- package/resources/components/Dashboard/Navbar.stx +1 -1
- package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +11 -6
- package/resources/components/Dashboard/Settings/SettingsDashboard.stx +2 -2
- package/resources/components/Dashboard/Transaction/index.stx +6 -6
- package/resources/components/Dashboard/UI/Avatar.stx +2 -2
- package/resources/components/Dashboard/UI/DropdownItem.stx +4 -4
- package/resources/components/Dashboard/UI/WindowControls.stx +17 -2
- package/resources/components/Docs/Demo/ComboboxDemo.stx +8 -3
- package/resources/components/Docs/Demo/DropdownDemo.stx +3 -3
- package/resources/components/Docs/Demo/StepperDemo.stx +1 -1
- package/resources/components/Forum/ForumLayout.stx +1 -1
- package/resources/components/Forum/ForumReplyForm.stx +1 -1
- package/resources/components/Marketing/ComingSoon.stx +5 -5
- package/resources/components/Storefront/CartDrawer.stx +44 -22
- package/resources/components/Storefront/ProductGallery.stx +3 -2
- package/resources/components/Storefront/QuantitySelector.stx +12 -7
- package/resources/functions/storefront/cart-cookie.ts +58 -0
- package/resources/layouts/storefront.stx +3 -2
- package/resources/views/cart.stx +19 -3
- package/resources/views/checkout/contact.stx +14 -3
- package/resources/views/checkout/payment.stx +14 -3
- package/resources/views/checkout/shipping.stx +14 -3
- package/resources/views/cms/blocks/form.stx +16 -11
- package/resources/views/cms/page.stx +28 -0
- package/resources/views/coming-soon.stx +0 -6
- package/resources/views/orders/[id].stx +19 -3
- package/views/dashboard/composables/index.ts +1 -1
- package/views/dashboard/composables/useChart.ts +26 -14
- package/views/dashboard/layouts/default.stx +42 -25
|
@@ -63,24 +63,44 @@
|
|
|
63
63
|
|
|
64
64
|
<script data-stx-scoped>
|
|
65
65
|
;(() => {
|
|
66
|
+
/*
|
|
67
|
+
* The two properties this block puts on `window`, named in one place.
|
|
68
|
+
*
|
|
69
|
+
* A cast rather than a `declare global`: an ambient declaration has to sit at
|
|
70
|
+
* the top level of a module, and this block is not one. A cast is an ordinary
|
|
71
|
+
* annotation, which is what the rest of this script already relies on.
|
|
72
|
+
*/
|
|
73
|
+
const w = window as Window & {
|
|
74
|
+
/** Set by this block so a fragment swap cannot stack a second set of listeners. */
|
|
75
|
+
__stxCartWired?: boolean
|
|
76
|
+
/** The hooks this drawer publishes for other components to drive it. */
|
|
77
|
+
stxCart?: { open: () => void, shut: () => void, refresh: () => Promise<void> }
|
|
78
|
+
}
|
|
79
|
+
|
|
66
80
|
// Idempotency guard. SPA fragment swaps re-inject scoped scripts when
|
|
67
81
|
// the cart link is followed; without this, every navigation stacked
|
|
68
82
|
// another set of click listeners on [data-stx-add-to-cart] buttons,
|
|
69
83
|
// and a single click would fire two/three/N XHRs — the user-visible
|
|
70
84
|
// tell was a brief flicker as the drawer opened, refreshed, then
|
|
71
85
|
// refreshed again from the duplicate handlers.
|
|
72
|
-
if (
|
|
73
|
-
|
|
86
|
+
if (w.__stxCartWired) return
|
|
87
|
+
w.__stxCartWired = true
|
|
74
88
|
|
|
75
|
-
const root = document.querySelector('[data-stx-cart-drawer]')
|
|
89
|
+
const root = document.querySelector<HTMLElement>('[data-stx-cart-drawer]')
|
|
76
90
|
if (!root) return
|
|
77
|
-
const backdrop = root.querySelector('[data-stx-cart-backdrop]')
|
|
78
|
-
const panel = root.querySelector('[data-stx-cart-panel]')
|
|
79
|
-
const closeBtn = root.querySelector('[data-stx-cart-close]')
|
|
80
|
-
const list = root.querySelector('[data-stx-cart-list]')
|
|
81
|
-
const empty = root.querySelector('[data-stx-cart-empty]')
|
|
82
|
-
const footer = root.querySelector('[data-stx-cart-footer]')
|
|
83
|
-
const subtotalEl = root.querySelector('[data-stx-cart-subtotal]')
|
|
91
|
+
const backdrop = root.querySelector<HTMLElement>('[data-stx-cart-backdrop]')
|
|
92
|
+
const panel = root.querySelector<HTMLElement>('[data-stx-cart-panel]')
|
|
93
|
+
const closeBtn = root.querySelector<HTMLElement>('[data-stx-cart-close]')
|
|
94
|
+
const list = root.querySelector<HTMLElement>('[data-stx-cart-list]')
|
|
95
|
+
const empty = root.querySelector<HTMLElement>('[data-stx-cart-empty]')
|
|
96
|
+
const footer = root.querySelector<HTMLElement>('[data-stx-cart-footer]')
|
|
97
|
+
const subtotalEl = root.querySelector<HTMLElement>('[data-stx-cart-subtotal]')
|
|
98
|
+
|
|
99
|
+
// `open()` and `shut()` drive both of these on every call. A drawer missing
|
|
100
|
+
// either is not a drawer, so this leaves rather than throwing halfway
|
|
101
|
+
// through the first open.
|
|
102
|
+
if (!backdrop || !panel)
|
|
103
|
+
return
|
|
84
104
|
|
|
85
105
|
// Reserve the scrollbar gutter on <html> so locking page scroll while
|
|
86
106
|
// the drawer is open doesn't cause the page width to jump (~17px on
|
|
@@ -89,10 +109,10 @@
|
|
|
89
109
|
// because the scrollbar disappeared.
|
|
90
110
|
document.documentElement.style.scrollbarGutter = 'stable'
|
|
91
111
|
|
|
92
|
-
let lastFocus = null
|
|
112
|
+
let lastFocus: HTMLElement | null = null
|
|
93
113
|
|
|
94
|
-
|
|
95
|
-
lastFocus = document.activeElement
|
|
114
|
+
const open = () => {
|
|
115
|
+
lastFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
96
116
|
root.style.pointerEvents = 'auto'
|
|
97
117
|
root.setAttribute('aria-hidden', 'false')
|
|
98
118
|
backdrop.style.opacity = '1'
|
|
@@ -101,7 +121,7 @@
|
|
|
101
121
|
// Move focus into the panel so Esc + Tab work as expected.
|
|
102
122
|
requestAnimationFrame(() => { panel.focus({ preventScroll: true }) })
|
|
103
123
|
}
|
|
104
|
-
|
|
124
|
+
const shut = () => {
|
|
105
125
|
root.style.pointerEvents = 'none'
|
|
106
126
|
root.setAttribute('aria-hidden', 'true')
|
|
107
127
|
backdrop.style.opacity = '0'
|
|
@@ -118,11 +138,12 @@
|
|
|
118
138
|
if (e.key === 'Escape' && root.getAttribute('aria-hidden') === 'false') shut()
|
|
119
139
|
})
|
|
120
140
|
|
|
121
|
-
function escapeHtml(s) {
|
|
122
|
-
|
|
141
|
+
function escapeHtml(s: string) {
|
|
142
|
+
const entities: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
143
|
+
return String(s ?? '').replace(/[&<>"']/g, c => entities[c] ?? c)
|
|
123
144
|
}
|
|
124
145
|
|
|
125
|
-
|
|
146
|
+
const render = (items: Array<{ slug?: string, name?: string, image?: string, qty?: number | string, price?: number | string, lineTotal?: number | string }> | null | undefined, subtotal: number | string) => {
|
|
126
147
|
if (!list || !empty || !footer) return
|
|
127
148
|
if (!items || items.length === 0) {
|
|
128
149
|
list.classList.add('hidden')
|
|
@@ -156,13 +177,14 @@
|
|
|
156
177
|
if (subtotalEl) subtotalEl.textContent = '$' + (Number(subtotal) || 0).toFixed(2)
|
|
157
178
|
}
|
|
158
179
|
|
|
159
|
-
let inflightRefresh = null
|
|
160
|
-
async
|
|
180
|
+
let inflightRefresh: Promise<void> | null = null
|
|
181
|
+
const refresh = async (): Promise<void> => {
|
|
161
182
|
// Coalesce concurrent refreshes — if a click fires two refresh() in a
|
|
162
183
|
// row (e.g. add-to-cart + open both call refresh), they reuse the
|
|
163
184
|
// same network request and the drawer doesn't briefly toggle between
|
|
164
185
|
// empty and populated.
|
|
165
|
-
if (inflightRefresh)
|
|
186
|
+
if (inflightRefresh)
|
|
187
|
+
return inflightRefresh
|
|
166
188
|
inflightRefresh = (async () => {
|
|
167
189
|
try {
|
|
168
190
|
const res = await fetch('/api/cart', {
|
|
@@ -174,7 +196,7 @@
|
|
|
174
196
|
render(data.items || [], data.subtotal || 0)
|
|
175
197
|
const badge = document.querySelector('[data-stx-cart-count]')
|
|
176
198
|
if (badge) {
|
|
177
|
-
const total = (data.items || []).reduce((
|
|
199
|
+
const total = (data.items || []).reduce((sum: number, item: { qty?: number | string }) => sum + (Number(item.qty) || 0), 0)
|
|
178
200
|
badge.textContent = String(total)
|
|
179
201
|
badge.classList.toggle('hidden', total === 0)
|
|
180
202
|
}
|
|
@@ -186,7 +208,7 @@
|
|
|
186
208
|
}
|
|
187
209
|
|
|
188
210
|
// Public hooks for other components to call.
|
|
189
|
-
|
|
211
|
+
w.stxCart = {
|
|
190
212
|
open: () => { open(); refresh() },
|
|
191
213
|
shut,
|
|
192
214
|
refresh,
|
|
@@ -64,8 +64,9 @@ const {
|
|
|
64
64
|
// so clicking a thumb in gallery B updated gallery A's hero.
|
|
65
65
|
// 2. SPA fragment swaps re-injected the scoped script and stacked
|
|
66
66
|
// a duplicate listener on every existing thumb.
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
const w = window as Window & { __stxGalleryWired?: boolean }
|
|
68
|
+
if (w.__stxGalleryWired) return
|
|
69
|
+
w.__stxGalleryWired = true
|
|
69
70
|
|
|
70
71
|
document.addEventListener('click', (e) => {
|
|
71
72
|
const target = e.target instanceof Element ? e.target : null
|
|
@@ -43,8 +43,13 @@ const {
|
|
|
43
43
|
// = two listeners per button = doubled increments per click.
|
|
44
44
|
// 2. SPA fragment swaps that re-inject our scoped script don't
|
|
45
45
|
// keep multiplying handlers; the guard below early-returns.
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const w = window as Window & {
|
|
47
|
+
/** Set by this block so a fragment swap cannot stack a second set of listeners. */
|
|
48
|
+
__stxQtyWired?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (w.__stxQtyWired) return
|
|
52
|
+
w.__stxQtyWired = true
|
|
48
53
|
|
|
49
54
|
// Default min/max chosen to match the component's defineProps fallbacks.
|
|
50
55
|
// STX SSR doesn't reliably propagate defineProps default values into
|
|
@@ -53,13 +58,13 @@ const {
|
|
|
53
58
|
// these fallbacks Math.min(0, qty+1) capped every increment at 0 and
|
|
54
59
|
// the +/- buttons appeared dead. Read either the data-* on the wrapper
|
|
55
60
|
// or the input attribute, whichever is non-empty.
|
|
56
|
-
function bound(wrap, kind, fallback) {
|
|
61
|
+
function bound(wrap: HTMLElement, kind: string, fallback: number) {
|
|
57
62
|
const fromData = wrap.getAttribute('data-' + kind)
|
|
58
63
|
if (fromData && fromData.trim() !== '') {
|
|
59
64
|
const n = Number(fromData)
|
|
60
65
|
if (Number.isFinite(n)) return n
|
|
61
66
|
}
|
|
62
|
-
const input = wrap.querySelector('input[type="number"]')
|
|
67
|
+
const input = wrap.querySelector<HTMLInputElement>('input[type="number"]')
|
|
63
68
|
const fromAttr = input && input.getAttribute(kind)
|
|
64
69
|
if (fromAttr && fromAttr.trim() !== '') {
|
|
65
70
|
const n = Number(fromAttr)
|
|
@@ -68,8 +73,8 @@ const {
|
|
|
68
73
|
return fallback
|
|
69
74
|
}
|
|
70
75
|
|
|
71
|
-
function step(wrap, delta) {
|
|
72
|
-
const input = wrap.querySelector('input[type="number"]')
|
|
76
|
+
function step(wrap: HTMLElement, delta: number) {
|
|
77
|
+
const input = wrap.querySelector<HTMLInputElement>('input[type="number"]')
|
|
73
78
|
if (!input) return
|
|
74
79
|
const min = bound(wrap, 'min', 1)
|
|
75
80
|
const max = bound(wrap, 'max', 99)
|
|
@@ -88,7 +93,7 @@ const {
|
|
|
88
93
|
const dec = target.closest('[data-stx-qty-dec]')
|
|
89
94
|
const inc = target.closest('[data-stx-qty-inc]')
|
|
90
95
|
if (!dec && !inc) return
|
|
91
|
-
const wrap = (dec
|
|
96
|
+
const wrap = (dec ?? inc)?.closest<HTMLElement>('[data-stx-qty]')
|
|
92
97
|
if (!wrap) return
|
|
93
98
|
e.preventDefault()
|
|
94
99
|
step(wrap, dec ? -1 : 1)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the storefront cart token out of the request.
|
|
3
|
+
*
|
|
4
|
+
* The cart and the three checkout steps each opened with
|
|
5
|
+
*
|
|
6
|
+
* const token = (typeof requestContext !== 'undefined')
|
|
7
|
+
* ? requestContext.cookie('stacks_cart')
|
|
8
|
+
* : null
|
|
9
|
+
*
|
|
10
|
+
* and `requestContext` is not a binding stx has ever put in a server script's
|
|
11
|
+
* scope. It is the NAME OF A PARAMETER inside the template hydrator, and the
|
|
12
|
+
* page context a server assembles carries `params`, `request` and `method`.
|
|
13
|
+
* So the `typeof` guard was always false, `token` was always null, and every
|
|
14
|
+
* one of those pages rendered its empty-cart branch no matter what was in the
|
|
15
|
+
* cart. The guard is what hid it: without it the pages would have thrown on
|
|
16
|
+
* the first request instead of quietly showing nothing.
|
|
17
|
+
*
|
|
18
|
+
* `request` is really provided, so the cookie is read from its header.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The cookie the storefront stores its cart session token in. */
|
|
22
|
+
export const CART_COOKIE = 'stacks_cart'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The value of `name` in a request's Cookie header, or null.
|
|
26
|
+
*
|
|
27
|
+
* Values are percent-encoded on the way in, so they are decoded here. A
|
|
28
|
+
* malformed encoding yields the raw value rather than throwing, since a
|
|
29
|
+
* cookie is attacker-supplied and a render must not fail on one.
|
|
30
|
+
*/
|
|
31
|
+
export function cookieFromRequest(request: Request | undefined, name: string): string | null {
|
|
32
|
+
const header = request?.headers?.get('cookie')
|
|
33
|
+
if (!header)
|
|
34
|
+
return null
|
|
35
|
+
|
|
36
|
+
for (const part of header.split(';')) {
|
|
37
|
+
const index = part.indexOf('=')
|
|
38
|
+
if (index < 0)
|
|
39
|
+
continue
|
|
40
|
+
if (part.slice(0, index).trim() !== name)
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
const raw = part.slice(index + 1).trim()
|
|
44
|
+
try {
|
|
45
|
+
return decodeURIComponent(raw)
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return raw
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The storefront cart token carried by this request, or null. */
|
|
56
|
+
export function cartTokenFromRequest(request: Request | undefined): string | null {
|
|
57
|
+
return cookieFromRequest(request, CART_COOKIE)
|
|
58
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* badge is correct on first paint, before any client-side JS runs.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
const { cartTokenFromRequest } = require('../functions/storefront/cart-cookie')
|
|
25
26
|
const Database = require('bun:sqlite').Database
|
|
26
27
|
const path = require('path')
|
|
27
28
|
|
|
@@ -29,7 +30,7 @@ const dbPath = path.resolve(process.cwd(), process.env.DB_DATABASE_PATH || 'data
|
|
|
29
30
|
|
|
30
31
|
let cartCount = 0
|
|
31
32
|
try {
|
|
32
|
-
const sessionToken = (
|
|
33
|
+
const sessionToken = cartTokenFromRequest(request)
|
|
33
34
|
if (sessionToken) {
|
|
34
35
|
const db = new Database(dbPath, { readonly: true })
|
|
35
36
|
const cart = db.query(`SELECT total_items FROM carts WHERE session_token = ?1 AND status = 'active' LIMIT 1`).get(sessionToken)
|
|
@@ -52,7 +53,7 @@ catch { /* schema not migrated yet, or db missing — render an empty header */
|
|
|
52
53
|
injects __stxRouterConfig with container:'body', which would erase
|
|
53
54
|
the header/footer on every navigation. STX_ROUTER_OPTIONS is checked
|
|
54
55
|
last by the router's Object.assign, so this wins. --}}
|
|
55
|
-
<script>window.STX_ROUTER_OPTIONS = { container: 'main' };</script>
|
|
56
|
+
<script>(window as Window & { STX_ROUTER_OPTIONS?: { container: string } }).STX_ROUTER_OPTIONS = { container: 'main' };</script>
|
|
56
57
|
</head>
|
|
57
58
|
<body class="antialiased font-serif text-stone-900 bg-stone-50">
|
|
58
59
|
|
package/resources/views/cart.stx
CHANGED
|
@@ -3,13 +3,29 @@
|
|
|
3
3
|
@section('title', 'Your cart')
|
|
4
4
|
|
|
5
5
|
<script server>
|
|
6
|
+
const { cartTokenFromRequest } = require('../../functions/storefront/cart-cookie')
|
|
7
|
+
// The columns this query selects. bun:sqlite returns `any`, so nothing
|
|
8
|
+
// downstream of the map was checked either.
|
|
9
|
+
interface CartItemRow {
|
|
10
|
+
id: number
|
|
11
|
+
quantity: number | string
|
|
12
|
+
unit_price: number | string
|
|
13
|
+
total_price: number | string
|
|
14
|
+
product_name: string
|
|
15
|
+
product_image: string | null
|
|
16
|
+
product_sku: string
|
|
17
|
+
weight_grams: number | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface StorefrontLineItem { lineTotal: number }
|
|
21
|
+
|
|
6
22
|
const Database = require('bun:sqlite').Database
|
|
7
23
|
const path = require('path')
|
|
8
24
|
|
|
9
25
|
const dbPath = path.resolve(process.cwd(), process.env.DB_DATABASE_PATH || 'database/stacks.sqlite')
|
|
10
26
|
const db = new Database(dbPath, { readonly: true })
|
|
11
27
|
|
|
12
|
-
const token = (
|
|
28
|
+
const token = cartTokenFromRequest(request)
|
|
13
29
|
let items = []
|
|
14
30
|
let cart = null
|
|
15
31
|
|
|
@@ -23,7 +39,7 @@ if (token) {
|
|
|
23
39
|
LEFT JOIN products p ON p.slug = ci.product_sku
|
|
24
40
|
WHERE ci.cart_id = ?1
|
|
25
41
|
ORDER BY ci.id ASC
|
|
26
|
-
`).all(cart.id).map((row) => {
|
|
42
|
+
`).all(cart.id).map((row: CartItemRow) => {
|
|
27
43
|
const qty = Number(row.quantity)
|
|
28
44
|
const price = Number(row.unit_price)
|
|
29
45
|
const lineTotal = Number(row.total_price)
|
|
@@ -49,7 +65,7 @@ if (token) {
|
|
|
49
65
|
// app/Actions/Storefront/_shipping.ts when changing.
|
|
50
66
|
const FREE_SHIPPING_THRESHOLD = Number(process.env.STOREFRONT_FREE_SHIPPING_THRESHOLD ?? 40) || 40
|
|
51
67
|
const FLAT_SHIPPING = Number(process.env.STOREFRONT_FLAT_SHIPPING ?? 5) || 5
|
|
52
|
-
const subtotal = items.reduce((sum, i) => sum + i.lineTotal, 0)
|
|
68
|
+
const subtotal = items.reduce((sum: number, i: StorefrontLineItem) => sum + i.lineTotal, 0)
|
|
53
69
|
const shipping = subtotal <= 0 ? 0 : (subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : FLAT_SHIPPING)
|
|
54
70
|
const total = subtotal + shipping
|
|
55
71
|
const remainingForFree = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal)
|
|
@@ -3,13 +3,24 @@
|
|
|
3
3
|
@section('title', 'Checkout — Contact')
|
|
4
4
|
|
|
5
5
|
<script server>
|
|
6
|
+
const { cartTokenFromRequest } = require('../../../functions/storefront/cart-cookie')
|
|
7
|
+
// The columns this query selects. bun:sqlite returns `any`, so nothing
|
|
8
|
+
// downstream of the map was checked either.
|
|
9
|
+
interface CartSummaryRow {
|
|
10
|
+
product_name: string
|
|
11
|
+
quantity: number | string
|
|
12
|
+
total_price: number | string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface StorefrontLineItem { lineTotal: number }
|
|
16
|
+
|
|
6
17
|
const Database = require('bun:sqlite').Database
|
|
7
18
|
const path = require('path')
|
|
8
19
|
|
|
9
20
|
const dbPath = path.resolve(process.cwd(), process.env.DB_DATABASE_PATH || 'database/stacks.sqlite')
|
|
10
21
|
const db = new Database(dbPath, { readonly: true })
|
|
11
22
|
|
|
12
|
-
const token = (
|
|
23
|
+
const token = cartTokenFromRequest(request)
|
|
13
24
|
let items = []
|
|
14
25
|
let cart = null
|
|
15
26
|
|
|
@@ -19,7 +30,7 @@ if (token) {
|
|
|
19
30
|
items = db.query(`
|
|
20
31
|
SELECT product_name, quantity, total_price
|
|
21
32
|
FROM cart_items WHERE cart_id = ?1 ORDER BY id ASC
|
|
22
|
-
`).all(cart.id).map((row) => {
|
|
33
|
+
`).all(cart.id).map((row: CartSummaryRow) => {
|
|
23
34
|
const qty = Number(row.quantity)
|
|
24
35
|
const lineTotal = Number(row.total_price)
|
|
25
36
|
return {
|
|
@@ -36,7 +47,7 @@ if (token) {
|
|
|
36
47
|
// totals on every checkout step match what gets charged.
|
|
37
48
|
const FREE_SHIPPING_THRESHOLD = Number(process.env.STOREFRONT_FREE_SHIPPING_THRESHOLD ?? 40) || 40
|
|
38
49
|
const FLAT_SHIPPING = Number(process.env.STOREFRONT_FLAT_SHIPPING ?? 5) || 5
|
|
39
|
-
const subtotal = items.reduce((s, i) => s + i.lineTotal, 0)
|
|
50
|
+
const subtotal = items.reduce((s: number, i: StorefrontLineItem) => s + i.lineTotal, 0)
|
|
40
51
|
const shipping = subtotal <= 0 ? 0 : (subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : FLAT_SHIPPING)
|
|
41
52
|
const total = subtotal + shipping
|
|
42
53
|
const remainingForFree = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal)
|
|
@@ -3,13 +3,24 @@
|
|
|
3
3
|
@section('title', 'Checkout — Payment')
|
|
4
4
|
|
|
5
5
|
<script server>
|
|
6
|
+
const { cartTokenFromRequest } = require('../../../functions/storefront/cart-cookie')
|
|
7
|
+
// The columns this query selects. bun:sqlite returns `any`, so nothing
|
|
8
|
+
// downstream of the map was checked either.
|
|
9
|
+
interface CartSummaryRow {
|
|
10
|
+
product_name: string
|
|
11
|
+
quantity: number | string
|
|
12
|
+
total_price: number | string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface StorefrontLineItem { lineTotal: number }
|
|
16
|
+
|
|
6
17
|
const Database = require('bun:sqlite').Database
|
|
7
18
|
const path = require('path')
|
|
8
19
|
|
|
9
20
|
const dbPath = path.resolve(process.cwd(), process.env.DB_DATABASE_PATH || 'database/stacks.sqlite')
|
|
10
21
|
const db = new Database(dbPath, { readonly: true })
|
|
11
22
|
|
|
12
|
-
const token = (
|
|
23
|
+
const token = cartTokenFromRequest(request)
|
|
13
24
|
let items = []
|
|
14
25
|
let cart = null
|
|
15
26
|
|
|
@@ -19,7 +30,7 @@ if (token) {
|
|
|
19
30
|
items = db.query(`
|
|
20
31
|
SELECT product_name, quantity, total_price
|
|
21
32
|
FROM cart_items WHERE cart_id = ?1 ORDER BY id ASC
|
|
22
|
-
`).all(cart.id).map((row) => {
|
|
33
|
+
`).all(cart.id).map((row: CartSummaryRow) => {
|
|
23
34
|
const qty = Number(row.quantity)
|
|
24
35
|
const lineTotal = Number(row.total_price)
|
|
25
36
|
return {
|
|
@@ -34,7 +45,7 @@ if (token) {
|
|
|
34
45
|
|
|
35
46
|
const FREE_SHIPPING_THRESHOLD = Number(process.env.STOREFRONT_FREE_SHIPPING_THRESHOLD ?? 40) || 40
|
|
36
47
|
const FLAT_SHIPPING = Number(process.env.STOREFRONT_FLAT_SHIPPING ?? 5) || 5
|
|
37
|
-
const subtotal = items.reduce((s, i) => s + i.lineTotal, 0)
|
|
48
|
+
const subtotal = items.reduce((s: number, i: StorefrontLineItem) => s + i.lineTotal, 0)
|
|
38
49
|
const shipping = subtotal <= 0 ? 0 : (subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : FLAT_SHIPPING)
|
|
39
50
|
const total = subtotal + shipping
|
|
40
51
|
const remainingForFree = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal)
|
|
@@ -3,13 +3,24 @@
|
|
|
3
3
|
@section('title', 'Checkout — Shipping')
|
|
4
4
|
|
|
5
5
|
<script server>
|
|
6
|
+
const { cartTokenFromRequest } = require('../../../functions/storefront/cart-cookie')
|
|
7
|
+
// The columns this query selects. bun:sqlite returns `any`, so nothing
|
|
8
|
+
// downstream of the map was checked either.
|
|
9
|
+
interface CartSummaryRow {
|
|
10
|
+
product_name: string
|
|
11
|
+
quantity: number | string
|
|
12
|
+
total_price: number | string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface StorefrontLineItem { lineTotal: number }
|
|
16
|
+
|
|
6
17
|
const Database = require('bun:sqlite').Database
|
|
7
18
|
const path = require('path')
|
|
8
19
|
|
|
9
20
|
const dbPath = path.resolve(process.cwd(), process.env.DB_DATABASE_PATH || 'database/stacks.sqlite')
|
|
10
21
|
const db = new Database(dbPath, { readonly: true })
|
|
11
22
|
|
|
12
|
-
const token = (
|
|
23
|
+
const token = cartTokenFromRequest(request)
|
|
13
24
|
let items = []
|
|
14
25
|
let cart = null
|
|
15
26
|
|
|
@@ -19,7 +30,7 @@ if (token) {
|
|
|
19
30
|
items = db.query(`
|
|
20
31
|
SELECT product_name, quantity, total_price
|
|
21
32
|
FROM cart_items WHERE cart_id = ?1 ORDER BY id ASC
|
|
22
|
-
`).all(cart.id).map((row) => {
|
|
33
|
+
`).all(cart.id).map((row: CartSummaryRow) => {
|
|
23
34
|
const qty = Number(row.quantity)
|
|
24
35
|
const lineTotal = Number(row.total_price)
|
|
25
36
|
return {
|
|
@@ -34,7 +45,7 @@ if (token) {
|
|
|
34
45
|
|
|
35
46
|
const FREE_SHIPPING_THRESHOLD = Number(process.env.STOREFRONT_FREE_SHIPPING_THRESHOLD ?? 40) || 40
|
|
36
47
|
const FLAT_SHIPPING = Number(process.env.STOREFRONT_FLAT_SHIPPING ?? 5) || 5
|
|
37
|
-
const subtotal = items.reduce((s, i) => s + i.lineTotal, 0)
|
|
48
|
+
const subtotal = items.reduce((s: number, i: StorefrontLineItem) => s + i.lineTotal, 0)
|
|
38
49
|
const shipping = subtotal <= 0 ? 0 : (subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : FLAT_SHIPPING)
|
|
39
50
|
const total = subtotal + shipping
|
|
40
51
|
const remainingForFree = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal)
|
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
--}}
|
|
7
7
|
<div class="cms-form mx-auto max-w-xl px-6 py-8" data-form-uuid="{{ props.formUuid }}">
|
|
8
8
|
<script client>
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
import type { PublicFormDefinition, PublicFormField, SubmissionErrors } from '@stacksjs/forms'
|
|
10
|
+
|
|
11
|
+
// `_form` carries a whole-form failure; the rest are keyed by field name.
|
|
12
|
+
type FormErrors = SubmissionErrors & { _form?: string }
|
|
13
|
+
|
|
14
|
+
const host = useRef<HTMLElement>('host')
|
|
15
|
+
const definition = state<PublicFormDefinition | null>(null)
|
|
16
|
+
const values = reactive<Record<string, string | number | boolean>>({})
|
|
17
|
+
const errors = state<FormErrors>({})
|
|
13
18
|
const sending = state(false)
|
|
14
19
|
const done = state('')
|
|
15
20
|
const openedAt = Date.now()
|
|
@@ -17,7 +22,7 @@
|
|
|
17
22
|
effect(() => {
|
|
18
23
|
if (!host() || definition())
|
|
19
24
|
return
|
|
20
|
-
const uuid = host()
|
|
25
|
+
const uuid = host()?.dataset.formUuid
|
|
21
26
|
if (!uuid)
|
|
22
27
|
return
|
|
23
28
|
fetch(`/api/forms/${uuid}`, { credentials: 'same-origin' })
|
|
@@ -26,7 +31,7 @@
|
|
|
26
31
|
.catch(() => {})
|
|
27
32
|
})
|
|
28
33
|
|
|
29
|
-
function fieldVisible(field) {
|
|
34
|
+
function fieldVisible(field: PublicFormField): boolean {
|
|
30
35
|
const conditions = field.conditions
|
|
31
36
|
if (!conditions || !conditions.rules || conditions.rules.length === 0)
|
|
32
37
|
return true
|
|
@@ -47,7 +52,7 @@
|
|
|
47
52
|
return conditions.action === 'show' ? matched : !matched
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
async function submit(event) {
|
|
55
|
+
async function submit(event: Event): Promise<void> {
|
|
51
56
|
event.preventDefault()
|
|
52
57
|
if (sending())
|
|
53
58
|
return
|
|
@@ -55,7 +60,7 @@
|
|
|
55
60
|
errors.set({})
|
|
56
61
|
|
|
57
62
|
try {
|
|
58
|
-
const uuid = host()
|
|
63
|
+
const uuid = host()?.dataset.formUuid
|
|
59
64
|
const reply = await fetch(`/api/forms/${uuid}/submissions`, {
|
|
60
65
|
method: 'POST',
|
|
61
66
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -90,11 +95,11 @@
|
|
|
90
95
|
<div ref="host" data-form-uuid="{{ props.formUuid }}">
|
|
91
96
|
<div :if="done()" class="rounded-lg border border-green-200 bg-green-50 px-5 py-4 text-[15px] text-green-900">{{ done() }}</div>
|
|
92
97
|
<form :else-if="definition()" @submit="submit" novalidate>
|
|
93
|
-
<h2 class="text-2xl font-semibold tracking-tight">{{ definition()
|
|
98
|
+
<h2 class="text-2xl font-semibold tracking-tight">{{ definition()?.name }}</h2>
|
|
94
99
|
<p :if="errors()._form" class="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[14px] text-red-800">{{ errors()._form }}</p>
|
|
95
100
|
|
|
96
101
|
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2">
|
|
97
|
-
<template :for="field in definition()
|
|
102
|
+
<template :for="field in definition()?.fields ?? []">
|
|
98
103
|
<div :if="fieldVisible(field)" class="{{ field.width === 'half' ? '' : 'sm:col-span-2' }}">
|
|
99
104
|
<h3 :if="field.type === 'section_break'" class="mt-2 border-t border-gray-200 pt-4 text-lg font-medium">{{ field.label }}</h3>
|
|
100
105
|
<template :else>
|
|
@@ -124,7 +129,7 @@
|
|
|
124
129
|
<input type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true" class="hidden" @input="values._hp = $event.target.value">
|
|
125
130
|
|
|
126
131
|
<button type="submit" :disabled="sending()" class="mt-7 rounded-lg px-6 py-3 font-medium bg-gray-900 text-white transition-transform active:scale-[0.98] disabled:opacity-60">
|
|
127
|
-
{{ sending() ? 'Sending...' : (definition()
|
|
132
|
+
{{ sending() ? 'Sending...' : (definition()?.submitLabel ?? 'Submit') }}
|
|
128
133
|
</button>
|
|
129
134
|
</form>
|
|
130
135
|
<p :else class="text-[15px] text-gray-500">Loading form...</p>
|
|
@@ -10,6 +10,34 @@
|
|
|
10
10
|
Deliberately minimal chrome: real sites override this to add their own
|
|
11
11
|
navigation (fetchMenuTree), theme tokens and footer.
|
|
12
12
|
--}}
|
|
13
|
+
<script server>
|
|
14
|
+
/*
|
|
15
|
+
* The bindings `renderCmsPage` puts in this template's context, declared so
|
|
16
|
+
* the checker sees the same contract the docblock above describes. They are
|
|
17
|
+
* supplied by the renderer rather than imported, so `declare` is the accurate
|
|
18
|
+
* spelling: it types them and emits nothing.
|
|
19
|
+
*
|
|
20
|
+
* See `storage/framework/core/cms/src/public/render.ts`.
|
|
21
|
+
*/
|
|
22
|
+
declare const site: {
|
|
23
|
+
id: number
|
|
24
|
+
name: string
|
|
25
|
+
subdomain: string | null
|
|
26
|
+
settings: Record<string, unknown>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
declare const page: {
|
|
30
|
+
id: number
|
|
31
|
+
title: string
|
|
32
|
+
path: string
|
|
33
|
+
template: string
|
|
34
|
+
metaDescription: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Pre-rendered, sanitized block HTML. */
|
|
38
|
+
declare const content: string
|
|
39
|
+
</script>
|
|
40
|
+
|
|
13
41
|
<!DOCTYPE html>
|
|
14
42
|
<html lang="en">
|
|
15
43
|
<head>
|
|
@@ -3,13 +3,29 @@
|
|
|
3
3
|
@section('title', 'Order confirmed')
|
|
4
4
|
|
|
5
5
|
<script server>
|
|
6
|
+
// The columns this query selects. bun:sqlite returns `any`, so nothing
|
|
7
|
+
// downstream of the map was checked either.
|
|
8
|
+
interface OrderItemRow {
|
|
9
|
+
quantity: number | string
|
|
10
|
+
price: number | string
|
|
11
|
+
name: string
|
|
12
|
+
image_url: string | null
|
|
13
|
+
slug: string
|
|
14
|
+
weight_grams: number | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface StorefrontLineItem { lineTotal: number }
|
|
18
|
+
|
|
6
19
|
const Database = require('bun:sqlite').Database
|
|
7
20
|
const path = require('path')
|
|
8
21
|
|
|
9
22
|
const dbPath = path.resolve(process.cwd(), process.env.DB_DATABASE_PATH || 'database/stacks.sqlite')
|
|
10
23
|
const db = new Database(dbPath, { readonly: true })
|
|
11
24
|
|
|
12
|
-
|
|
25
|
+
// From `params`, which is what stx puts in a server script's scope for a
|
|
26
|
+
// `[id]` route. A bare `id` has never been a binding, so the `typeof` guard
|
|
27
|
+
// was always false and this page rendered "order not found" for every order.
|
|
28
|
+
const orderId = Number(params?.id ?? 0) || 0
|
|
13
29
|
|
|
14
30
|
const order = orderId
|
|
15
31
|
? db.query(`
|
|
@@ -27,7 +43,7 @@ const items = order
|
|
|
27
43
|
LEFT JOIN products p ON p.id = oi.product_id
|
|
28
44
|
WHERE oi.order_id = ?1
|
|
29
45
|
ORDER BY oi.id ASC
|
|
30
|
-
`).all(order.id).map((row) => ({
|
|
46
|
+
`).all(order.id).map((row: OrderItemRow) => ({
|
|
31
47
|
name: row.name,
|
|
32
48
|
slug: row.slug,
|
|
33
49
|
image: row.image_url,
|
|
@@ -38,7 +54,7 @@ const items = order
|
|
|
38
54
|
}))
|
|
39
55
|
: []
|
|
40
56
|
|
|
41
|
-
const subtotal = items.reduce((s, i) => s + i.lineTotal, 0)
|
|
57
|
+
const subtotal = items.reduce((s: number, i: StorefrontLineItem) => s + i.lineTotal, 0)
|
|
42
58
|
const shipping = order ? Number(order.delivery_fee || 0) : 0
|
|
43
59
|
const total = order ? Number(order.total_amount || 0) : 0
|
|
44
60
|
</script>
|
|
@@ -30,7 +30,7 @@ export {
|
|
|
30
30
|
sidebarThemeFor,
|
|
31
31
|
useAppearance,
|
|
32
32
|
} from './useAppearance'
|
|
33
|
-
export type { ChartCtor, ChartHandle, ChartLike, LazyChartsHandle, UseChartOptions } from './useChart'
|
|
33
|
+
export type { ChartConfig, ChartCtor, ChartData, ChartDataset, ChartHandle, ChartLike, ChartOptions, ChartType, LazyChartsHandle, UseChartOptions } from './useChart'
|
|
34
34
|
export { useChart, useCharts, useLazyCharts } from './useChart'
|
|
35
35
|
export type { NavigationSnapshot } from './useNavigation'
|
|
36
36
|
export { useNavigation } from './useNavigation'
|