@velastack/cms 0.6.5 → 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 +40 -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.svelte +10 -10
- package/dist/components/cms/cms-whatsapp-form.svelte.d.ts +3 -1
- 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 +7 -1
- package/dist/core/forms/target.js +5 -12
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/package.json +2 -1
- package/dist/core/forms/whatsapp.d.ts +0 -18
- package/dist/core/forms/whatsapp.js +0 -29
package/README.md
CHANGED
|
@@ -465,6 +465,46 @@ The same form, but a submission opens a WhatsApp chat to the editor's number wit
|
|
|
465
465
|
|
|
466
466
|
The chat opens in a new tab (this one, if the popup is blocked), and `redirect` then moves this tab on. Without JavaScript the form posts normally, so the answers are still stored; `form.whatsappHref` links to the number alone for a `<noscript>` fallback. `CmsFormFields` works inside it as in `CmsForm`.
|
|
467
467
|
|
|
468
|
+
### `<CmsWhatsAppCart>` and `whatsappCart`
|
|
469
|
+
|
|
470
|
+
A cart whose checkout is a WhatsApp chat. Visitors add items with `whatsappCart.add(...)`, kept in the browser. The checkout form asks for `name`, `phone` and an optional `note`, then opens a chat to the editor's number with the order as the message. It also records the order (reference, customer, lines, subtotal and message) to the hosted form `key`, so the order is kept even if the chat is never sent. Nothing is paid on the site: the business confirms and takes payment in the chat.
|
|
471
|
+
|
|
472
|
+
```svelte
|
|
473
|
+
<script lang="ts">
|
|
474
|
+
import { CmsWhatsAppCart, whatsappCart } from '@velastack/cms';
|
|
475
|
+
</script>
|
|
476
|
+
|
|
477
|
+
<button onclick={() => whatsappCart.add({ id: 'concha', name: 'Concha', price: 12 })}>
|
|
478
|
+
Add to order
|
|
479
|
+
</button>
|
|
480
|
+
|
|
481
|
+
<CmsWhatsAppCart key="order" name="whatsapp" scope="root">
|
|
482
|
+
{#snippet children({ cart, placed, formatPrice })}
|
|
483
|
+
{#if placed}
|
|
484
|
+
<p>Order {placed.ref} opened in WhatsApp.</p>
|
|
485
|
+
<a href={placed.href} target="_blank" rel="noopener">Open WhatsApp again</a>
|
|
486
|
+
<button type="button" onclick={() => cart.clear()}>Done</button>
|
|
487
|
+
{:else}
|
|
488
|
+
{#each cart.items as item (item.id)}
|
|
489
|
+
<p>{item.quantity} × {item.name}</p>
|
|
490
|
+
{/each}
|
|
491
|
+
{#if cart.subtotal !== undefined}<p>{formatPrice(cart.subtotal)}</p>{/if}
|
|
492
|
+
<label>Name <input name="name" required /></label>
|
|
493
|
+
<label>Phone <input name="phone" type="tel" required /></label>
|
|
494
|
+
<label>Note <textarea name="note"></textarea></label>
|
|
495
|
+
<button disabled={cart.count === 0}>Order on WhatsApp</button>
|
|
496
|
+
{/if}
|
|
497
|
+
{/snippet}
|
|
498
|
+
</CmsWhatsAppCart>
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
- **One currency, the business's own.** Every price is in it, and the order and message are too. It comes from the `currency` site option (declare it in `createCms({ site })`), or the `currency` prop. `formatPrice` formats an amount in the page's language, so an English page shows MXN as `MX$5.50` and a Spanish one as `$5.50`.
|
|
502
|
+
- **Prices are optional**, and written as an editor types them (`4.5`). `cart.subtotal` exists only when every item has a price; otherwise the business quotes the total in the chat.
|
|
503
|
+
- **The message is the editor's**, set from the pencil like `CmsWhatsAppForm`'s, with `{ref}`, `{items}`, `{total}`, `{name}`, `{phone}` and `{note}`. Until one is set, a default lists the items, the total and the customer. Lines an empty placeholder leaves blank are dropped.
|
|
504
|
+
- **After checkout**, `placed` holds the order's reference and chat link until `cart.clear()`. Adding an item starts the next order.
|
|
505
|
+
- **The cart lives in the browser**, so the server renders it empty. `CmsWhatsAppCart` loads it on mount; a count shown elsewhere can call `whatsappCart.load()` in its own `onMount`.
|
|
506
|
+
- Declare the `order` form in `template.json` `forms`, as for `contact`. The links, references and recording come from [`@velastack/whatsapp`](https://github.com/velastack/whatsapp).
|
|
507
|
+
|
|
468
508
|
## Custom CMS components
|
|
469
509
|
|
|
470
510
|
VelaStack CMS discovers your custom or third-party CMS components by convention.
|
|
@@ -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;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
2
|
/** `CmsWhatsAppForm`: a form whose answers open a prefilled WhatsApp chat. */
|
|
3
|
-
import { getContext } from 'svelte';
|
|
3
|
+
import { getContext, setContext } from 'svelte';
|
|
4
4
|
import { isControlField } from '../../core/forms/target.js';
|
|
5
5
|
import type { CmsFormState } from './cms-form.svelte';
|
|
6
6
|
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
export const getWhatsAppFieldNames = (): (() => string[]) =>
|
|
18
18
|
getContext<() => string[]>(FIELD_NAMES) ?? (() => []);
|
|
19
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
|
+
|
|
20
25
|
const fieldNames = (form: HTMLFormElement): string[] => {
|
|
21
26
|
const names = new Set<string>();
|
|
22
27
|
for (const el of form.elements) {
|
|
@@ -44,14 +49,14 @@
|
|
|
44
49
|
* stored and `redirect` applies; `whatsappHref` is there for a
|
|
45
50
|
* `<noscript>` link.
|
|
46
51
|
*/
|
|
47
|
-
import {
|
|
52
|
+
import type { Snippet } from 'svelte';
|
|
48
53
|
import type { HTMLFormAttributes } from 'svelte/elements';
|
|
49
54
|
import Structured from './cms-structured.svelte';
|
|
50
55
|
import FormControls from './cms-form-controls.svelte';
|
|
51
56
|
import { cmsStore } from './cms-store.svelte.js';
|
|
52
57
|
import { followRedirect, useFormTarget, type FormStatus } from './use-form-target.svelte.js';
|
|
53
58
|
import { cmsWhatsApp, type CmsWhatsAppValue } from '../../core/shapes/whatsapp.js';
|
|
54
|
-
import { fillMessage, whatsappHref } from '
|
|
59
|
+
import { fillMessage, openChat, whatsappHref } from '@velastack/whatsapp';
|
|
55
60
|
|
|
56
61
|
type Props = Omit<HTMLFormAttributes, 'action' | 'method' | 'children'> & {
|
|
57
62
|
/** The CMS field holding the number and message. */
|
|
@@ -82,7 +87,7 @@
|
|
|
82
87
|
const target = useFormTarget(() => ({ key, action }));
|
|
83
88
|
|
|
84
89
|
let formEl = $state<HTMLFormElement>();
|
|
85
|
-
|
|
90
|
+
setWhatsAppFieldNames(() => (formEl ? fieldNames(formEl) : []));
|
|
86
91
|
|
|
87
92
|
let status = $state<FormStatus>('idle');
|
|
88
93
|
let message = $state<string | null>(null);
|
|
@@ -133,12 +138,7 @@
|
|
|
133
138
|
message = 'No WhatsApp number is set.';
|
|
134
139
|
return;
|
|
135
140
|
}
|
|
136
|
-
|
|
137
|
-
if (!chat) {
|
|
138
|
-
location.assign(href);
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
chat.opener = null;
|
|
141
|
+
if (!openChat(href)) return;
|
|
142
142
|
if (redirect) void followRedirect(redirect);
|
|
143
143
|
};
|
|
144
144
|
</script>
|
|
@@ -6,6 +6,8 @@ export type CmsWhatsAppFormState = CmsFormState & {
|
|
|
6
6
|
};
|
|
7
7
|
/** For the settings editor: the `{placeholders}` this form's inputs provide. */
|
|
8
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;
|
|
9
11
|
/**
|
|
10
12
|
* Like `CmsForm`, but a submission opens `wa.me` with the editor's
|
|
11
13
|
* message (answers substituted for `{field}` placeholders) addressed to
|
|
@@ -21,7 +23,7 @@ export declare const getWhatsAppFieldNames: () => (() => string[]);
|
|
|
21
23
|
* stored and `redirect` applies; `whatsappHref` is there for a
|
|
22
24
|
* `<noscript>` link.
|
|
23
25
|
*/
|
|
24
|
-
import {
|
|
26
|
+
import type { Snippet } from 'svelte';
|
|
25
27
|
import type { HTMLFormAttributes } from 'svelte/elements';
|
|
26
28
|
import { type CmsWhatsAppValue } from '../../core/shapes/whatsapp.js';
|
|
27
29
|
type Props = Omit<HTMLFormAttributes, 'action' | 'method' | 'children'> & {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cart a `CmsWhatsAppCart` checks out: items kept in the browser, which
|
|
3
|
+
* checkout turns into a WhatsApp message to the business. Nothing is paid on
|
|
4
|
+
* the site, so what is stored is what the visitor asked for, not a price.
|
|
5
|
+
*
|
|
6
|
+
* Module state, filled from localStorage by `load()` in the browser only, so
|
|
7
|
+
* the server renders an empty cart and hydration agrees. Its methods run from
|
|
8
|
+
* event handlers, never during a server render.
|
|
9
|
+
*/
|
|
10
|
+
import { type OrderLine } from '@velastack/whatsapp';
|
|
11
|
+
/** A line in the cart; its `price` is in the site's currency. */
|
|
12
|
+
export type WhatsAppCartItem = OrderLine & {
|
|
13
|
+
/** The template's id for the item (a slug); lines with the same id merge. */
|
|
14
|
+
id: string;
|
|
15
|
+
};
|
|
16
|
+
/** An order sent to WhatsApp, kept until the visitor says they're done. */
|
|
17
|
+
export type WhatsAppPlacedOrder = {
|
|
18
|
+
ref: string;
|
|
19
|
+
/** The chat link, to open it again. */
|
|
20
|
+
href: string;
|
|
21
|
+
};
|
|
22
|
+
declare class WhatsAppCart {
|
|
23
|
+
#private;
|
|
24
|
+
items: WhatsAppCartItem[];
|
|
25
|
+
/** For the template's drawer or sheet, if it has one. */
|
|
26
|
+
open: boolean;
|
|
27
|
+
placed: WhatsAppPlacedOrder | null;
|
|
28
|
+
count: number;
|
|
29
|
+
/**
|
|
30
|
+
* The currency every price is in: the business's own. `CmsWhatsAppCart`
|
|
31
|
+
* sets it from its `currency` prop or the `currency` site option.
|
|
32
|
+
*/
|
|
33
|
+
currency: string;
|
|
34
|
+
/** Only when every item has a price; otherwise the business quotes. */
|
|
35
|
+
subtotal: number | undefined;
|
|
36
|
+
/** Reads the saved cart, once, in the browser. Safe to call from any component's mount. */
|
|
37
|
+
load(): void;
|
|
38
|
+
add(item: Omit<WhatsAppCartItem, 'quantity'>, quantity?: number): void;
|
|
39
|
+
/** A quantity of 0 removes the item. */
|
|
40
|
+
setQuantity(id: string, quantity: number): void;
|
|
41
|
+
remove(id: string): void;
|
|
42
|
+
place(order: WhatsAppPlacedOrder): void;
|
|
43
|
+
clear(): void;
|
|
44
|
+
}
|
|
45
|
+
export declare const whatsappCart: WhatsAppCart;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cart a `CmsWhatsAppCart` checks out: items kept in the browser, which
|
|
3
|
+
* checkout turns into a WhatsApp message to the business. Nothing is paid on
|
|
4
|
+
* the site, so what is stored is what the visitor asked for, not a price.
|
|
5
|
+
*
|
|
6
|
+
* Module state, filled from localStorage by `load()` in the browser only, so
|
|
7
|
+
* the server renders an empty cart and hydration agrees. Its methods run from
|
|
8
|
+
* event handlers, never during a server render.
|
|
9
|
+
*/
|
|
10
|
+
import { orderSubtotal } from '@velastack/whatsapp';
|
|
11
|
+
const STORAGE_KEY = 'vela-cms:whatsapp-cart';
|
|
12
|
+
class WhatsAppCart {
|
|
13
|
+
items = $state([]);
|
|
14
|
+
/** For the template's drawer or sheet, if it has one. */
|
|
15
|
+
open = $state(false);
|
|
16
|
+
placed = $state(null);
|
|
17
|
+
count = $derived(this.items.reduce((total, item) => total + item.quantity, 0));
|
|
18
|
+
/**
|
|
19
|
+
* The currency every price is in: the business's own. `CmsWhatsAppCart`
|
|
20
|
+
* sets it from its `currency` prop or the `currency` site option.
|
|
21
|
+
*/
|
|
22
|
+
currency = $state('');
|
|
23
|
+
/** Only when every item has a price; otherwise the business quotes. */
|
|
24
|
+
subtotal = $derived(this.currency ? orderSubtotal(this.items, this.currency) : undefined);
|
|
25
|
+
#loaded = false;
|
|
26
|
+
/** Reads the saved cart, once, in the browser. Safe to call from any component's mount. */
|
|
27
|
+
load() {
|
|
28
|
+
if (typeof localStorage === 'undefined' || this.#loaded)
|
|
29
|
+
return;
|
|
30
|
+
this.#loaded = true;
|
|
31
|
+
try {
|
|
32
|
+
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}');
|
|
33
|
+
if (Array.isArray(saved?.items))
|
|
34
|
+
this.items = saved.items;
|
|
35
|
+
if (saved?.placed)
|
|
36
|
+
this.placed = saved.placed;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Unreadable or blocked storage starts an empty cart.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
add(item, quantity = 1) {
|
|
43
|
+
this.load();
|
|
44
|
+
// Adding after an order was sent starts the next one.
|
|
45
|
+
if (this.placed)
|
|
46
|
+
this.clear();
|
|
47
|
+
const line = this.items.find((line) => line.id === item.id);
|
|
48
|
+
if (line)
|
|
49
|
+
line.quantity += quantity;
|
|
50
|
+
else
|
|
51
|
+
this.items.push({ ...item, quantity });
|
|
52
|
+
this.#save();
|
|
53
|
+
}
|
|
54
|
+
/** A quantity of 0 removes the item. */
|
|
55
|
+
setQuantity(id, quantity) {
|
|
56
|
+
if (quantity <= 0)
|
|
57
|
+
return this.remove(id);
|
|
58
|
+
const line = this.items.find((line) => line.id === id);
|
|
59
|
+
if (!line)
|
|
60
|
+
return;
|
|
61
|
+
line.quantity = quantity;
|
|
62
|
+
this.#save();
|
|
63
|
+
}
|
|
64
|
+
remove(id) {
|
|
65
|
+
this.items = this.items.filter((line) => line.id !== id);
|
|
66
|
+
this.#save();
|
|
67
|
+
}
|
|
68
|
+
place(order) {
|
|
69
|
+
this.placed = order;
|
|
70
|
+
this.#save();
|
|
71
|
+
}
|
|
72
|
+
clear() {
|
|
73
|
+
this.items = [];
|
|
74
|
+
this.placed = null;
|
|
75
|
+
this.#save();
|
|
76
|
+
}
|
|
77
|
+
#save() {
|
|
78
|
+
try {
|
|
79
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({ items: $state.snapshot(this.items), placed: this.placed }));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Storage can be full or blocked; the cart still works for this visit.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export const whatsappCart = new WhatsAppCart();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The message a `CmsWhatsAppCart` sends: the editor's template, filled from
|
|
3
|
+
* the order by `@velastack/whatsapp`'s `orderPlaceholders`.
|
|
4
|
+
*/
|
|
5
|
+
/** The `{placeholders}` an order message can use. */
|
|
6
|
+
export declare const ORDER_PLACEHOLDERS: string[];
|
|
7
|
+
/** Until the editor writes one. Lines left empty (no total, no note) are dropped. */
|
|
8
|
+
export declare const DEFAULT_ORDER_MESSAGE = "Hi! I'd like to place an order ({ref}):\n\n{items}\n\n{total}\n\n{name} \u00B7 {phone}\n{note}";
|
|
9
|
+
/** Drops the blank lines and dangling spaces that empty placeholders leave behind. */
|
|
10
|
+
export declare const tidyMessage: (message: string) => string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The message a `CmsWhatsAppCart` sends: the editor's template, filled from
|
|
3
|
+
* the order by `@velastack/whatsapp`'s `orderPlaceholders`.
|
|
4
|
+
*/
|
|
5
|
+
/** The `{placeholders}` an order message can use. */
|
|
6
|
+
export const ORDER_PLACEHOLDERS = ['ref', 'items', 'total', 'name', 'phone', 'note'];
|
|
7
|
+
/** Until the editor writes one. Lines left empty (no total, no note) are dropped. */
|
|
8
|
+
export const DEFAULT_ORDER_MESSAGE = "Hi! I'd like to place an order ({ref}):\n\n{items}\n\n{total}\n\n{name} · {phone}\n{note}";
|
|
9
|
+
/** Drops the blank lines and dangling spaces that empty placeholders leave behind. */
|
|
10
|
+
export const tidyMessage = (message) => message
|
|
11
|
+
.replace(/[ \t]+$/gm, '')
|
|
12
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
13
|
+
.trim();
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Where a form posts when the template names a hosted form by `key`:
|
|
3
|
+
* velastack.dev serves a project's hosted forms beside its CMS, at
|
|
4
|
+
* `/v1/projects/<id>/forms/<key>`. Any other endpoint shape has no hosted
|
|
5
|
+
* forms, so `hostedFormUrl` gives `null` and the template passes `action`.
|
|
6
|
+
*/
|
|
7
|
+
export { hostedFormUrl } from '@velastack/whatsapp';
|
|
2
8
|
/** Fields a form carries for the endpoint rather than the submission. */
|
|
3
9
|
export declare const isControlField: (name: string) => boolean;
|
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Where a form posts when the template names a hosted form by `key
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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.
|
|
2
|
+
* Where a form posts when the template names a hosted form by `key`:
|
|
3
|
+
* velastack.dev serves a project's hosted forms beside its CMS, at
|
|
4
|
+
* `/v1/projects/<id>/forms/<key>`. Any other endpoint shape has no hosted
|
|
5
|
+
* forms, so `hostedFormUrl` gives `null` and the template passes `action`.
|
|
9
6
|
*/
|
|
10
|
-
|
|
11
|
-
export const hostedFormUrl = (endpoint, key) => {
|
|
12
|
-
const match = HOSTED_CMS.exec(endpoint);
|
|
13
|
-
return match ? `${match[1]}/forms/${encodeURIComponent(key)}` : null;
|
|
14
|
-
};
|
|
7
|
+
export { hostedFormUrl } from '@velastack/whatsapp';
|
|
15
8
|
/** Fields a form carries for the endpoint rather than the submission. */
|
|
16
9
|
export const isControlField = (name) => name.startsWith('_');
|
package/dist/index.d.ts
CHANGED
|
@@ -36,6 +36,10 @@ export type { CmsFormState, CmsFormStatus } from './components/cms/cms-form.svel
|
|
|
36
36
|
export { default as CmsFormFields } from './components/cms/cms-form-fields.svelte';
|
|
37
37
|
export { default as CmsWhatsAppForm } from './components/cms/cms-whatsapp-form.svelte';
|
|
38
38
|
export type { CmsWhatsAppFormState } from './components/cms/cms-whatsapp-form.svelte';
|
|
39
|
+
export { default as CmsWhatsAppCart } from './components/cms/cms-whatsapp-cart.svelte';
|
|
40
|
+
export type { CmsWhatsAppCartState } from './components/cms/cms-whatsapp-cart.svelte';
|
|
41
|
+
export { whatsappCart } from './components/cms/whatsapp-cart.svelte.js';
|
|
42
|
+
export type { WhatsAppCartItem, WhatsAppPlacedOrder } from './components/cms/whatsapp-cart.svelte.js';
|
|
39
43
|
export * from './core/shapes/index.js';
|
|
40
44
|
export { default as AdminBar } from './components/admin-bar/admin-bar.svelte';
|
|
41
45
|
export { useCmsField, resolveScopeRef, ROOT_SCOPE_ID } from './components/cms/use-cms-field.svelte.js';
|
package/dist/index.js
CHANGED
|
@@ -39,6 +39,8 @@ export { default as CmsSchedule } from './components/cms/cms-schedule.svelte';
|
|
|
39
39
|
export { default as CmsForm } from './components/cms/cms-form.svelte';
|
|
40
40
|
export { default as CmsFormFields } from './components/cms/cms-form-fields.svelte';
|
|
41
41
|
export { default as CmsWhatsAppForm } from './components/cms/cms-whatsapp-form.svelte';
|
|
42
|
+
export { default as CmsWhatsAppCart } from './components/cms/cms-whatsapp-cart.svelte';
|
|
43
|
+
export { whatsappCart } from './components/cms/whatsapp-cart.svelte.js';
|
|
42
44
|
export * from './core/shapes/index.js';
|
|
43
45
|
// Admin bar (sync wrapper; admin-bar-internal + seo-panel are async chunks)
|
|
44
46
|
export { default as AdminBar } from './components/admin-bar/admin-bar.svelte';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velastack/cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "A scope-aware CMS for SvelteKit, for static or dynamic sites.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -126,6 +126,7 @@
|
|
|
126
126
|
"@tiptap/extension-link": "^3.30.6",
|
|
127
127
|
"@tiptap/pm": "^3.30.6",
|
|
128
128
|
"@tiptap/starter-kit": "^3.30.6",
|
|
129
|
+
"@velastack/whatsapp": "^0.1.0",
|
|
129
130
|
"bits-ui": "^2.19.0",
|
|
130
131
|
"devalue": "^6.0.0",
|
|
131
132
|
"tailwind-merge": "^3.6.0"
|
|
@@ -1,18 +0,0 @@
|
|
|
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;
|
|
@@ -1,29 +0,0 @@
|
|
|
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
|
-
};
|