@velastack/cms 0.6.3 → 0.6.5
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 +96 -4
- package/dist/components/admin-bar/admin-bar-internal.svelte +18 -12
- package/dist/components/admin-bar/admin-bar.css +1 -1
- package/dist/components/admin-bar/admin-bar.svelte +1 -1
- package/dist/components/admin-bar/field-input.svelte +1 -1
- package/dist/components/admin-bar/history-panel.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-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 +43 -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/core/forms/target.d.ts +3 -0
- package/dist/core/forms/target.js +16 -0
- package/dist/core/forms/whatsapp.d.ts +18 -0
- package/dist/core/forms/whatsapp.js +29 -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 +5 -0
- package/dist/index.js +6 -0
- package/dist/server/load-cms.js +10 -7
- package/dist/server/mock-adapter.js +4 -0
- package/package.json +6 -1
- package/plugin/dist/build.d.ts +34 -2
- package/plugin/dist/build.js +58 -2
- package/plugin/dist/build.js.map +1 -1
- package/plugin/dist/index.js +66 -20
- package/plugin/dist/index.js.map +1 -1
- package/plugin/dist/manifest.d.ts +22 -0
- package/plugin/dist/manifest.js +35 -4
- package/plugin/dist/manifest.js.map +1 -1
- package/plugin/dist/parse-svelte.d.ts +6 -0
- package/plugin/dist/parse-svelte.js +5 -0
- package/plugin/dist/parse-svelte.js.map +1 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/** `CmsFormFields`: a form's inputs as content editors can change. */
|
|
3
|
+
export * from '../../core/shapes/form-fields.js';
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
/**
|
|
8
|
+
* Goes inside a `CmsForm` or `CmsWhatsAppForm`. Renders one input per
|
|
9
|
+
* field through the template's `field` snippet, or plain labelled inputs
|
|
10
|
+
* without one. Editors add, remove, reorder and retype fields from the
|
|
11
|
+
* pencil; each field's submitted `name` stays put (see the shape).
|
|
12
|
+
*/
|
|
13
|
+
import type { Snippet } from 'svelte';
|
|
14
|
+
import Structured from './cms-structured.svelte';
|
|
15
|
+
import {
|
|
16
|
+
cmsFormFields,
|
|
17
|
+
formFieldsView,
|
|
18
|
+
type CmsFormFieldItem,
|
|
19
|
+
type CmsFormFieldsValue
|
|
20
|
+
} from '../../core/shapes/form-fields.js';
|
|
21
|
+
|
|
22
|
+
type Props = {
|
|
23
|
+
name: string;
|
|
24
|
+
scope?: string;
|
|
25
|
+
/** The full value, or a bare field list. */
|
|
26
|
+
fallback?: Partial<CmsFormFieldsValue> | Array<Partial<CmsFormFieldItem>>;
|
|
27
|
+
value?: unknown;
|
|
28
|
+
field?: Snippet<[CmsFormFieldItem]>;
|
|
29
|
+
};
|
|
30
|
+
let { name, scope, fallback, value, field }: Props = $props();
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
{#snippet plain(f: CmsFormFieldItem)}
|
|
34
|
+
<label class="cms-form-field" data-type={f.type}>
|
|
35
|
+
{#if f.type === 'checkbox'}
|
|
36
|
+
<input type="checkbox" name={f.name} value="yes" required={f.required} />
|
|
37
|
+
<span>{f.label}</span>
|
|
38
|
+
{:else}
|
|
39
|
+
<span>{f.label}</span>
|
|
40
|
+
{#if f.type === 'textarea'}
|
|
41
|
+
<textarea name={f.name} required={f.required} placeholder={f.placeholder || undefined}
|
|
42
|
+
></textarea>
|
|
43
|
+
{:else if f.type === 'select'}
|
|
44
|
+
<select name={f.name} required={f.required}>
|
|
45
|
+
<option value="">{f.placeholder}</option>
|
|
46
|
+
{#each f.options as option, i (i)}
|
|
47
|
+
<option>{option}</option>
|
|
48
|
+
{/each}
|
|
49
|
+
</select>
|
|
50
|
+
{:else}
|
|
51
|
+
<input
|
|
52
|
+
type={f.type}
|
|
53
|
+
name={f.name}
|
|
54
|
+
required={f.required}
|
|
55
|
+
placeholder={f.placeholder || undefined}
|
|
56
|
+
/>
|
|
57
|
+
{/if}
|
|
58
|
+
{/if}
|
|
59
|
+
</label>
|
|
60
|
+
{/snippet}
|
|
61
|
+
|
|
62
|
+
<Structured schema={cmsFormFields} {name} {scope} {fallback} {value} view={formFieldsView}>
|
|
63
|
+
{#snippet children(fields)}
|
|
64
|
+
{#each fields as f (f.id)}
|
|
65
|
+
{@render (field ?? plain)(f)}
|
|
66
|
+
{/each}
|
|
67
|
+
{/snippet}
|
|
68
|
+
</Structured>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** `CmsFormFields`: a form's inputs as content editors can change. */
|
|
2
|
+
export * from '../../core/shapes/form-fields.js';
|
|
3
|
+
/**
|
|
4
|
+
* Goes inside a `CmsForm` or `CmsWhatsAppForm`. Renders one input per
|
|
5
|
+
* field through the template's `field` snippet, or plain labelled inputs
|
|
6
|
+
* without one. Editors add, remove, reorder and retype fields from the
|
|
7
|
+
* pencil; each field's submitted `name` stays put (see the shape).
|
|
8
|
+
*/
|
|
9
|
+
import type { Snippet } from 'svelte';
|
|
10
|
+
import { type CmsFormFieldItem, type CmsFormFieldsValue } from '../../core/shapes/form-fields.js';
|
|
11
|
+
type Props = {
|
|
12
|
+
name: string;
|
|
13
|
+
scope?: string;
|
|
14
|
+
/** The full value, or a bare field list. */
|
|
15
|
+
fallback?: Partial<CmsFormFieldsValue> | Array<Partial<CmsFormFieldItem>>;
|
|
16
|
+
value?: unknown;
|
|
17
|
+
field?: Snippet<[CmsFormFieldItem]>;
|
|
18
|
+
};
|
|
19
|
+
declare const CmsFormFields: import("svelte").Component<Props, {}, "">;
|
|
20
|
+
type CmsFormFields = ReturnType<typeof CmsFormFields>;
|
|
21
|
+
export default CmsFormFields;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { FormStatus } from './use-form-target.svelte.js';
|
|
3
|
+
|
|
4
|
+
export type { FormStatus as CmsFormStatus };
|
|
5
|
+
|
|
6
|
+
/** What `children` gets: how the last submission went. */
|
|
7
|
+
export type CmsFormState = {
|
|
8
|
+
readonly status: FormStatus;
|
|
9
|
+
/** The endpoint's or action's message on `failure` / `error`. */
|
|
10
|
+
readonly message: string | null;
|
|
11
|
+
/** The action's returned data on `success` / `failure`. */
|
|
12
|
+
readonly data: Record<string, unknown> | null;
|
|
13
|
+
};
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<script lang="ts">
|
|
17
|
+
/**
|
|
18
|
+
* `CmsForm`: a form that posts to a hosted form by `key`, or to the
|
|
19
|
+
* template's own `action`. The inputs are the template's children.
|
|
20
|
+
*
|
|
21
|
+
* It is a real `<form method="POST">`, so it works without JavaScript;
|
|
22
|
+
* with it, `use:enhance` posts in place and `children` sees the status.
|
|
23
|
+
* The hosted endpoint answers enhanced posts the way a SvelteKit action
|
|
24
|
+
* does, so both destinations behave the same. Results are handled here
|
|
25
|
+
* rather than by the default callback, which would reload the page's data
|
|
26
|
+
* on success and send a network failure to the error page.
|
|
27
|
+
*/
|
|
28
|
+
import type { Snippet } from 'svelte';
|
|
29
|
+
import type { HTMLFormAttributes } from 'svelte/elements';
|
|
30
|
+
import { enhance, type SubmitFunction } from '$app/forms';
|
|
31
|
+
import { cmsStore } from './cms-store.svelte.js';
|
|
32
|
+
import { followRedirect, useFormTarget } from './use-form-target.svelte.js';
|
|
33
|
+
import FormControls from './cms-form-controls.svelte';
|
|
34
|
+
|
|
35
|
+
type Props = Omit<HTMLFormAttributes, 'action' | 'method' | 'children'> & {
|
|
36
|
+
/** A hosted form's key, as the template manifest declares it. */
|
|
37
|
+
key?: string;
|
|
38
|
+
/** Post here instead: a route, or a SvelteKit action like `?/contact`. */
|
|
39
|
+
action?: string;
|
|
40
|
+
/** Where to go after a successful submission. */
|
|
41
|
+
redirect?: string;
|
|
42
|
+
children: Snippet<[CmsFormState]>;
|
|
43
|
+
};
|
|
44
|
+
let { key, action, redirect, children, ...rest }: Props = $props();
|
|
45
|
+
|
|
46
|
+
const target = useFormTarget(() => ({ key, action }));
|
|
47
|
+
|
|
48
|
+
let status = $state<FormStatus>('idle');
|
|
49
|
+
let message = $state<string | null>(null);
|
|
50
|
+
let data = $state<Record<string, unknown> | null>(null);
|
|
51
|
+
const formState: CmsFormState = {
|
|
52
|
+
get status() {
|
|
53
|
+
return status;
|
|
54
|
+
},
|
|
55
|
+
get message() {
|
|
56
|
+
return message;
|
|
57
|
+
},
|
|
58
|
+
get data() {
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const messageOf = (value: unknown): string | null =>
|
|
64
|
+
value && typeof value === 'object' && 'message' in value && typeof value.message === 'string'
|
|
65
|
+
? value.message
|
|
66
|
+
: null;
|
|
67
|
+
|
|
68
|
+
const submit: SubmitFunction = ({ cancel }) => {
|
|
69
|
+
// Editors are changing the page, not filling it in.
|
|
70
|
+
if (cmsStore.isEditing) return cancel();
|
|
71
|
+
status = 'submitting';
|
|
72
|
+
message = null;
|
|
73
|
+
data = null;
|
|
74
|
+
return async ({ result, formElement, update }) => {
|
|
75
|
+
if (result.type === 'success') {
|
|
76
|
+
status = 'success';
|
|
77
|
+
data = result.data ?? null;
|
|
78
|
+
formElement.reset();
|
|
79
|
+
if (redirect) await followRedirect(redirect);
|
|
80
|
+
} else if (result.type === 'failure') {
|
|
81
|
+
status = 'failure';
|
|
82
|
+
data = result.data ?? null;
|
|
83
|
+
message = messageOf(result.data);
|
|
84
|
+
} else if (result.type === 'redirect') {
|
|
85
|
+
// A same-app action redirected; let SvelteKit follow it.
|
|
86
|
+
status = 'success';
|
|
87
|
+
await update();
|
|
88
|
+
} else {
|
|
89
|
+
status = 'error';
|
|
90
|
+
message = messageOf(result.error);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<form {...rest} method="POST" action={target.url} use:enhance={submit}>
|
|
97
|
+
<FormControls {redirect} token={target.token} />
|
|
98
|
+
{@render children(formState)}
|
|
99
|
+
</form>
|
|
@@ -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,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 } 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
|
+
const fieldNames = (form: HTMLFormElement): string[] => {
|
|
21
|
+
const names = new Set<string>();
|
|
22
|
+
for (const el of form.elements) {
|
|
23
|
+
const name = (el as HTMLInputElement).name;
|
|
24
|
+
const type = (el as HTMLInputElement).type;
|
|
25
|
+
if (name && !isControlField(name) && type !== 'submit' && type !== 'button') names.add(name);
|
|
26
|
+
}
|
|
27
|
+
return [...names];
|
|
28
|
+
};
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<script lang="ts">
|
|
32
|
+
/**
|
|
33
|
+
* Like `CmsForm`, but a submission opens `wa.me` with the editor's
|
|
34
|
+
* message (answers substituted for `{field}` placeholders) addressed to
|
|
35
|
+
* the editor's number. The answers are also posted in the background, so
|
|
36
|
+
* the lead is kept even if the visitor never sends the chat.
|
|
37
|
+
*
|
|
38
|
+
* The chat opens inside the submit handler, before the post settles: a
|
|
39
|
+
* popup opened after an `await` loses the click's user activation and is
|
|
40
|
+
* blocked. `keepalive` lets the post finish if the page goes away. If the
|
|
41
|
+
* popup is blocked anyway, this tab goes to WhatsApp instead.
|
|
42
|
+
*
|
|
43
|
+
* Without JavaScript the form posts normally, so the answers are still
|
|
44
|
+
* stored and `redirect` applies; `whatsappHref` is there for a
|
|
45
|
+
* `<noscript>` link.
|
|
46
|
+
*/
|
|
47
|
+
import { setContext, type Snippet } from 'svelte';
|
|
48
|
+
import type { HTMLFormAttributes } from 'svelte/elements';
|
|
49
|
+
import Structured from './cms-structured.svelte';
|
|
50
|
+
import FormControls from './cms-form-controls.svelte';
|
|
51
|
+
import { cmsStore } from './cms-store.svelte.js';
|
|
52
|
+
import { followRedirect, useFormTarget, type FormStatus } from './use-form-target.svelte.js';
|
|
53
|
+
import { cmsWhatsApp, type CmsWhatsAppValue } from '../../core/shapes/whatsapp.js';
|
|
54
|
+
import { fillMessage, whatsappHref } from '../../core/forms/whatsapp.js';
|
|
55
|
+
|
|
56
|
+
type Props = Omit<HTMLFormAttributes, 'action' | 'method' | 'children'> & {
|
|
57
|
+
/** The CMS field holding the number and message. */
|
|
58
|
+
name: string;
|
|
59
|
+
scope?: string;
|
|
60
|
+
fallback?: Partial<CmsWhatsAppValue>;
|
|
61
|
+
value?: unknown;
|
|
62
|
+
/** A hosted form's key, to record each submission to. */
|
|
63
|
+
key?: string;
|
|
64
|
+
/** Record to this route or SvelteKit action instead. */
|
|
65
|
+
action?: string;
|
|
66
|
+
/** Where this tab goes once the chat has opened in a new one. */
|
|
67
|
+
redirect?: string;
|
|
68
|
+
children: Snippet<[CmsWhatsAppFormState]>;
|
|
69
|
+
};
|
|
70
|
+
let {
|
|
71
|
+
name,
|
|
72
|
+
scope,
|
|
73
|
+
fallback,
|
|
74
|
+
value,
|
|
75
|
+
key,
|
|
76
|
+
action,
|
|
77
|
+
redirect,
|
|
78
|
+
children: content,
|
|
79
|
+
...rest
|
|
80
|
+
}: Props = $props();
|
|
81
|
+
|
|
82
|
+
const target = useFormTarget(() => ({ key, action }));
|
|
83
|
+
|
|
84
|
+
let formEl = $state<HTMLFormElement>();
|
|
85
|
+
setContext(FIELD_NAMES, () => (formEl ? fieldNames(formEl) : []));
|
|
86
|
+
|
|
87
|
+
let status = $state<FormStatus>('idle');
|
|
88
|
+
let message = $state<string | null>(null);
|
|
89
|
+
|
|
90
|
+
const stateFor = (settings: CmsWhatsAppValue): CmsWhatsAppFormState => ({
|
|
91
|
+
get status() {
|
|
92
|
+
return status;
|
|
93
|
+
},
|
|
94
|
+
get message() {
|
|
95
|
+
return message;
|
|
96
|
+
},
|
|
97
|
+
data: null,
|
|
98
|
+
whatsappHref: whatsappHref(settings.number)
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const record = (body: FormData) => {
|
|
102
|
+
if (!target.url) {
|
|
103
|
+
status = 'success';
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
body.delete('_redirect');
|
|
107
|
+
status = 'submitting';
|
|
108
|
+
fetch(target.url, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
body,
|
|
111
|
+
keepalive: true,
|
|
112
|
+
headers: { accept: 'application/json' }
|
|
113
|
+
})
|
|
114
|
+
.then(async (res) => {
|
|
115
|
+
status = res.ok ? 'success' : 'failure';
|
|
116
|
+
if (!res.ok) message = ((await res.json().catch(() => null))?.message as string) ?? null;
|
|
117
|
+
})
|
|
118
|
+
.catch(() => {
|
|
119
|
+
status = 'error';
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const submit = (event: SubmitEvent, settings: CmsWhatsAppValue) => {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
if (cmsStore.isEditing) return;
|
|
126
|
+
const form = event.currentTarget as HTMLFormElement;
|
|
127
|
+
const body = new FormData(form, event.submitter);
|
|
128
|
+
const href = whatsappHref(settings.number, fillMessage(settings.message, body));
|
|
129
|
+
message = null;
|
|
130
|
+
record(body);
|
|
131
|
+
if (!href) {
|
|
132
|
+
status = 'error';
|
|
133
|
+
message = 'No WhatsApp number is set.';
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const chat = window.open(href, '_blank');
|
|
137
|
+
if (!chat) {
|
|
138
|
+
location.assign(href);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
chat.opener = null;
|
|
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,43 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Like `CmsForm`, but a submission opens `wa.me` with the editor's
|
|
11
|
+
* message (answers substituted for `{field}` placeholders) addressed to
|
|
12
|
+
* the editor's number. The answers are also posted in the background, so
|
|
13
|
+
* the lead is kept even if the visitor never sends the chat.
|
|
14
|
+
*
|
|
15
|
+
* The chat opens inside the submit handler, before the post settles: a
|
|
16
|
+
* popup opened after an `await` loses the click's user activation and is
|
|
17
|
+
* blocked. `keepalive` lets the post finish if the page goes away. If the
|
|
18
|
+
* popup is blocked anyway, this tab goes to WhatsApp instead.
|
|
19
|
+
*
|
|
20
|
+
* Without JavaScript the form posts normally, so the answers are still
|
|
21
|
+
* stored and `redirect` applies; `whatsappHref` is there for a
|
|
22
|
+
* `<noscript>` link.
|
|
23
|
+
*/
|
|
24
|
+
import { type Snippet } from 'svelte';
|
|
25
|
+
import type { HTMLFormAttributes } from 'svelte/elements';
|
|
26
|
+
import { type CmsWhatsAppValue } from '../../core/shapes/whatsapp.js';
|
|
27
|
+
type Props = Omit<HTMLFormAttributes, 'action' | 'method' | 'children'> & {
|
|
28
|
+
/** The CMS field holding the number and message. */
|
|
29
|
+
name: string;
|
|
30
|
+
scope?: string;
|
|
31
|
+
fallback?: Partial<CmsWhatsAppValue>;
|
|
32
|
+
value?: unknown;
|
|
33
|
+
/** A hosted form's key, to record each submission to. */
|
|
34
|
+
key?: string;
|
|
35
|
+
/** Record to this route or SvelteKit action instead. */
|
|
36
|
+
action?: string;
|
|
37
|
+
/** Where this tab goes once the chat has opened in a new one. */
|
|
38
|
+
redirect?: string;
|
|
39
|
+
children: Snippet<[CmsWhatsAppFormState]>;
|
|
40
|
+
};
|
|
41
|
+
declare const CmsWhatsappForm: import("svelte").Component<Props, {}, "">;
|
|
42
|
+
type CmsWhatsappForm = ReturnType<typeof CmsWhatsappForm>;
|
|
43
|
+
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
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a form posts when the template names a hosted form by `key`.
|
|
3
|
+
*
|
|
4
|
+
* velastack.dev serves a project's CMS at `/v1/projects/<id>/cms` and its
|
|
5
|
+
* hosted forms beside it at `/v1/projects/<id>/forms/<key>`, the forms a
|
|
6
|
+
* template's manifest declares. Any other endpoint shape (a same-app
|
|
7
|
+
* `/api/cms`, this package's own `/cms` backend) has no hosted forms, so
|
|
8
|
+
* there is nothing to derive and the template passes `action` instead.
|
|
9
|
+
*/
|
|
10
|
+
const HOSTED_CMS = /^(.*\/v1\/projects\/[^/?#]+)\/cms\/?$/;
|
|
11
|
+
export const hostedFormUrl = (endpoint, key) => {
|
|
12
|
+
const match = HOSTED_CMS.exec(endpoint);
|
|
13
|
+
return match ? `${match[1]}/forms/${encodeURIComponent(key)}` : null;
|
|
14
|
+
};
|
|
15
|
+
/** Fields a form carries for the endpoint rather than the submission. */
|
|
16
|
+
export const isControlField = (name) => name.startsWith('_');
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Building the `wa.me` link a `CmsWhatsAppForm` opens: the editor's format
|
|
3
|
+
* string with the visitor's answers substituted, sent to the editor's number.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Substitute `{field}` placeholders with the submitted values. A field sent
|
|
7
|
+
* more than once (checkboxes, multi-selects) joins with commas; a placeholder
|
|
8
|
+
* with no matching field, and file uploads, become empty.
|
|
9
|
+
*/
|
|
10
|
+
export declare const fillMessage: (template: string, data: FormData) => string;
|
|
11
|
+
/**
|
|
12
|
+
* The digits `wa.me` wants: country code and number, no `+`, spaces or
|
|
13
|
+
* dashes. A leading `00` international prefix is dropped, since no country
|
|
14
|
+
* code starts with 0.
|
|
15
|
+
*/
|
|
16
|
+
export declare const whatsappNumber: (raw: string) => string;
|
|
17
|
+
/** `https://wa.me/<number>?text=<message>`, or `null` when no number is set. */
|
|
18
|
+
export declare const whatsappHref: (number: string, message?: string) => string | null;
|