@velastack/cms 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +125 -0
  2. package/dist/components/admin-bar/admin-bar.css +1 -1
  3. package/dist/components/admin-bar/field-input.svelte +1 -1
  4. package/dist/components/admin-bar/structured-editor-popover.svelte +21 -3
  5. package/dist/components/admin-bar/ui/badge/badge.svelte.d.ts +1 -1
  6. package/dist/components/cms/cms-form-controls.svelte +23 -0
  7. package/dist/components/cms/cms-form-controls.svelte.d.ts +13 -0
  8. package/dist/components/cms/cms-form-fields.svelte +68 -0
  9. package/dist/components/cms/cms-form-fields.svelte.d.ts +21 -0
  10. package/dist/components/cms/cms-form.svelte +99 -0
  11. package/dist/components/cms/cms-form.svelte.d.ts +35 -0
  12. package/dist/components/cms/cms-whatsapp-cart.svelte +202 -0
  13. package/dist/components/cms/cms-whatsapp-cart.svelte.d.ts +49 -0
  14. package/dist/components/cms/cms-whatsapp-form-editable.svelte +43 -0
  15. package/dist/components/cms/cms-whatsapp-form-editable.svelte.d.ts +17 -0
  16. package/dist/components/cms/cms-whatsapp-form.svelte +167 -0
  17. package/dist/components/cms/cms-whatsapp-form.svelte.d.ts +45 -0
  18. package/dist/components/cms/index.d.ts +5 -0
  19. package/dist/components/cms/index.js +3 -0
  20. package/dist/components/cms/use-form-target.svelte.d.ts +18 -0
  21. package/dist/components/cms/use-form-target.svelte.js +68 -0
  22. package/dist/components/cms/whatsapp-cart.svelte.d.ts +46 -0
  23. package/dist/components/cms/whatsapp-cart.svelte.js +86 -0
  24. package/dist/core/forms/order-message.d.ts +10 -0
  25. package/dist/core/forms/order-message.js +13 -0
  26. package/dist/core/forms/target.d.ts +9 -0
  27. package/dist/core/forms/target.js +9 -0
  28. package/dist/core/shapes/form-fields.d.ts +20 -0
  29. package/dist/core/shapes/form-fields.js +117 -0
  30. package/dist/core/shapes/index.d.ts +4 -0
  31. package/dist/core/shapes/index.js +2 -0
  32. package/dist/core/shapes/whatsapp.d.ts +8 -0
  33. package/dist/core/shapes/whatsapp.js +28 -0
  34. package/dist/index.d.ts +9 -0
  35. package/dist/index.js +8 -0
  36. package/package.json +2 -1
