@stacksjs/defaults 0.70.380 → 0.71.2
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/app/Actions/Auth/MagicLinkConsumeAction.ts +53 -0
- package/app/Actions/Auth/MagicLinkSendAction.ts +35 -0
- package/app/Actions/Cms/SitemapAction.ts +23 -2
- package/app/Jobs/PublishScheduledPagesJob.ts +26 -0
- package/app/Middleware/Site.ts +14 -0
- package/app/Middleware.ts +1 -0
- package/app/Models/Automation.ts +23 -0
- package/app/Models/AutomationRun.ts +26 -0
- package/app/Models/Campaign.ts +67 -3
- package/app/Models/CampaignSend.ts +90 -6
- package/app/Models/CampaignVariant.ts +25 -0
- package/app/Models/CommunicationSuppression.ts +22 -0
- package/app/Models/ConsentEvent.ts +26 -0
- package/app/Models/Content/Menu.ts +56 -0
- package/app/Models/Content/MenuItem.ts +91 -0
- package/app/Models/Content/Page.ts +111 -14
- package/app/Models/Content/PageRevision.ts +86 -0
- package/app/Models/Content/Post.ts +24 -9
- package/app/Models/Content/Redirect.ts +80 -0
- package/app/Models/Forms/Form.ts +91 -0
- package/app/Models/Forms/FormField.ts +127 -0
- package/app/Models/Forms/FormSubmission.ts +114 -0
- package/app/Models/MagicLinkToken.ts +97 -0
- package/app/Models/SenderDomain.ts +22 -0
- package/app/Models/Site.ts +112 -0
- package/app/Models/SiteDomain.ts +74 -0
- package/app/Models/SmsOptOut.ts +65 -0
- package/app/Models/UsageEvent.ts +24 -0
- package/app/Models/commerce/Auction.ts +173 -0
- package/app/Models/commerce/AuctionItem.ts +204 -0
- package/app/Models/commerce/Bid.ts +129 -0
- package/app/Models/commerce/Pledge.ts +112 -0
- package/bootstrap.ts +7 -0
- package/functions/public-application-url.ts +1 -1
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/functions/dashboard/sidebar.ts +109 -3
- package/resources/functions/dashboard/toggles.ts +90 -2
- package/resources/views/cms/blocks/columns.stx +10 -0
- package/resources/views/cms/blocks/cta.stx +8 -0
- package/resources/views/cms/blocks/embed.stx +14 -0
- package/resources/views/cms/blocks/form.stx +132 -0
- package/resources/views/cms/blocks/hero.stx +16 -0
- package/resources/views/cms/blocks/image.stx +7 -0
- package/resources/views/cms/blocks/rich-text.stx +4 -0
- package/resources/views/cms/page.stx +28 -0
- package/routes/auth.ts +7 -0
- package/routes/forms.ts +110 -0
- package/views/auth/magic/[token].stx +88 -0
- package/views/dashboard/.discovered-models.json +46 -1
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* persistence stay inside the component instead of being serialized as HTML.
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { createRequire } from 'node:module'
|
|
9
10
|
import { resolve } from 'node:path'
|
|
10
11
|
|
|
11
12
|
type ModelCategory = 'userland' | 'data' | 'commerce' | 'content' | 'marketing' | 'system'
|
|
@@ -57,9 +58,24 @@ export interface DashboardSectionToggles {
|
|
|
57
58
|
data: DataRowToggles
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/** An application-defined sidebar row, from `config/dashboard.ts:nav`. */
|
|
62
|
+
export interface AppNavItem {
|
|
63
|
+
label: string
|
|
64
|
+
href: string
|
|
65
|
+
icon?: string
|
|
66
|
+
roles?: string[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AppNavSection {
|
|
70
|
+
title: string
|
|
71
|
+
items: AppNavItem[]
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
export interface DiscoveredManifest {
|
|
61
75
|
models: DiscoveredModel[]
|
|
62
76
|
sections: DashboardSectionToggles
|
|
77
|
+
/** Sections this application declared. Empty for a project that declared none. */
|
|
78
|
+
nav: AppNavSection[]
|
|
63
79
|
}
|
|
64
80
|
|
|
65
81
|
export const DEFAULT_DATA_TOGGLES: DataRowToggles = {
|
|
@@ -103,7 +119,7 @@ export function loadDiscoveredManifest(
|
|
|
103
119
|
manifestPath = resolve(process.cwd(), 'storage/framework/defaults/views/dashboard/.discovered-models.json'),
|
|
104
120
|
): DiscoveredManifest {
|
|
105
121
|
if (!existsSync(manifestPath))
|
|
106
|
-
return { models: [], sections: defaultToggles() }
|
|
122
|
+
return { models: [], sections: defaultToggles(), nav: [] }
|
|
107
123
|
|
|
108
124
|
try {
|
|
109
125
|
return parseDiscoveredManifest(readFileSync(manifestPath, 'utf8'))
|
|
@@ -138,9 +154,35 @@ export function parseDiscoveredManifest(source: string): DiscoveredManifest {
|
|
|
138
154
|
return {
|
|
139
155
|
models,
|
|
140
156
|
sections: normalizeManifestToggles(envelope.sections),
|
|
157
|
+
nav: normalizeManifestNav(envelope.nav),
|
|
141
158
|
}
|
|
142
159
|
}
|
|
143
160
|
|
|
161
|
+
/**
|
|
162
|
+
* App-declared sections are validated at config load; this only has to survive
|
|
163
|
+
* a manifest written by an older framework version, which has no `nav` key.
|
|
164
|
+
*/
|
|
165
|
+
function normalizeManifestNav(value: unknown): AppNavSection[] {
|
|
166
|
+
if (value === undefined)
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
if (!Array.isArray(value))
|
|
170
|
+
throw new TypeError('manifest nav must be an array')
|
|
171
|
+
|
|
172
|
+
return value.map((entry, index) => {
|
|
173
|
+
const section = objectValue(entry, `manifest nav[${index}]`)
|
|
174
|
+
if (typeof section.title !== 'string' || !section.title)
|
|
175
|
+
throw new TypeError(`manifest nav[${index}].title must be a non-empty string`)
|
|
176
|
+
if (!Array.isArray(section.items))
|
|
177
|
+
throw new TypeError(`manifest nav[${index}].items must be an array`)
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
title: section.title,
|
|
181
|
+
items: section.items as AppNavItem[],
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
144
186
|
function normalizeManifestToggles(value: unknown): DashboardSectionToggles {
|
|
145
187
|
const sections = objectValue(value, 'manifest sections')
|
|
146
188
|
const data = sections.data === undefined
|
|
@@ -241,9 +283,25 @@ function categoryNavItems(
|
|
|
241
283
|
export function buildNavSections(
|
|
242
284
|
discoveredModels: DiscoveredModel[] = [],
|
|
243
285
|
toggles: DashboardSectionToggles = DEFAULT_TOGGLES,
|
|
286
|
+
appNav: AppNavSection[] = [],
|
|
244
287
|
): Array<[string, string, NavItem[]]> {
|
|
245
288
|
const sections: Array<[string, string, NavItem[]]> = []
|
|
246
289
|
|
|
290
|
+
// Application sections come first. An app that declares its own pages is
|
|
291
|
+
// saying those are the product; the framework's operational surfaces
|
|
292
|
+
// (queue, logs, deployments) are support and belong below them.
|
|
293
|
+
for (const [index, section] of appNav.entries()) {
|
|
294
|
+
if (section.items.length === 0)
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
sections.push([`app-nav-${index}`, section.title, section.items.map(item => ({
|
|
298
|
+
to: item.href,
|
|
299
|
+
icon: item.icon ?? 'circle',
|
|
300
|
+
text: item.label,
|
|
301
|
+
...(item.roles && item.roles.length > 0 ? { roles: item.roles } : {}),
|
|
302
|
+
}))])
|
|
303
|
+
}
|
|
304
|
+
|
|
247
305
|
// Library section: the established views live at the project root
|
|
248
306
|
// (`/functions`, `/packages`, `/releases`). Components use the explicit
|
|
249
307
|
// `/library/components` path so it does not collide with STX's component
|
|
@@ -549,9 +607,57 @@ function titleCase(label: string): string {
|
|
|
549
607
|
* discovered-models manifest synchronously (stx server-script friendly)
|
|
550
608
|
* and applies the shared section and toggle logic.
|
|
551
609
|
*/
|
|
610
|
+
/**
|
|
611
|
+
* Framework sections name their icons from the map above. App-declared rows
|
|
612
|
+
* may instead give a full iconify class, which is passed through untouched -
|
|
613
|
+
* an application should not be limited to the icons the framework happened to
|
|
614
|
+
* name, and `i-hugeicons-champion` is the spelling its own templates use.
|
|
615
|
+
*/
|
|
616
|
+
function resolveNavIcon(icon: string): string {
|
|
617
|
+
if (icon.startsWith('i-'))
|
|
618
|
+
return icon
|
|
619
|
+
|
|
620
|
+
return NAV_ICON_CLASSES[icon] ?? NAV_ICON_CLASSES.file!
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Read app-declared sections straight from `config/dashboard.ts`.
|
|
625
|
+
*
|
|
626
|
+
* The manifest is the primary source, but it is written by the dashboard dev
|
|
627
|
+
* command - so an app running a published framework older than the `nav` key
|
|
628
|
+
* would get a manifest without one, and its own pages would stay unreachable.
|
|
629
|
+
* Reading the config directly also means editing it shows up on the next
|
|
630
|
+
* reload rather than on the next manifest write.
|
|
631
|
+
*
|
|
632
|
+
* `require` rather than `import`: this module is called synchronously from STX
|
|
633
|
+
* server-script context, and Bun transpiles the TS config on the way in.
|
|
634
|
+
*/
|
|
635
|
+
function loadAppNavFromConfig(configPath = resolve(process.cwd(), 'config/dashboard.ts')): AppNavSection[] {
|
|
636
|
+
if (!existsSync(configPath))
|
|
637
|
+
return []
|
|
638
|
+
|
|
639
|
+
let config: unknown
|
|
640
|
+
try {
|
|
641
|
+
const requireFrom = createRequire(import.meta.url)
|
|
642
|
+
const loaded = requireFrom(configPath) as { default?: unknown }
|
|
643
|
+
config = loaded?.default ?? loaded
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
// An unreadable config is the dashboard's problem elsewhere, not the
|
|
647
|
+
// sidebar's: fall back to the framework sections rather than blanking the
|
|
648
|
+
// whole navigation.
|
|
649
|
+
return []
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Validation deliberately outside the catch - a malformed `nav` is an
|
|
653
|
+
// authoring mistake and should say so, not silently render nothing.
|
|
654
|
+
return normalizeManifestNav((config as { nav?: unknown } | undefined)?.nav)
|
|
655
|
+
}
|
|
656
|
+
|
|
552
657
|
export function buildWebSidebarSections(): WebSidebarSection[] {
|
|
553
658
|
const manifest = loadDiscoveredManifest()
|
|
554
|
-
const
|
|
659
|
+
const appNav = manifest.nav.length > 0 ? manifest.nav : loadAppNavFromConfig()
|
|
660
|
+
const sections = buildNavSections(manifest.models, manifest.sections, appNav)
|
|
555
661
|
|
|
556
662
|
return [
|
|
557
663
|
{
|
|
@@ -565,7 +671,7 @@ export function buildWebSidebarSections(): WebSidebarSection[] {
|
|
|
565
671
|
items: items.map(item => ({
|
|
566
672
|
id: navItemId(item.to),
|
|
567
673
|
label: item.text,
|
|
568
|
-
icon:
|
|
674
|
+
icon: resolveNavIcon(item.icon),
|
|
569
675
|
iconColor: 'blue',
|
|
570
676
|
href: item.to,
|
|
571
677
|
...(item.roles && item.roles.length > 0 ? { roles: item.roles } : {}),
|
|
@@ -10,6 +10,29 @@ export interface DashboardDataRowToggles {
|
|
|
10
10
|
allModels: boolean
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* One row of an application-defined sidebar section.
|
|
15
|
+
*
|
|
16
|
+
* `icon` is either a full iconify class (`i-hugeicons-calendar-03`), which is
|
|
17
|
+
* passed through as written, or one of the framework's own short sidebar icon
|
|
18
|
+
* names (`calendar`, `bell`, `chart`), which resolves through its icon map.
|
|
19
|
+
* The first form is what an app wants: it is the same spelling every template
|
|
20
|
+
* in the project already uses, and it is not limited to the set the framework
|
|
21
|
+
* happens to have named.
|
|
22
|
+
*/
|
|
23
|
+
export interface DashboardNavItem {
|
|
24
|
+
label: string
|
|
25
|
+
href: string
|
|
26
|
+
icon?: string
|
|
27
|
+
/** Role gate, matching the per-model `dashboard.roles` metadata. */
|
|
28
|
+
roles?: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DashboardNavSection {
|
|
32
|
+
title: string
|
|
33
|
+
items: DashboardNavItem[]
|
|
34
|
+
}
|
|
35
|
+
|
|
13
36
|
export interface ResolvedDashboardToggles {
|
|
14
37
|
library: boolean
|
|
15
38
|
content: boolean
|
|
@@ -73,14 +96,79 @@ export function resolveDashboardToggles(value: unknown): ResolvedDashboardToggle
|
|
|
73
96
|
}
|
|
74
97
|
|
|
75
98
|
export async function loadDashboardToggles(configPath: string): Promise<ResolvedDashboardToggles> {
|
|
99
|
+
return (await loadDashboardConfig(configPath)).toggles
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Application-defined sidebar sections from `config/dashboard.ts:nav`.
|
|
104
|
+
*
|
|
105
|
+
* The framework's own sections are a fixed list, so before this an app that
|
|
106
|
+
* added dashboard pages under `resources/views/dashboard/` had pages the
|
|
107
|
+
* sidebar could not reach - the pages worked, but only if you typed the URL.
|
|
108
|
+
* Declaring them in config keeps the sidebar in one place and out of the
|
|
109
|
+
* framework's own registry, which an app cannot edit without vendoring it.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveDashboardNav(value: unknown): DashboardNavSection[] {
|
|
112
|
+
if (!value || typeof value !== 'object')
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
const config = value as Record<string, unknown>
|
|
116
|
+
if (config.nav === undefined)
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
if (!Array.isArray(config.nav))
|
|
120
|
+
throw new TypeError('dashboard config nav must be an array of sections')
|
|
121
|
+
|
|
122
|
+
return config.nav.map((entry, index) => {
|
|
123
|
+
const section = objectValue(entry, `dashboard config nav[${index}]`)
|
|
124
|
+
if (typeof section.title !== 'string' || !section.title)
|
|
125
|
+
throw new TypeError(`dashboard config nav[${index}].title must be a non-empty string`)
|
|
126
|
+
if (!Array.isArray(section.items))
|
|
127
|
+
throw new TypeError(`dashboard config nav[${index}].items must be an array`)
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
title: section.title,
|
|
131
|
+
items: section.items.map((value, itemIndex) => {
|
|
132
|
+
const label = `dashboard config nav[${index}].items[${itemIndex}]`
|
|
133
|
+
const item = objectValue(value, label)
|
|
134
|
+
if (typeof item.label !== 'string' || !item.label)
|
|
135
|
+
throw new TypeError(`${label}.label must be a non-empty string`)
|
|
136
|
+
if (typeof item.href !== 'string' || !item.href)
|
|
137
|
+
throw new TypeError(`${label}.href must be a non-empty string`)
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
label: item.label,
|
|
141
|
+
href: item.href,
|
|
142
|
+
icon: typeof item.icon === 'string' ? item.icon : undefined,
|
|
143
|
+
roles: Array.isArray(item.roles) ? item.roles.map(String) : undefined,
|
|
144
|
+
}
|
|
145
|
+
}),
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface LoadedDashboardConfig {
|
|
151
|
+
toggles: ResolvedDashboardToggles
|
|
152
|
+
nav: DashboardNavSection[]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read `config/dashboard.ts` once and return everything the sidebar needs.
|
|
157
|
+
* One import rather than two: the config module is cache-busted on mtime, so a
|
|
158
|
+
* second load of the same file would be a second evaluation of user code.
|
|
159
|
+
*/
|
|
160
|
+
export async function loadDashboardConfig(configPath: string): Promise<LoadedDashboardConfig> {
|
|
76
161
|
if (!existsSync(configPath))
|
|
77
|
-
return defaultDashboardToggles()
|
|
162
|
+
return { toggles: defaultDashboardToggles(), nav: [] }
|
|
78
163
|
|
|
79
164
|
try {
|
|
80
165
|
const moduleUrl = pathToFileURL(configPath)
|
|
81
166
|
moduleUrl.searchParams.set('dashboard-config-mtime', String(statSync(configPath).mtimeMs))
|
|
82
167
|
const configModule = await import(moduleUrl.href) as { default?: unknown }
|
|
83
|
-
return
|
|
168
|
+
return {
|
|
169
|
+
toggles: resolveDashboardToggles(configModule.default),
|
|
170
|
+
nav: resolveDashboardNav(configModule.default),
|
|
171
|
+
}
|
|
84
172
|
}
|
|
85
173
|
catch (error) {
|
|
86
174
|
throw new Error(`Could not load config/dashboard.ts: ${error instanceof Error ? error.message : String(error)}`)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{{-- Columns block: 1-4 sanitized HTML columns on a responsive grid. --}}
|
|
2
|
+
<div class="cms-columns mx-auto max-w-6xl px-6 py-8">
|
|
3
|
+
{{-- Literal class names, not `md:grid-cols-{{ n }}`: Crosswind extracts
|
|
4
|
+
classes from template SOURCE, so a composed name generates no CSS. --}}
|
|
5
|
+
<div class="grid grid-cols-1 gap-8 {{ props.columns.length === 4 ? 'md:grid-cols-4' : (props.columns.length === 3 ? 'md:grid-cols-3' : (props.columns.length === 2 ? 'md:grid-cols-2' : '')) }}">
|
|
6
|
+
@foreach (props.columns as column)
|
|
7
|
+
<div class="leading-relaxed">{!! column.html !!}</div>
|
|
8
|
+
@endforeach
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{{-- Call-to-action band. --}}
|
|
2
|
+
<section class="cms-cta mx-auto max-w-4xl px-6 py-16 text-center">
|
|
3
|
+
<h2 class="text-3xl tracking-tight font-semibold">{{ props.heading }}</h2>
|
|
4
|
+
@if (props.body)
|
|
5
|
+
<p class="mt-4 text-lg text-gray-600 max-w-[65ch] mx-auto">{{ props.body }}</p>
|
|
6
|
+
@endif
|
|
7
|
+
<a href="{{ props.buttonHref }}" class="mt-8 inline-block rounded-lg px-6 py-3 font-medium bg-gray-900 text-white transition-transform active:scale-[0.98]">{{ props.buttonLabel }}</a>
|
|
8
|
+
</section>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{{-- Embed block: sandboxed iframe, never inline third-party markup. --}}
|
|
2
|
+
<div class="cms-embed mx-auto max-w-4xl px-6 py-8">
|
|
3
|
+
<div class="relative w-full overflow-hidden rounded-lg {{ props.aspect === '4:3' ? 'aspect-[4/3]' : (props.aspect === '1:1' ? 'aspect-square' : 'aspect-video') }}">
|
|
4
|
+
<iframe
|
|
5
|
+
src="{{ props.src }}"
|
|
6
|
+
title="{{ props.title }}"
|
|
7
|
+
class="absolute inset-0 h-full w-full"
|
|
8
|
+
sandbox="allow-scripts allow-same-origin allow-presentation"
|
|
9
|
+
referrerpolicy="no-referrer"
|
|
10
|
+
loading="lazy"
|
|
11
|
+
allowfullscreen
|
|
12
|
+
></iframe>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
{{--
|
|
2
|
+
Form block: renders a @stacksjs/forms form inline on a CMS page.
|
|
3
|
+
`props.formUuid` names the form; the client script fetches the public
|
|
4
|
+
definition (same-origin), renders the fields, evaluates conditions live,
|
|
5
|
+
and submits with the CSRF header the page response seeded.
|
|
6
|
+
--}}
|
|
7
|
+
<div class="cms-form mx-auto max-w-xl px-6 py-8" data-form-uuid="{{ props.formUuid }}">
|
|
8
|
+
<script client>
|
|
9
|
+
const host = useRef('host')
|
|
10
|
+
const definition = state(null)
|
|
11
|
+
const values = reactive({})
|
|
12
|
+
const errors = state({})
|
|
13
|
+
const sending = state(false)
|
|
14
|
+
const done = state('')
|
|
15
|
+
const openedAt = Date.now()
|
|
16
|
+
|
|
17
|
+
effect(() => {
|
|
18
|
+
if (!host() || definition())
|
|
19
|
+
return
|
|
20
|
+
const uuid = host().dataset.formUuid
|
|
21
|
+
if (!uuid)
|
|
22
|
+
return
|
|
23
|
+
fetch(`/api/forms/${uuid}`, { credentials: 'same-origin' })
|
|
24
|
+
.then(reply => reply.ok ? reply.json() : null)
|
|
25
|
+
.then((body) => { if (body) definition.set(body) })
|
|
26
|
+
.catch(() => {})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
function fieldVisible(field) {
|
|
30
|
+
const conditions = field.conditions
|
|
31
|
+
if (!conditions || !conditions.rules || conditions.rules.length === 0)
|
|
32
|
+
return true
|
|
33
|
+
const results = conditions.rules.map((rule) => {
|
|
34
|
+
const actual = values[rule.field]
|
|
35
|
+
switch (rule.op) {
|
|
36
|
+
case 'eq': return String(actual ?? '') === String(rule.value ?? '')
|
|
37
|
+
case 'neq': return String(actual ?? '') !== String(rule.value ?? '')
|
|
38
|
+
case 'contains': return String(actual ?? '').includes(String(rule.value ?? ''))
|
|
39
|
+
case 'gt': return Number(actual) > Number(rule.value)
|
|
40
|
+
case 'lt': return Number(actual) < Number(rule.value)
|
|
41
|
+
case 'empty': return actual === undefined || actual === null || String(actual) === ''
|
|
42
|
+
case 'not_empty': return actual !== undefined && actual !== null && String(actual) !== ''
|
|
43
|
+
default: return false
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
const matched = conditions.logic === 'all' ? results.every(Boolean) : results.some(Boolean)
|
|
47
|
+
return conditions.action === 'show' ? matched : !matched
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function submit(event) {
|
|
51
|
+
event.preventDefault()
|
|
52
|
+
if (sending())
|
|
53
|
+
return
|
|
54
|
+
sending.set(true)
|
|
55
|
+
errors.set({})
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const uuid = host().dataset.formUuid
|
|
59
|
+
const reply = await fetch(`/api/forms/${uuid}/submissions`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
credentials: 'same-origin',
|
|
63
|
+
body: JSON.stringify({ ...values, _renderedMs: Date.now() - openedAt }),
|
|
64
|
+
})
|
|
65
|
+
const body = await reply.json().catch(() => ({}))
|
|
66
|
+
|
|
67
|
+
if (reply.status === 422) {
|
|
68
|
+
errors.set(body.errors ?? {})
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (!reply.ok) {
|
|
72
|
+
errors.set({ _form: body.message ?? 'Something went wrong. Please try again.' })
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (body.redirect) {
|
|
76
|
+
navigate(body.redirect)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
done.set(body.message ?? 'Thanks - your response was received.')
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
errors.set({ _form: 'Something went wrong. Please try again.' })
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
sending.set(false)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<div ref="host" data-form-uuid="{{ props.formUuid }}">
|
|
91
|
+
<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
|
+
<form :else-if="definition()" @submit="submit" novalidate>
|
|
93
|
+
<h2 class="text-2xl font-semibold tracking-tight">{{ definition().name }}</h2>
|
|
94
|
+
<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
|
+
|
|
96
|
+
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2">
|
|
97
|
+
<template :for="field in definition().fields">
|
|
98
|
+
<div :if="fieldVisible(field)" class="{{ field.width === 'half' ? '' : 'sm:col-span-2' }}">
|
|
99
|
+
<h3 :if="field.type === 'section_break'" class="mt-2 border-t border-gray-200 pt-4 text-lg font-medium">{{ field.label }}</h3>
|
|
100
|
+
<template :else>
|
|
101
|
+
<label class="block text-[14px] font-medium text-gray-800">{{ field.label }}<span :if="field.required" aria-hidden="true" class="text-red-500"> *</span></label>
|
|
102
|
+
<textarea :if="field.type === 'textarea'" rows="4" :required="field.required" placeholder="{{ field.options.placeholder ?? '' }}" @input="values[field.name] = $event.target.value" class="mt-1.5 w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-[15px]"></textarea>
|
|
103
|
+
<select :else-if="field.type === 'select'" :required="field.required" @change="values[field.name] = $event.target.value" class="mt-1.5 w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-[15px] bg-white">
|
|
104
|
+
<option value="">Choose...</option>
|
|
105
|
+
<option :for="choice in field.options.choices ?? []" value="{{ choice.value }}">{{ choice.label }}</option>
|
|
106
|
+
</select>
|
|
107
|
+
<div :else-if="field.type === 'radio'" class="mt-2 space-y-2">
|
|
108
|
+
<label :for="choice in field.options.choices ?? []" class="flex items-center gap-2.5 text-[15px]">
|
|
109
|
+
<input type="radio" name="{{ field.name }}" value="{{ choice.value }}" @change="values[field.name] = $event.target.value" class="h-4 w-4">
|
|
110
|
+
{{ choice.label }}
|
|
111
|
+
</label>
|
|
112
|
+
</div>
|
|
113
|
+
<label :else-if="field.type === 'checkbox'" class="mt-2 flex items-center gap-2.5 text-[15px]">
|
|
114
|
+
<input type="checkbox" @change="values[field.name] = $event.target.checked" class="h-4 w-4 rounded">
|
|
115
|
+
{{ field.options.placeholder ?? 'Yes' }}
|
|
116
|
+
</label>
|
|
117
|
+
<input :else type="{{ field.type === 'email' ? 'email' : (field.type === 'phone' ? 'tel' : (field.type === 'date' ? 'date' : (field.type === 'currency' ? 'number' : 'text'))) }}" :required="field.required" placeholder="{{ field.options.placeholder ?? '' }}" @input="values[field.name] = field.type === 'currency' ? Math.round(Number($event.target.value) * 100) : $event.target.value" class="mt-1.5 w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-[15px]">
|
|
118
|
+
<p :if="errors()[field.name]" class="mt-1.5 text-[13px] text-red-600">{{ errors()[field.name] }}</p>
|
|
119
|
+
</template>
|
|
120
|
+
</div>
|
|
121
|
+
</template>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<input type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true" class="hidden" @input="values._hp = $event.target.value">
|
|
125
|
+
|
|
126
|
+
<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().submitLabel ?? 'Submit') }}
|
|
128
|
+
</button>
|
|
129
|
+
</form>
|
|
130
|
+
<p :else class="text-[15px] text-gray-500">Loading form...</p>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{{-- Hero block: heading, optional subheading/image/CTA. --}}
|
|
2
|
+
<section class="cms-hero relative overflow-hidden {{ props.align === 'center' ? 'text-center' : 'text-left' }}">
|
|
3
|
+
@if (props.imageUrl)
|
|
4
|
+
<img src="{{ props.imageUrl }}" alt="" class="absolute inset-0 h-full w-full object-cover" aria-hidden="true">
|
|
5
|
+
<div class="absolute inset-0 bg-black/45" aria-hidden="true"></div>
|
|
6
|
+
@endif
|
|
7
|
+
<div class="relative mx-auto max-w-4xl px-6 py-24 {{ props.imageUrl ? 'text-white' : '' }}">
|
|
8
|
+
<h1 class="text-4xl md:text-6xl tracking-tighter leading-none font-semibold">{{ props.heading }}</h1>
|
|
9
|
+
@if (props.subheading)
|
|
10
|
+
<p class="mt-5 text-lg md:text-xl max-w-[65ch] {{ props.align === 'center' ? 'mx-auto' : '' }} opacity-90">{{ props.subheading }}</p>
|
|
11
|
+
@endif
|
|
12
|
+
@if (props.ctaLabel && props.ctaHref)
|
|
13
|
+
<a href="{{ props.ctaHref }}" class="mt-8 inline-block rounded-lg px-6 py-3 font-medium bg-white text-gray-900 shadow-sm transition-transform active:scale-[0.98]">{{ props.ctaLabel }}</a>
|
|
14
|
+
@endif
|
|
15
|
+
</div>
|
|
16
|
+
</section>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{{-- Image block with required alt text (the editor enforces it; the schema requires it). --}}
|
|
2
|
+
<figure class="cms-image mx-auto px-6 py-8 {{ props.width === 'full' ? 'max-w-none px-0' : (props.width === 'wide' ? 'max-w-6xl' : 'max-w-3xl') }}">
|
|
3
|
+
<img src="{{ props.src }}" alt="{{ props.alt }}" class="w-full rounded-lg" loading="lazy">
|
|
4
|
+
@if (props.caption)
|
|
5
|
+
<figcaption class="mt-3 text-sm text-gray-500">{{ props.caption }}</figcaption>
|
|
6
|
+
@endif
|
|
7
|
+
</figure>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{{--
|
|
2
|
+
The default CMS page shell. Every published block-document page renders
|
|
3
|
+
through this template unless the app overrides it at
|
|
4
|
+
`resources/views/cms/page.stx`. It receives:
|
|
5
|
+
|
|
6
|
+
site - { id, name, subdomain, settings } for the request's site
|
|
7
|
+
page - { id, title, path, template, metaDescription }
|
|
8
|
+
content - the pre-rendered, sanitized block HTML
|
|
9
|
+
|
|
10
|
+
Deliberately minimal chrome: real sites override this to add their own
|
|
11
|
+
navigation (fetchMenuTree), theme tokens and footer.
|
|
12
|
+
--}}
|
|
13
|
+
<!DOCTYPE html>
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="utf-8">
|
|
17
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
18
|
+
<title>{{ page.title }}</title>
|
|
19
|
+
@if (page.metaDescription)
|
|
20
|
+
<meta name="description" content="{{ page.metaDescription }}">
|
|
21
|
+
@endif
|
|
22
|
+
</head>
|
|
23
|
+
<body>
|
|
24
|
+
<main class="cms-page" data-template="{{ page.template }}">
|
|
25
|
+
{!! content !!}
|
|
26
|
+
</main>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
package/routes/auth.ts
CHANGED
|
@@ -34,6 +34,13 @@ import { route } from '@stacksjs/router'
|
|
|
34
34
|
// `routes/api.ts` (user routes win) gets to pick its own limits.
|
|
35
35
|
route.post('/login', 'Actions/Auth/LoginAction').rateLimit(5, 'minute')
|
|
36
36
|
route.post('/register', 'Actions/Auth/RegisterAction').rateLimit(3, 'minute')
|
|
37
|
+
// Magic links (config.auth.magicLink.enabled gates both, 404 when off).
|
|
38
|
+
// The send endpoint answers a uniform 202 either way (anti-enumeration
|
|
39
|
+
// lives in sendMagicLink); the consume endpoint is a POST because email
|
|
40
|
+
// scanners prefetch GETs and would burn single-use tokens - the GET page
|
|
41
|
+
// at /auth/magic/{token} is an interstitial that posts here.
|
|
42
|
+
route.post('/auth/magic-link', 'Actions/Auth/MagicLinkSendAction').rateLimit(3, 'minute')
|
|
43
|
+
route.post('/auth/magic-link/consume', 'Actions/Auth/MagicLinkConsumeAction').rateLimit(10, 'minute')
|
|
37
44
|
// Passkey ENROLLMENT (attaching a new credential to an account) must be
|
|
38
45
|
// auth-gated — it's not a login flow, it's a logged-in user adding a
|
|
39
46
|
// second factor to their own account. Previously unauthenticated and
|
package/routes/forms.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { requestHost, resolveSiteByHost, sitesOptions } from '@stacksjs/sites'
|
|
2
|
+
import { response, route } from '@stacksjs/router'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Public form endpoints (`@stacksjs/forms`). The admin builder surface is
|
|
6
|
+
* the models' own auth'd `useApi` routes plus the export route below; these
|
|
7
|
+
* two are what a visitor's browser talks to.
|
|
8
|
+
*
|
|
9
|
+
* CSRF: the submit endpoint keeps the default-on double-submit protection.
|
|
10
|
+
* A form rendered by the CMS `form` block lives on the same origin, so the
|
|
11
|
+
* page-seeded cookie + header work exactly like every other public form.
|
|
12
|
+
* (Cross-origin embeds would need a `.skipCsrf()` variant with an Origin
|
|
13
|
+
* allowlist - deliberately not shipped until something needs it.)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
async function siteIdForRequest(request: any): Promise<number | null> {
|
|
17
|
+
const options = sitesOptions()
|
|
18
|
+
if (!options.enabled)
|
|
19
|
+
return null
|
|
20
|
+
|
|
21
|
+
const headers: Headers = request.headers instanceof Headers
|
|
22
|
+
? request.headers
|
|
23
|
+
: new Headers(request.headers ?? {})
|
|
24
|
+
const site = await resolveSiteByHost(requestHost(headers, options), undefined, options)
|
|
25
|
+
return site?.id ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
route.get('/api/forms/{uuid}', async (request: any) => {
|
|
29
|
+
const { loadFormByUuid, publicDefinition } = await import('@stacksjs/forms')
|
|
30
|
+
|
|
31
|
+
const form = await loadFormByUuid(String(request.params?.uuid ?? request.param?.('uuid') ?? ''), await siteIdForRequest(request))
|
|
32
|
+
if (!form || form.status === 'draft')
|
|
33
|
+
return response.notFound('Form not found')
|
|
34
|
+
|
|
35
|
+
return response.json(publicDefinition(form))
|
|
36
|
+
}).rateLimit(60, 'minute')
|
|
37
|
+
|
|
38
|
+
route.post('/api/forms/{uuid}/submissions', async (request: any) => {
|
|
39
|
+
const { dispatchSubmissionNotifications, loadFormByUuid, submitForm } = await import('@stacksjs/forms')
|
|
40
|
+
|
|
41
|
+
const form = await loadFormByUuid(String(request.params?.uuid ?? request.param?.('uuid') ?? ''), await siteIdForRequest(request))
|
|
42
|
+
if (!form)
|
|
43
|
+
return response.notFound('Form not found')
|
|
44
|
+
|
|
45
|
+
const body = (typeof request.all === 'function' ? request.all() : request.body) ?? {}
|
|
46
|
+
const { _hp, _renderedMs, ...payload } = body as Record<string, unknown>
|
|
47
|
+
|
|
48
|
+
const result = await submitForm(form, payload, {
|
|
49
|
+
ip: typeof request.ip === 'function' ? request.ip() : request.ip,
|
|
50
|
+
honeypot: typeof _hp === 'string' ? _hp : undefined,
|
|
51
|
+
renderedAtMs: typeof _renderedMs === 'number' ? _renderedMs : undefined,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
if (!result.ok) {
|
|
55
|
+
if (result.status === 422)
|
|
56
|
+
return response.json({ errors: result.errors }, { status: 422 })
|
|
57
|
+
return response.json({ message: result.message }, { status: result.status })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fire-and-forget AFTER the write: a slow mail transport must not hold a
|
|
61
|
+
// parent's phone on a spinner, and a failed one must not undo the answers.
|
|
62
|
+
if (result.submissionId > 0) {
|
|
63
|
+
void dispatchSubmissionNotifications(form, result, {
|
|
64
|
+
...await submissionIdentity(result.submissionId),
|
|
65
|
+
}).catch(() => {})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return response.json({
|
|
69
|
+
message: result.confirmation ?? 'Thanks - your response was received.',
|
|
70
|
+
status: result.status,
|
|
71
|
+
submission: result.submissionUuid,
|
|
72
|
+
amount_cents: result.amountCents,
|
|
73
|
+
redirect: result.redirect,
|
|
74
|
+
}, { status: 201 })
|
|
75
|
+
}).rateLimit(10, 'minute')
|
|
76
|
+
|
|
77
|
+
async function submissionIdentity(submissionId: number): Promise<{ email: string | null, name: string | null, values: Record<string, unknown> }> {
|
|
78
|
+
const { db } = await import('@stacksjs/database')
|
|
79
|
+
const row = await db
|
|
80
|
+
.selectFrom('form_submissions')
|
|
81
|
+
.where('id', '=', submissionId)
|
|
82
|
+
.select(['email', 'name', 'values'])
|
|
83
|
+
.executeTakeFirst() as { email: string | null, name: string | null, values: string | null } | undefined
|
|
84
|
+
|
|
85
|
+
let values: Record<string, unknown> = {}
|
|
86
|
+
try {
|
|
87
|
+
values = row?.values ? JSON.parse(row.values) as Record<string, unknown> : {}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// unreadable values only degrade the notification summary
|
|
91
|
+
}
|
|
92
|
+
return { email: row?.email ?? null, name: row?.name ?? null, values }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Admin CSV export. Auth'd; site scoping rides the form lookup. */
|
|
96
|
+
route.get('/api/admin/forms/{uuid}/submissions.csv', async (request: any) => {
|
|
97
|
+
const { exportSubmissionsCsv, loadFormByUuid } = await import('@stacksjs/forms')
|
|
98
|
+
|
|
99
|
+
const form = await loadFormByUuid(String(request.params?.uuid ?? request.param?.('uuid') ?? ''), await siteIdForRequest(request))
|
|
100
|
+
if (!form)
|
|
101
|
+
return response.notFound('Form not found')
|
|
102
|
+
|
|
103
|
+
const csv = await exportSubmissionsCsv(form)
|
|
104
|
+
return new Response(csv, {
|
|
105
|
+
headers: {
|
|
106
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
107
|
+
'Content-Disposition': `attachment; filename="${form.handle}-submissions.csv"`,
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
}).middleware('auth')
|