@velastack/cms 0.6.4 → 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 +85 -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-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/package.json +1 -1
|
@@ -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;
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
const PLACEHOLDER = /\{([^{}\s]+)\}/g;
|
|
6
|
+
/**
|
|
7
|
+
* Substitute `{field}` placeholders with the submitted values. A field sent
|
|
8
|
+
* more than once (checkboxes, multi-selects) joins with commas; a placeholder
|
|
9
|
+
* with no matching field, and file uploads, become empty.
|
|
10
|
+
*/
|
|
11
|
+
export const fillMessage = (template, data) => template.replace(PLACEHOLDER, (_, name) => data
|
|
12
|
+
.getAll(name)
|
|
13
|
+
.filter((value) => typeof value === 'string' && value !== '')
|
|
14
|
+
.join(', '));
|
|
15
|
+
/**
|
|
16
|
+
* The digits `wa.me` wants: country code and number, no `+`, spaces or
|
|
17
|
+
* dashes. A leading `00` international prefix is dropped, since no country
|
|
18
|
+
* code starts with 0.
|
|
19
|
+
*/
|
|
20
|
+
export const whatsappNumber = (raw) => raw.replace(/\D/g, '').replace(/^00/, '');
|
|
21
|
+
/** `https://wa.me/<number>?text=<message>`, or `null` when no number is set. */
|
|
22
|
+
export const whatsappHref = (number, message = '') => {
|
|
23
|
+
const digits = whatsappNumber(number);
|
|
24
|
+
if (!digits)
|
|
25
|
+
return null;
|
|
26
|
+
return message
|
|
27
|
+
? `https://wa.me/${digits}?text=${encodeURIComponent(message)}`
|
|
28
|
+
: `https://wa.me/${digits}`;
|
|
29
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ListValue } from './form.js';
|
|
2
|
+
export declare const FORM_FIELD_TYPES: readonly ["text", "email", "tel", "number", "date", "textarea", "select", "checkbox"];
|
|
3
|
+
export type CmsFormFieldType = (typeof FORM_FIELD_TYPES)[number];
|
|
4
|
+
export type CmsFormFieldItem = {
|
|
5
|
+
id: string;
|
|
6
|
+
/** The submitted field name. */
|
|
7
|
+
name: string;
|
|
8
|
+
label: string;
|
|
9
|
+
type: CmsFormFieldType;
|
|
10
|
+
required: boolean;
|
|
11
|
+
placeholder: string;
|
|
12
|
+
/** `select`: the choices, in order. */
|
|
13
|
+
options: string[];
|
|
14
|
+
};
|
|
15
|
+
export type CmsFormFieldsValue = ListValue<CmsFormFieldItem>;
|
|
16
|
+
/** `Preferred date` → `preferred_date`; a label with no latin letters → `field`. */
|
|
17
|
+
export declare const fieldNameFromLabel: (label: string) => string;
|
|
18
|
+
export declare const cmsFormFields: import("./form.js").FormShape<CmsFormFieldsValue>;
|
|
19
|
+
/** What a template renders: every field, in order. */
|
|
20
|
+
export declare const formFieldsView: (value: CmsFormFieldsValue) => CmsFormFieldItem[];
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CmsFormFields`: the inputs of a form, as content an editor can add to,
|
|
3
|
+
* remove from, reorder and retype.
|
|
4
|
+
*
|
|
5
|
+
* A field's `name` is the key its answer is stored under, so it must not
|
|
6
|
+
* follow later label edits or a form's submissions would split across keys.
|
|
7
|
+
* Fields from the template's fallback carry their own names. A field an
|
|
8
|
+
* editor adds has none until its label is first saved; reads derive one from
|
|
9
|
+
* the label, and the next save through the popover writes it down, after
|
|
10
|
+
* which renaming the label leaves it alone. The editor never shows `name`.
|
|
11
|
+
*/
|
|
12
|
+
import { asBoolean, asEnum, asItems, asString, asStrings, isPlainObject } from '../structured.js';
|
|
13
|
+
import { defineForm } from './form.js';
|
|
14
|
+
export const FORM_FIELD_TYPES = [
|
|
15
|
+
'text',
|
|
16
|
+
'email',
|
|
17
|
+
'tel',
|
|
18
|
+
'number',
|
|
19
|
+
'date',
|
|
20
|
+
'textarea',
|
|
21
|
+
'select',
|
|
22
|
+
'checkbox'
|
|
23
|
+
];
|
|
24
|
+
/** `Preferred date` → `preferred_date`; a label with no latin letters → `field`. */
|
|
25
|
+
export const fieldNameFromLabel = (label) => label
|
|
26
|
+
.normalize('NFKD')
|
|
27
|
+
.replace(/[̀-ͯ]/g, '')
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
30
|
+
.replace(/^_+|_+$/g, '') || 'field';
|
|
31
|
+
/**
|
|
32
|
+
* Give every field a distinct name. Stored names are kept and claimed
|
|
33
|
+
* first, so a newly added field can never take one an existing field's
|
|
34
|
+
* submissions already use; derived names get `_2`, `_3`, … on a clash.
|
|
35
|
+
*/
|
|
36
|
+
const assignNames = (items) => {
|
|
37
|
+
const taken = new Set();
|
|
38
|
+
const named = items.map((item) => {
|
|
39
|
+
if (!item.stored || taken.has(item.stored))
|
|
40
|
+
return { ...item, name: '' };
|
|
41
|
+
taken.add(item.stored);
|
|
42
|
+
return { ...item, name: item.stored };
|
|
43
|
+
});
|
|
44
|
+
return named.map(({ stored: _stored, ...item }) => {
|
|
45
|
+
if (item.name)
|
|
46
|
+
return item;
|
|
47
|
+
const base = fieldNameFromLabel(item.label);
|
|
48
|
+
let name = base;
|
|
49
|
+
for (let n = 2; taken.has(name); n++)
|
|
50
|
+
name = `${base}_${n}`;
|
|
51
|
+
taken.add(name);
|
|
52
|
+
return { ...item, name };
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
export const cmsFormFields = defineForm({
|
|
56
|
+
component: 'CmsFormFields',
|
|
57
|
+
label: 'Form fields',
|
|
58
|
+
version: 1,
|
|
59
|
+
translatable: [],
|
|
60
|
+
items: { key: 'items', translatable: ['label', 'placeholder', 'options'] },
|
|
61
|
+
normalize: (raw) => {
|
|
62
|
+
const items = Array.isArray(raw) ? raw : isPlainObject(raw) ? raw.items : [];
|
|
63
|
+
return {
|
|
64
|
+
v: 1,
|
|
65
|
+
items: assignNames(asItems(items, (r) => ({
|
|
66
|
+
stored: asString(r.name).trim(),
|
|
67
|
+
name: '',
|
|
68
|
+
label: asString(r.label),
|
|
69
|
+
type: asEnum(r.type, FORM_FIELD_TYPES, 'text'),
|
|
70
|
+
required: asBoolean(r.required),
|
|
71
|
+
placeholder: asString(r.placeholder),
|
|
72
|
+
options: asStrings(r.options)
|
|
73
|
+
})))
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
empty: () => ({ v: 1, items: [] }),
|
|
77
|
+
fields: [
|
|
78
|
+
{
|
|
79
|
+
key: 'items',
|
|
80
|
+
label: 'Fields',
|
|
81
|
+
type: 'list',
|
|
82
|
+
itemLabel: 'field',
|
|
83
|
+
titleKey: 'label',
|
|
84
|
+
blank: () => ({ label: '', type: 'text', required: false, placeholder: '', options: [] }),
|
|
85
|
+
fields: [
|
|
86
|
+
{ key: 'label', label: 'Label', type: 'text' },
|
|
87
|
+
{
|
|
88
|
+
key: 'type',
|
|
89
|
+
label: 'Type',
|
|
90
|
+
type: 'enum',
|
|
91
|
+
values: FORM_FIELD_TYPES,
|
|
92
|
+
names: {
|
|
93
|
+
text: 'Short text',
|
|
94
|
+
email: 'Email',
|
|
95
|
+
tel: 'Phone',
|
|
96
|
+
number: 'Number',
|
|
97
|
+
date: 'Date',
|
|
98
|
+
textarea: 'Long text',
|
|
99
|
+
select: 'Dropdown',
|
|
100
|
+
checkbox: 'Checkbox'
|
|
101
|
+
},
|
|
102
|
+
half: true
|
|
103
|
+
},
|
|
104
|
+
{ key: 'required', label: 'Required', type: 'boolean' },
|
|
105
|
+
{ key: 'placeholder', label: 'Placeholder', type: 'text' },
|
|
106
|
+
{
|
|
107
|
+
key: 'options',
|
|
108
|
+
label: 'Dropdown choices',
|
|
109
|
+
type: 'strings',
|
|
110
|
+
placeholder: 'One per line'
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
});
|
|
116
|
+
/** What a template renders: every field, in order. */
|
|
117
|
+
export const formFieldsView = (value) => value.items;
|
|
@@ -27,5 +27,9 @@ export { cmsPricing, pricingView, formatPrice } from './pricing.js';
|
|
|
27
27
|
export type { CmsPricingValue, PricingFeature, PricingTier, PricingTierView } from './pricing.js';
|
|
28
28
|
export { cmsFaq, faqView, toFaqPage } from './faq.js';
|
|
29
29
|
export type { CmsFaqValue, FaqItem } from './faq.js';
|
|
30
|
+
export { cmsFormFields, formFieldsView, fieldNameFromLabel, FORM_FIELD_TYPES } from './form-fields.js';
|
|
31
|
+
export type { CmsFormFieldsValue, CmsFormFieldItem, CmsFormFieldType } from './form-fields.js';
|
|
32
|
+
export { cmsWhatsApp } from './whatsapp.js';
|
|
33
|
+
export type { CmsWhatsAppValue } from './whatsapp.js';
|
|
30
34
|
export { cmsStats, cmsSteps, cmsTimeline, cmsGallery, cmsLogos, cmsSchedule, logosView, scheduleByDay } from './presets.js';
|
|
31
35
|
export type { CmsStatsValue, StatItem, CmsStepsValue, StepItem, CmsTimelineValue, TimelineItem, CmsGalleryValue, GalleryItem, CmsLogosValue, LogoItem, LogoItemView, CmsScheduleValue, ScheduleItem } from './presets.js';
|
|
@@ -10,4 +10,6 @@ export { cmsTestimonials, testimonialsView, toReview } from './testimonials.js';
|
|
|
10
10
|
export { cmsCollection, collectionView, collectionTags, formatCollectionPrice, toItemList } from './collection.js';
|
|
11
11
|
export { cmsPricing, pricingView, formatPrice } from './pricing.js';
|
|
12
12
|
export { cmsFaq, faqView, toFaqPage } from './faq.js';
|
|
13
|
+
export { cmsFormFields, formFieldsView, fieldNameFromLabel, FORM_FIELD_TYPES } from './form-fields.js';
|
|
14
|
+
export { cmsWhatsApp } from './whatsapp.js';
|
|
13
15
|
export { cmsStats, cmsSteps, cmsTimeline, cmsGallery, cmsLogos, cmsSchedule, logosView, scheduleByDay } from './presets.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type CmsWhatsAppValue = {
|
|
2
|
+
v: 1;
|
|
3
|
+
/** International format; anything but digits is dropped when linking. */
|
|
4
|
+
number: string;
|
|
5
|
+
/** The message, with `{field}` placeholders for the visitor's answers. */
|
|
6
|
+
message: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const cmsWhatsApp: import("./form.js").FormShape<CmsWhatsAppValue>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** `CmsWhatsAppForm`: the number a form's answers go to, and how they read. */
|
|
2
|
+
import { asString, isPlainObject } from '../structured.js';
|
|
3
|
+
import { defineForm } from './form.js';
|
|
4
|
+
export const cmsWhatsApp = defineForm({
|
|
5
|
+
component: 'CmsWhatsAppForm',
|
|
6
|
+
label: 'WhatsApp',
|
|
7
|
+
version: 1,
|
|
8
|
+
translatable: ['message'],
|
|
9
|
+
normalize: (raw) => {
|
|
10
|
+
const r = isPlainObject(raw) ? raw : {};
|
|
11
|
+
return { v: 1, number: asString(r.number), message: asString(r.message) };
|
|
12
|
+
},
|
|
13
|
+
empty: () => ({ v: 1, number: '', message: '' }),
|
|
14
|
+
fields: [
|
|
15
|
+
{
|
|
16
|
+
key: 'number',
|
|
17
|
+
label: 'WhatsApp number',
|
|
18
|
+
type: 'text',
|
|
19
|
+
placeholder: '+1 555 010 0199'
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
key: 'message',
|
|
23
|
+
label: 'Message',
|
|
24
|
+
type: 'long-string',
|
|
25
|
+
placeholder: "Hi, I'm {name}…"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -31,6 +31,11 @@ export { default as CmsTimeline } from './components/cms/cms-timeline.svelte';
|
|
|
31
31
|
export { default as CmsGallery } from './components/cms/cms-gallery.svelte';
|
|
32
32
|
export { default as CmsLogos } from './components/cms/cms-logos.svelte';
|
|
33
33
|
export { default as CmsSchedule } from './components/cms/cms-schedule.svelte';
|
|
34
|
+
export { default as CmsForm } from './components/cms/cms-form.svelte';
|
|
35
|
+
export type { CmsFormState, CmsFormStatus } from './components/cms/cms-form.svelte';
|
|
36
|
+
export { default as CmsFormFields } from './components/cms/cms-form-fields.svelte';
|
|
37
|
+
export { default as CmsWhatsAppForm } from './components/cms/cms-whatsapp-form.svelte';
|
|
38
|
+
export type { CmsWhatsAppFormState } from './components/cms/cms-whatsapp-form.svelte';
|
|
34
39
|
export * from './core/shapes/index.js';
|
|
35
40
|
export { default as AdminBar } from './components/admin-bar/admin-bar.svelte';
|
|
36
41
|
export { useCmsField, resolveScopeRef, ROOT_SCOPE_ID } from './components/cms/use-cms-field.svelte.js';
|