@@ -0,0 +1,35 @@
1
+ import type { FormStatus } from './use-form-target.svelte.js';
2
+ export type { FormStatus as CmsFormStatus };
3
+ /** What `children` gets: how the last submission went. */
4
+ export type CmsFormState = {
5
+ readonly status: FormStatus;
6
+ /** The endpoint's or action's message on `failure` / `error`. */
7
+ readonly message: string | null;
8
+ /** The action's returned data on `success` / `failure`. */
9
+ readonly data: Record<string, unknown> | null;
10
+ };
11
+ /**
12
+ * `CmsForm`: a form that posts to a hosted form by `key`, or to the
13
+ * template's own `action`. The inputs are the template's children.
14
+ *
15
+ * It is a real `<form method="POST">`, so it works without JavaScript;
16
+ * with it, `use:enhance` posts in place and `children` sees the status.
17
+ * The hosted endpoint answers enhanced posts the way a SvelteKit action
18
+ * does, so both destinations behave the same. Results are handled here
19
+ * rather than by the default callback, which would reload the page's data
20
+ * on success and send a network failure to the error page.
21
+ */
22
+ import type { Snippet } from 'svelte';
23
+ import type { HTMLFormAttributes } from 'svelte/elements';
24
+ type Props = Omit<HTMLFormAttributes, 'action' | 'method' | 'children'> & {
25
+ /** A hosted form's key, as the template manifest declares it. */
26
+ key?: string;
27
+ /** Post here instead: a route, or a SvelteKit action like `?/contact`. */
28
+ action?: string;
29
+ /** Where to go after a successful submission. */
30
+ redirect?: string;
31
+ children: Snippet<[CmsFormState]>;
32
+ };
33
+ declare const CmsForm: import("svelte").Component<Props, {}, "">;
34
+ type CmsForm = ReturnType<typeof CmsForm>;
35
+ export default CmsForm;
@@ -0,0 +1,202 @@
1
+ <script lang="ts" module>
2
+ import type { CmsFormState } from './cms-form.svelte';
3
+ import type { WhatsAppPlacedOrder } from './whatsapp-cart.svelte.js';
4
+ import { whatsappCart } from './whatsapp-cart.svelte.js';
5
+
6
+ export type CmsWhatsAppCartState = CmsFormState & {
7
+ /** The cart: its items, count and subtotal, and the methods that change them. */
8
+ readonly cart: typeof whatsappCart;
9
+ /** The order last sent to WhatsApp, until `cart.clear()`. */
10
+ readonly placed: WhatsAppPlacedOrder | null;
11
+ /** An amount in the site's currency, formatted in the page's language. */
12
+ formatPrice: (amount: number) => string;
13
+ };
14
+ </script>
15
+
16
+ <script lang="ts">
17
+ /**
18
+ * `CmsWhatsAppCart`: checkout for `whatsappCart`. The template's form
19
+ * asks for `name`, `phone` and an optional `note`; submitting turns the
20
+ * cart into the editor's message (`{ref}`, `{items}`, `{total}`, `{name}`,
21
+ * `{phone}`, `{note}`), opens it in WhatsApp to the editor's number, and
22
+ * records the order to the hosted form `key` in the background, so it is
23
+ * kept even if the visitor never sends the chat.
24
+ *
25
+ * The chat opens inside the submit handler, before the post settles: a
26
+ * popup opened after an `await` loses the click's user activation and is
27
+ * blocked. If it is blocked anyway, this tab goes to WhatsApp instead.
28
+ *
29
+ * The template draws the cart itself: items, quantities and add buttons
30
+ * call `whatsappCart`, and `children` gets the cart and how checkout went.
31
+ */
32
+ import { onMount, type Snippet } from 'svelte';
33
+ import type { HTMLFormAttributes } from 'svelte/elements';
34
+ import {
35
+ fillMessage,
36
+ formatMoney,
37
+ openChat,
38
+ orderPlaceholders,
39
+ orderRef,
40
+ orderSubtotal,
41
+ recordOrder,
42
+ whatsappHref,
43
+ type Order
44
+ } from '@velastack/whatsapp';
45
+ import Structured from './cms-structured.svelte';
46
+ import FormControls from './cms-form-controls.svelte';
47
+ import { cms, cmsStore } from './cms-store.svelte.js';
48
+ import { useFormTarget, type FormStatus } from './use-form-target.svelte.js';
49
+ import { setWhatsAppFieldNames } from './cms-whatsapp-form.svelte';
50
+ import { cmsWhatsApp, type CmsWhatsAppValue } from '../../core/shapes/whatsapp.js';
51
+ import {
52
+ DEFAULT_ORDER_MESSAGE,
53
+ ORDER_PLACEHOLDERS,
54
+ tidyMessage
55
+ } from '../../core/forms/order-message.js';
56
+
57
+ type Props = Omit<HTMLFormAttributes, 'action' | 'method' | 'children'> & {
58
+ /** The CMS field holding the number and message. */
59
+ name: string;
60
+ scope?: string;
61
+ fallback?: Partial<CmsWhatsAppValue>;
62
+ value?: unknown;
63
+ /** The hosted form to record each order to, as the template manifest declares it. */
64
+ key?: string;
65
+ /** Record to this route instead. */
66
+ action?: string;
67
+ /**
68
+ * ISO 4217: the business's currency, which every price is in. Defaults
69
+ * to the `currency` site option.
70
+ */
71
+ currency?: string;
72
+ children: Snippet<[CmsWhatsAppCartState]>;
73
+ };
74
+ let {
75
+ name,
76
+ scope,
77
+ fallback,
78
+ value,
79
+ key,
80
+ action,
81
+ currency,
82
+ children: content,
83
+ ...rest
84
+ }: Props = $props();
85
+
86
+ $effect.pre(() => {
87
+ const fromSite = cms.site.currency;
88
+ whatsappCart.currency =
89
+ currency ?? (typeof fromSite === 'string' ? fromSite.trim().toUpperCase() : '');
90
+ });
91
+
92
+ // The page's language, not the browser's: an English page shows MXN as
93
+ // `MX$5.50`, a Spanish one as `$5.50`.
94
+ const pageLocale = () =>
95
+ (typeof document !== 'undefined' && document.documentElement.lang) || undefined;
96
+
97
+ const target = useFormTarget(() => ({ key, action }));
98
+ setWhatsAppFieldNames(() => ORDER_PLACEHOLDERS);
99
+
100
+ let status = $state<FormStatus>('idle');
101
+ let message = $state<string | null>(null);
102
+
103
+ const cartState: CmsWhatsAppCartState = {
104
+ get status() {
105
+ return status;
106
+ },
107
+ get message() {
108
+ return message;
109
+ },
110
+ data: null,
111
+ cart: whatsappCart,
112
+ get placed() {
113
+ return whatsappCart.placed;
114
+ },
115
+ formatPrice: (amount) => formatMoney(amount, whatsappCart.currency, pageLocale())
116
+ };
117
+
118
+ // The cart lives in the browser, so the server renders it empty and it
119
+ // fills in here.
120
+ onMount(() => whatsappCart.load());
121
+
122
+ const field = (body: FormData, name: string) => {
123
+ const value = body.get(name);
124
+ return typeof value === 'string' ? value.trim() : '';
125
+ };
126
+
127
+ const record = (order: Order, text: string, gotcha: string) => {
128
+ if (!target.url) {
129
+ status = 'success';
130
+ return;
131
+ }
132
+ status = 'submitting';
133
+ recordOrder(target.url, order, { message: text, token: target.token, gotcha })
134
+ .then(async (res) => {
135
+ status = res.ok ? 'success' : 'failure';
136
+ if (!res.ok) message = ((await res.json().catch(() => null))?.message as string) ?? null;
137
+ })
138
+ .catch(() => {
139
+ status = 'error';
140
+ });
141
+ };
142
+
143
+ const submit = (event: SubmitEvent, settings: CmsWhatsAppValue) => {
144
+ event.preventDefault();
145
+ if (cmsStore.isEditing) return;
146
+ message = null;
147
+ if (whatsappCart.items.length === 0) {
148
+ status = 'error';
149
+ message = 'The cart is empty.';
150
+ return;
151
+ }
152
+ const body = new FormData(event.currentTarget as HTMLFormElement, event.submitter);
153
+ if (!whatsappCart.currency) {
154
+ status = 'error';
155
+ message = 'No currency is set.';
156
+ return;
157
+ }
158
+ const lines = whatsappCart.items.map(({ name, quantity, price }) => ({
159
+ name,
160
+ quantity,
161
+ price
162
+ }));
163
+ const order: Order = {
164
+ ref: orderRef(),
165
+ name: field(body, 'name'),
166
+ phone: field(body, 'phone'),
167
+ note: field(body, 'note') || undefined,
168
+ currency: whatsappCart.currency,
169
+ lines,
170
+ subtotal: orderSubtotal(lines, whatsappCart.currency)
171
+ };
172
+ const text = tidyMessage(
173
+ fillMessage(settings.message || DEFAULT_ORDER_MESSAGE, orderPlaceholders(order, pageLocale()))
174
+ );
175
+ const href = whatsappHref(settings.number, text);
176
+ if (!href) {
177
+ status = 'error';
178
+ message = 'No WhatsApp number is set.';
179
+ return;
180
+ }
181
+ record(order, text, field(body, '_gotcha'));
182
+ openChat(href);
183
+ whatsappCart.place({ ref: order.ref, href });
184
+ };
185
+ </script>
186
+
187
+ <Structured
188
+ schema={cmsWhatsApp}
189
+ {name}
190
+ {scope}
191
+ {fallback}
192
+ {value}
193
+ view={(settings) => settings}
194
+ editable={() => import('./cms-whatsapp-form-editable.svelte')}
195
+ >
196
+ {#snippet children(settings)}
197
+ <form {...rest} method="POST" action={target.url} onsubmit={(event) => submit(event, settings)}>
198
+ <FormControls token={target.token} />
199
+ {@render content(cartState)}
200
+ </form>
201
+ {/snippet}
202
+ </Structured>
@@ -0,0 +1,49 @@
1
+ import type { CmsFormState } from './cms-form.svelte';
2
+ import type { WhatsAppPlacedOrder } from './whatsapp-cart.svelte.js';
3
+ import { whatsappCart } from './whatsapp-cart.svelte.js';
4
+ export type CmsWhatsAppCartState = CmsFormState & {
5
+ /** The cart: its items, count and subtotal, and the methods that change them. */
6
+ readonly cart: typeof whatsappCart;
7
+ /** The order last sent to WhatsApp, until `cart.clear()`. */
8
+ readonly placed: WhatsAppPlacedOrder | null;
9
+ /** An amount in the site's currency, formatted in the page's language. */
10
+ formatPrice: (amount: number) => string;
11
+ };
12
+ /**
13
+ * `CmsWhatsAppCart`: checkout for `whatsappCart`. The template's form
14
+ * asks for `name`, `phone` and an optional `note`; submitting turns the
15
+ * cart into the editor's message (`{ref}`, `{items}`, `{total}`, `{name}`,
16
+ * `{phone}`, `{note}`), opens it in WhatsApp to the editor's number, and
17
+ * records the order to the hosted form `key` in the background, so it is
18
+ * kept even if the visitor never sends the chat.
19
+ *
20
+ * The chat opens inside the submit handler, before the post settles: a
21
+ * popup opened after an `await` loses the click's user activation and is
22
+ * blocked. If it is blocked anyway, this tab goes to WhatsApp instead.
23
+ *
24
+ * The template draws the cart itself: items, quantities and add buttons
25
+ * call `whatsappCart`, and `children` gets the cart and how checkout went.
26
+ */
27
+ import { type Snippet } from 'svelte';
28
+ import type { HTMLFormAttributes } from 'svelte/elements';
29
+ import { type CmsWhatsAppValue } from '../../core/shapes/whatsapp.js';
30
+ type Props = Omit<HTMLFormAttributes, 'action' | 'method' | 'children'> & {
31
+ /** The CMS field holding the number and message. */
32
+ name: string;
33
+ scope?: string;
34
+ fallback?: Partial<CmsWhatsAppValue>;
35
+ value?: unknown;
36
+ /** The hosted form to record each order to, as the template manifest declares it. */
37
+ key?: string;
38
+ /** Record to this route instead. */
39
+ action?: string;
40
+ /**
41
+ * ISO 4217: the business's currency, which every price is in. Defaults
42
+ * to the `currency` site option.
43
+ */
44
+ currency?: string;
45
+ children: Snippet<[CmsWhatsAppCartState]>;
46
+ };
47
+ declare const CmsWhatsappCart: import("svelte").Component<Props, {}, "">;
48
+ type CmsWhatsappCart = ReturnType<typeof CmsWhatsappCart>;
49
+ export default CmsWhatsappCart;
@@ -0,0 +1,43 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The editor for `CmsWhatsAppForm`: the shape's fields, plus the
4
+ * `{placeholders}` the form's inputs provide, read from the form when the
5
+ * popover opens so fields added through `CmsFormFields` show up too.
6
+ */
7
+ import type { Snippet } from 'svelte';
8
+ import type { Tree } from '../../core/path.js';
9
+ import type { FormShape } from '../../core/shapes/form.js';
10
+ import type { CmsWhatsAppValue } from '../../core/shapes/whatsapp.js';
11
+ import type { CmsField } from './use-cms-field.svelte.js';
12
+ import { getWhatsAppFieldNames } from './cms-whatsapp-form.svelte';
13
+ import StructuredEditorPopover from '../admin-bar/structured-editor-popover.svelte';
14
+ import FieldsForm from '../admin-bar/fields-form.svelte';
15
+
16
+ type Props = {
17
+ field: CmsField<CmsWhatsAppValue>;
18
+ schema: FormShape<CmsWhatsAppValue>;
19
+ preview: Snippet;
20
+ };
21
+ let { field, schema, preview }: Props = $props();
22
+
23
+ const fieldNames = getWhatsAppFieldNames();
24
+ </script>
25
+
26
+ <StructuredEditorPopover {field} {schema} label={schema.label} {preview}>
27
+ {#snippet editor(draft: CmsWhatsAppValue, update: (next: CmsWhatsAppValue) => void)}
28
+ {@const names = fieldNames()}
29
+ <FieldsForm
30
+ fields={schema.fields}
31
+ value={draft as Tree}
32
+ onChange={(next) => update(next as CmsWhatsAppValue)}
33
+ />
34
+ {#if names.length > 0}
35
+ <p class="vela:text-[11px] vela:text-bar-text-tertiary vela:m-0">
36
+ Placeholders:
37
+ {#each names as name (name)}
38
+ <code class="vela:font-mono vela:mr-1">{`{${name}}`}</code>
39
+ {/each}
40
+ </p>
41
+ {/if}
42
+ {/snippet}
43
+ </StructuredEditorPopover>
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The editor for `CmsWhatsAppForm`: the shape's fields, plus the
3
+ * `{placeholders}` the form's inputs provide, read from the form when the
4
+ * popover opens so fields added through `CmsFormFields` show up too.
5
+ */
6
+ import type { Snippet } from 'svelte';
7
+ import type { FormShape } from '../../core/shapes/form.js';
8
+ import type { CmsWhatsAppValue } from '../../core/shapes/whatsapp.js';
9
+ import type { CmsField } from './use-cms-field.svelte.js';
10
+ type Props = {
11
+ field: CmsField<CmsWhatsAppValue>;
12
+ schema: FormShape<CmsWhatsAppValue>;
13
+ preview: Snippet;
14
+ };
15
+ declare const CmsWhatsappFormEditable: import("svelte").Component<Props, {}, "">;
16
+ type CmsWhatsappFormEditable = ReturnType<typeof CmsWhatsappFormEditable>;
17
+ export default CmsWhatsappFormEditable;
@@ -0,0 +1,167 @@
1
+ <script lang="ts" module>
2
+ /** `CmsWhatsAppForm`: a form whose answers open a prefilled WhatsApp chat. */
3
+ import { getContext, setContext } from 'svelte';
4
+ import { isControlField } from '../../core/forms/target.js';
5
+ import type { CmsFormState } from './cms-form.svelte';
6
+
7
+ export * from '../../core/shapes/whatsapp.js';
8
+
9
+ export type CmsWhatsAppFormState = CmsFormState & {
10
+ /** A chat link with no message, for a `<noscript>` fallback; `null` without a number. */
11
+ readonly whatsappHref: string | null;
12
+ };
13
+
14
+ const FIELD_NAMES = Symbol('vela-cms:whatsapp-field-names');
15
+
16
+ /** For the settings editor: the `{placeholders}` this form's inputs provide. */
17
+ export const getWhatsAppFieldNames = (): (() => string[]) =>
18
+ getContext<() => string[]>(FIELD_NAMES) ?? (() => []);
19
+
20
+ /** What a form fills its message from, for the settings editor to list. */
21
+ export const setWhatsAppFieldNames = (names: () => string[]): void => {
22
+ setContext(FIELD_NAMES, names);
23
+ };
24
+
25
+ const fieldNames = (form: HTMLFormElement): string[] => {
26
+ const names = new Set<string>();
27
+ for (const el of form.elements) {
28
+ const name = (el as HTMLInputElement).name;
29
+ const type = (el as HTMLInputElement).type;
30
+ if (name && !isControlField(name) && type !== 'submit' && type !== 'button') names.add(name);
31
+ }
32
+ return [...names];
33
+ };
34
+ </script>
35
+
36
+ <script lang="ts">
37
+ /**
38
+ * Like `CmsForm`, but a submission opens `wa.me` with the editor's
39
+ * message (answers substituted for `{field}` placeholders) addressed to
40
+ * the editor's number. The answers are also posted in the background, so
41
+ * the lead is kept even if the visitor never sends the chat.
42
+ *
43
+ * The chat opens inside the submit handler, before the post settles: a
44
+ * popup opened after an `await` loses the click's user activation and is
45
+ * blocked. `keepalive` lets the post finish if the page goes away. If the
46
+ * popup is blocked anyway, this tab goes to WhatsApp instead.
47
+ *
48
+ * Without JavaScript the form posts normally, so the answers are still
49
+ * stored and `redirect` applies; `whatsappHref` is there for a
50
+ * `<noscript>` link.
51
+ */
52
+ import type { Snippet } from 'svelte';
53
+ import type { HTMLFormAttributes } from 'svelte/elements';
54
+ import Structured from './cms-structured.svelte';
55
+ import FormControls from './cms-form-controls.svelte';
56
+ import { cmsStore } from './cms-store.svelte.js';
57
+ import { followRedirect, useFormTarget, type FormStatus } from './use-form-target.svelte.js';
58
+ import { cmsWhatsApp, type CmsWhatsAppValue } from '../../core/shapes/whatsapp.js';
59
+ import { fillMessage, openChat, whatsappHref } from '@velastack/whatsapp';
60
+
61
+ type Props = Omit<HTMLFormAttributes, 'action' | 'method' | 'children'> & {
62
+ /** The CMS field holding the number and message. */
63
+ name: string;
64
+ scope?: string;
65
+ fallback?: Partial<CmsWhatsAppValue>;
66
+ value?: unknown;
67
+ /** A hosted form's key, to record each submission to. */
68
+ key?: string;
69
+ /** Record to this route or SvelteKit action instead. */
70
+ action?: string;
71
+ /** Where this tab goes once the chat has opened in a new one. */
72
+ redirect?: string;
73
+ children: Snippet<[CmsWhatsAppFormState]>;
74
+ };
75
+ let {
76
+ name,
77
+ scope,
78
+ fallback,
79
+ value,
80
+ key,
81
+ action,
82
+ redirect,
83
+ children: content,
84
+ ...rest
85
+ }: Props = $props();
86
+
87
+ const target = useFormTarget(() => ({ key, action }));
88
+
89
+ let formEl = $state<HTMLFormElement>();
90
+ setWhatsAppFieldNames(() => (formEl ? fieldNames(formEl) : []));
91
+
92
+ let status = $state<FormStatus>('idle');
93
+ let message = $state<string | null>(null);
94
+
95
+ const stateFor = (settings: CmsWhatsAppValue): CmsWhatsAppFormState => ({
96
+ get status() {
97
+ return status;
98
+ },
99
+ get message() {
100
+ return message;
101
+ },
102
+ data: null,
103
+ whatsappHref: whatsappHref(settings.number)
104
+ });
105
+
106
+ const record = (body: FormData) => {
107
+ if (!target.url) {
108
+ status = 'success';
109
+ return;
110
+ }
111
+ body.delete('_redirect');
112
+ status = 'submitting';
113
+ fetch(target.url, {
114
+ method: 'POST',
115
+ body,
116
+ keepalive: true,
117
+ headers: { accept: 'application/json' }
118
+ })
119
+ .then(async (res) => {
120
+ status = res.ok ? 'success' : 'failure';
121
+ if (!res.ok) message = ((await res.json().catch(() => null))?.message as string) ?? null;
122
+ })
123
+ .catch(() => {
124
+ status = 'error';
125
+ });
126
+ };
127
+
128
+ const submit = (event: SubmitEvent, settings: CmsWhatsAppValue) => {
129
+ event.preventDefault();
130
+ if (cmsStore.isEditing) return;
131
+ const form = event.currentTarget as HTMLFormElement;
132
+ const body = new FormData(form, event.submitter);
133
+ const href = whatsappHref(settings.number, fillMessage(settings.message, body));
134
+ message = null;
135
+ record(body);
136
+ if (!href) {
137
+ status = 'error';
138
+ message = 'No WhatsApp number is set.';
139
+ return;
140
+ }
141
+ if (!openChat(href)) return;
142
+ if (redirect) void followRedirect(redirect);
143
+ };
144
+ </script>
145
+
146
+ <Structured
147
+ schema={cmsWhatsApp}
148
+ {name}
149
+ {scope}
150
+ {fallback}
151
+ {value}
152
+ view={(settings) => settings}
153
+ editable={() => import('./cms-whatsapp-form-editable.svelte')}
154
+ >
155
+ {#snippet children(settings)}
156
+ <form
157
+ {...rest}
158
+ method="POST"
159
+ action={target.url}
160
+ bind:this={formEl}
161
+ onsubmit={(event) => submit(event, settings)}
162
+ >
163
+ <FormControls {redirect} token={target.token} />
164
+ {@render content(stateFor(settings))}
165
+ </form>
166
+ {/snippet}
167
+ </Structured>
@@ -0,0 +1,45 @@
1
+ import type { CmsFormState } from './cms-form.svelte';
2
+ export * from '../../core/shapes/whatsapp.js';
3
+ export type CmsWhatsAppFormState = CmsFormState & {
4
+ /** A chat link with no message, for a `<noscript>` fallback; `null` without a number. */
5
+ readonly whatsappHref: string | null;
6
+ };
7
+ /** For the settings editor: the `{placeholders}` this form's inputs provide. */
8
+ export declare const getWhatsAppFieldNames: () => (() => string[]);
9
+ /** What a form fills its message from, for the settings editor to list. */
10
+ export declare const setWhatsAppFieldNames: (names: () => string[]) => void;
11
+ /**
12
+ * Like `CmsForm`, but a submission opens `wa.me` with the editor's
13
+ * message (answers substituted for `{field}` placeholders) addressed to
14
+ * the editor's number. The answers are also posted in the background, so
15
+ * the lead is kept even if the visitor never sends the chat.
16
+ *
17
+ * The chat opens inside the submit handler, before the post settles: a
18
+ * popup opened after an `await` loses the click's user activation and is
19
+ * blocked. `keepalive` lets the post finish if the page goes away. If the
20
+ * popup is blocked anyway, this tab goes to WhatsApp instead.
21
+ *
22
+ * Without JavaScript the form posts normally, so the answers are still
23
+ * stored and `redirect` applies; `whatsappHref` is there for a
24
+ * `<noscript>` link.
25
+ */
26
+ import type { Snippet } from 'svelte';
27
+ import type { HTMLFormAttributes } from 'svelte/elements';
28
+ import { type CmsWhatsAppValue } from '../../core/shapes/whatsapp.js';
29
+ type Props = Omit<HTMLFormAttributes, 'action' | 'method' | 'children'> & {
30
+ /** The CMS field holding the number and message. */
31
+ name: string;
32
+ scope?: string;
33
+ fallback?: Partial<CmsWhatsAppValue>;
34
+ value?: unknown;
35
+ /** A hosted form's key, to record each submission to. */
36
+ key?: string;
37
+ /** Record to this route or SvelteKit action instead. */
38
+ action?: string;
39
+ /** Where this tab goes once the chat has opened in a new one. */
40
+ redirect?: string;
41
+ children: Snippet<[CmsWhatsAppFormState]>;
42
+ };
43
+ declare const CmsWhatsappForm: import("svelte").Component<Props, {}, "">;
44
+ type CmsWhatsappForm = ReturnType<typeof CmsWhatsappForm>;
45
+ export default CmsWhatsappForm;
@@ -23,5 +23,10 @@ export { default as CmsGallery } from './cms-gallery.svelte';
23
23
  export { default as CmsLogos } from './cms-logos.svelte';
24
24
  export { default as CmsSchedule } from './cms-schedule.svelte';
25
25
  export { default as CmsEntries } from './cms-entries.svelte';
26
+ export { default as CmsForm } from './cms-form.svelte';
27
+ export type { CmsFormState, CmsFormStatus } from './cms-form.svelte';
28
+ export { default as CmsFormFields } from './cms-form-fields.svelte';
29
+ export { default as CmsWhatsAppForm } from './cms-whatsapp-form.svelte';
30
+ export type { CmsWhatsAppFormState } from './cms-whatsapp-form.svelte';
26
31
  export { CMS_SCOPE, getCmsScope, getCmsValue } from './scope.js';
27
32
  export type { CmsScope, CmsPayload, CmsEntry } from './scope.js';
@@ -22,4 +22,7 @@ export { default as CmsGallery } from './cms-gallery.svelte';
22
22
  export { default as CmsLogos } from './cms-logos.svelte';
23
23
  export { default as CmsSchedule } from './cms-schedule.svelte';
24
24
  export { default as CmsEntries } from './cms-entries.svelte';
25
+ export { default as CmsForm } from './cms-form.svelte';
26
+ export { default as CmsFormFields } from './cms-form-fields.svelte';
27
+ export { default as CmsWhatsAppForm } from './cms-whatsapp-form.svelte';
25
28
  export { CMS_SCOPE, getCmsScope, getCmsValue } from './scope.js';
@@ -0,0 +1,18 @@
1
+ export type FormStatus = 'idle' | 'submitting' | 'success' | 'failure' | 'error';
2
+ export type FormTargetInput = {
3
+ key?: string;
4
+ action?: string;
5
+ };
6
+ /**
7
+ * After an enhanced post, go where `_redirect` would have sent a post
8
+ * without JavaScript. The hosted endpoint never answers an enhanced post
9
+ * with a redirect (the client would hand an external URL to `goto`), so the
10
+ * form follows it itself.
11
+ */
12
+ export declare const followRedirect: (redirect: string) => Promise<void>;
13
+ export type FormTarget = {
14
+ /** The form's `action`; `undefined` posts back to the page. */
15
+ readonly url: string | undefined;
16
+ readonly token: string | null;
17
+ };
18
+ export declare const useFormTarget: (input: () => FormTargetInput) => FormTarget;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Where a `CmsForm` or `CmsWhatsAppForm` posts, and the load-time token a
3
+ * hosted form wants with it.
4
+ *
5
+ * `action` wins when given: the template's own route or SvelteKit action.
6
+ * Otherwise `key` names a hosted form beside the CMS endpoint (see
7
+ * `core/forms/target.ts`). The token only exists for hosted forms. It is
8
+ * fetched on mount because the endpoint refuses one younger than two
9
+ * seconds; a post without it is still stored, just as unverified.
10
+ */
11
+ import { dev } from '$app/env';
12
+ import { goto } from '$app/navigation';
13
+ import { cms } from './cms-store.svelte.js';
14
+ import { hostedFormUrl } from '../../core/forms/target.js';
15
+ /**
16
+ * After an enhanced post, go where `_redirect` would have sent a post
17
+ * without JavaScript. The hosted endpoint never answers an enhanced post
18
+ * with a redirect (the client would hand an external URL to `goto`), so the
19
+ * form follows it itself.
20
+ */
21
+ export const followRedirect = async (redirect) => {
22
+ const url = new URL(redirect, location.href);
23
+ if (url.origin === location.origin)
24
+ await goto(url.href);
25
+ else
26
+ location.assign(url.href);
27
+ };
28
+ export const useFormTarget = (input) => {
29
+ const hosted = $derived.by(() => {
30
+ const { key, action } = input();
31
+ return action === undefined && key ? hostedFormUrl(cms.endpoint, key) : null;
32
+ });
33
+ const url = $derived(input().action ?? hosted ?? undefined);
34
+ let token = $state(null);
35
+ $effect(() => {
36
+ const target = hosted;
37
+ token = null;
38
+ if (!target)
39
+ return;
40
+ let live = true;
41
+ fetch(target, { headers: { accept: 'application/json' } })
42
+ .then((res) => (res.ok ? res.json() : null))
43
+ .then((body) => {
44
+ if (live && typeof body?.token === 'string')
45
+ token = body.token;
46
+ })
47
+ .catch(() => { });
48
+ return () => {
49
+ live = false;
50
+ };
51
+ });
52
+ $effect(() => {
53
+ if (dev && url === undefined) {
54
+ const { key } = input();
55
+ console.warn(key
56
+ ? `[cms] Form "${key}" has no hosted endpoint (CMS endpoint "${cms.endpoint}"); pass \`action\`.`
57
+ : '[cms] Form has neither `key` nor `action`; it will post back to the page.');
58
+ }
59
+ });
60
+ return {
61
+ get url() {
62
+ return url;
63
+ },
64
+ get token() {
65
+ return token;
66
+ }
67
+ };
68
+ };