@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.
- package/README.md +125 -0
- package/dist/components/admin-bar/admin-bar.css +1 -1
- package/dist/components/admin-bar/field-input.svelte +1 -1
- package/dist/components/admin-bar/structured-editor-popover.svelte +21 -3
- package/dist/components/admin-bar/ui/badge/badge.svelte.d.ts +1 -1
- package/dist/components/cms/cms-form-controls.svelte +23 -0
- package/dist/components/cms/cms-form-controls.svelte.d.ts +13 -0
- package/dist/components/cms/cms-form-fields.svelte +68 -0
- package/dist/components/cms/cms-form-fields.svelte.d.ts +21 -0
- package/dist/components/cms/cms-form.svelte +99 -0
- package/dist/components/cms/cms-form.svelte.d.ts +35 -0
- package/dist/components/cms/cms-whatsapp-cart.svelte +202 -0
- package/dist/components/cms/cms-whatsapp-cart.svelte.d.ts +49 -0
- package/dist/components/cms/cms-whatsapp-form-editable.svelte +43 -0
- package/dist/components/cms/cms-whatsapp-form-editable.svelte.d.ts +17 -0
- package/dist/components/cms/cms-whatsapp-form.svelte +167 -0
- package/dist/components/cms/cms-whatsapp-form.svelte.d.ts +45 -0
- package/dist/components/cms/index.d.ts +5 -0
- package/dist/components/cms/index.js +3 -0
- package/dist/components/cms/use-form-target.svelte.d.ts +18 -0
- package/dist/components/cms/use-form-target.svelte.js +68 -0
- package/dist/components/cms/whatsapp-cart.svelte.d.ts +46 -0
- package/dist/components/cms/whatsapp-cart.svelte.js +86 -0
- package/dist/core/forms/order-message.d.ts +10 -0
- package/dist/core/forms/order-message.js +13 -0
- package/dist/core/forms/target.d.ts +9 -0
- package/dist/core/forms/target.js +9 -0
- package/dist/core/shapes/form-fields.d.ts +20 -0
- package/dist/core/shapes/form-fields.js +117 -0
- package/dist/core/shapes/index.d.ts +4 -0
- package/dist/core/shapes/index.js +2 -0
- package/dist/core/shapes/whatsapp.d.ts +8 -0
- package/dist/core/shapes/whatsapp.js +28 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- 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
|
+
};
|