@softure-ai/waitlist 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +302 -2
- package/dist/contract.d.ts +53 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +2 -0
- package/dist/contract.js.map +1 -0
- package/dist/fields.d.ts +7 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +11 -0
- package/dist/fields.js.map +1 -0
- package/dist/index.d.ts +97 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/mail-template.d.ts +36 -0
- package/dist/mail-template.d.ts.map +1 -0
- package/dist/mail-template.js +20 -0
- package/dist/mail-template.js.map +1 -0
- package/dist/messages/en.d.ts +47 -0
- package/dist/messages/en.d.ts.map +1 -0
- package/dist/messages/en.js +47 -0
- package/dist/messages/en.js.map +1 -0
- package/dist/messages/index.d.ts +101 -0
- package/dist/messages/index.d.ts.map +1 -0
- package/dist/messages/index.js +14 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/pl.d.ts +3 -0
- package/dist/messages/pl.d.ts.map +1 -0
- package/dist/messages/pl.js +47 -0
- package/dist/messages/pl.js.map +1 -0
- package/dist/next/actions.d.ts +9 -0
- package/dist/next/actions.d.ts.map +1 -0
- package/dist/next/actions.js +119 -0
- package/dist/next/actions.js.map +1 -0
- package/dist/next/confirm-page.d.ts +7 -0
- package/dist/next/confirm-page.d.ts.map +1 -0
- package/dist/next/confirm-page.js +40 -0
- package/dist/next/confirm-page.js.map +1 -0
- package/dist/next/context.d.ts +4 -0
- package/dist/next/context.d.ts.map +1 -0
- package/dist/next/context.js +14 -0
- package/dist/next/context.js.map +1 -0
- package/dist/next/index.d.ts +6 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +8 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/params.d.ts +4 -0
- package/dist/next/params.d.ts.map +1 -0
- package/dist/next/params.js +4 -0
- package/dist/next/params.js.map +1 -0
- package/dist/next/waitlist.d.ts +13 -0
- package/dist/next/waitlist.d.ts.map +1 -0
- package/dist/next/waitlist.js +23 -0
- package/dist/next/waitlist.js.map +1 -0
- package/dist/options.d.ts +62 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +80 -0
- package/dist/options.js.map +1 -0
- package/dist/schema.d.ts +248 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +18 -0
- package/dist/schema.js.map +1 -0
- package/dist/server/confirmation-mail.d.ts +22 -0
- package/dist/server/confirmation-mail.d.ts.map +1 -0
- package/dist/server/confirmation-mail.js +70 -0
- package/dist/server/confirmation-mail.js.map +1 -0
- package/dist/server/confirmation-token.d.ts +8 -0
- package/dist/server/confirmation-token.d.ts.map +1 -0
- package/dist/server/confirmation-token.js +18 -0
- package/dist/server/confirmation-token.js.map +1 -0
- package/dist/server/health.d.ts +3 -0
- package/dist/server/health.d.ts.map +1 -0
- package/dist/server/health.js +11 -0
- package/dist/server/health.js.map +1 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +10 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/mail-html.d.ts +14 -0
- package/dist/server/mail-html.d.ts.map +1 -0
- package/dist/server/mail-html.js +20 -0
- package/dist/server/mail-html.js.map +1 -0
- package/dist/server/options.d.ts +21 -0
- package/dist/server/options.d.ts.map +1 -0
- package/dist/server/options.js +39 -0
- package/dist/server/options.js.map +1 -0
- package/dist/server/privacy.d.ts +18 -0
- package/dist/server/privacy.d.ts.map +1 -0
- package/dist/server/privacy.js +46 -0
- package/dist/server/privacy.js.map +1 -0
- package/dist/server/setup.d.ts +7 -0
- package/dist/server/setup.d.ts.map +1 -0
- package/dist/server/setup.js +30 -0
- package/dist/server/setup.js.map +1 -0
- package/dist/server/signups.d.ts +81 -0
- package/dist/server/signups.d.ts.map +1 -0
- package/dist/server/signups.js +269 -0
- package/dist/server/signups.js.map +1 -0
- package/dist/server/unsubscribe.d.ts +12 -0
- package/dist/server/unsubscribe.d.ts.map +1 -0
- package/dist/server/unsubscribe.js +23 -0
- package/dist/server/unsubscribe.js.map +1 -0
- package/dist/server/welcome-mail.d.ts +17 -0
- package/dist/server/welcome-mail.d.ts.map +1 -0
- package/dist/server/welcome-mail.js +31 -0
- package/dist/server/welcome-mail.js.map +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +5 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/waitlist-form.d.ts +28 -0
- package/dist/ui/waitlist-form.d.ts.map +1 -0
- package/dist/ui/waitlist-form.js +26 -0
- package/dist/ui/waitlist-form.js.map +1 -0
- package/migrations/0001_create_signups.sql +36 -0
- package/migrations/0002_add_confirmation.sql +33 -0
- package/module.json +12 -0
- package/package.json +66 -4
- package/src/contract.ts +62 -0
- package/src/fields.ts +12 -0
- package/src/index.ts +79 -0
- package/src/mail-template.ts +56 -0
- package/src/messages/en.ts +46 -0
- package/src/messages/index.ts +18 -0
- package/src/messages/pl.ts +48 -0
- package/src/next/actions.ts +119 -0
- package/src/next/confirm-page.tsx +66 -0
- package/src/next/context.ts +15 -0
- package/src/next/index.ts +7 -0
- package/src/next/next-modules.d.ts +16 -0
- package/src/next/params.ts +6 -0
- package/src/next/waitlist.tsx +43 -0
- package/src/options.ts +106 -0
- package/src/schema.ts +19 -0
- package/src/server/confirmation-mail.ts +72 -0
- package/src/server/confirmation-token.ts +21 -0
- package/src/server/health.ts +12 -0
- package/src/server/index.ts +33 -0
- package/src/server/mail-html.ts +32 -0
- package/src/server/options.ts +53 -0
- package/src/server/privacy.ts +62 -0
- package/src/server/setup.ts +32 -0
- package/src/server/signups.ts +341 -0
- package/src/server/unsubscribe.ts +28 -0
- package/src/server/welcome-mail.ts +34 -0
- package/src/ui/index.ts +10 -0
- package/src/ui/waitlist-form.tsx +98 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SOFTURE
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,303 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @softure-ai/waitlist
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Collects sign-ups before a product opens: one row per email address with the consent scopes the
|
|
4
|
+
person checked, evidence of each consent in privacy's ledger, a welcome mail sent once through
|
|
5
|
+
mailing, and a standalone form. Built from FIRE_TRACKER's waitlist (`src/db/waitlist.ts`,
|
|
6
|
+
`src/app/actions/{do-waitlist,waitlist,waitlist-contract}.ts`,
|
|
7
|
+
`src/lib/{waitlist-consent,waitlist-placement,welcome-mail}.ts`, `src/components/consent-checkbox.tsx`
|
|
8
|
+
and the form inside `calculator-lista.tsx`), with the scopes and placements moved from CHECK
|
|
9
|
+
constraints into the app's configuration and the form out of the domain component.
|
|
10
|
+
|
|
11
|
+
## 1. What it provides
|
|
12
|
+
|
|
13
|
+
- The table `waitlist.signups`: the address (trimmed, lowercased, unique), the granted scopes in
|
|
14
|
+
the config's order, the placement of the first sign-up, the locale, its timestamps and, with
|
|
15
|
+
double opt-in, the request that waits for its confirmation link.
|
|
16
|
+
- **Double opt-in as an option** (`doubleOptIn`, off by default). A sign-up then waits for the link
|
|
17
|
+
in a confirmation mail: until it is used, nothing is recorded, no opt-out is lifted, no list mail
|
|
18
|
+
goes out and `listSignups` leaves the address out. The link applies the request and sends the
|
|
19
|
+
welcome mail.
|
|
20
|
+
- **Consent scopes from configuration.** The app declares them in `waitlist({ scopes })`, with
|
|
21
|
+
labels per locale, which ones are required and the legal document each refers to. The table
|
|
22
|
+
checks only their shape; nothing app-specific is hard-coded in a migration.
|
|
23
|
+
- **Repeat sign-ups widen, never narrow.** Signing up again adds the newly checked scopes and keeps
|
|
24
|
+
the earlier ones. The answer is the same for a new and a known address.
|
|
25
|
+
- **Consent evidence in privacy.** Each requested scope the ledger does not currently grant (never
|
|
26
|
+
given, withdrawn, or given to an older version of its document) is recorded with
|
|
27
|
+
`recordConsent` (source `waitlist`, the document's version), in the sign-up's transaction.
|
|
28
|
+
- **A welcome mail after the response**, through mailing's delivery ledger: at most once per
|
|
29
|
+
sign-up (scope `waitlist.welcome:<id>`), as list mail of kind `waitlist`, so it carries mailing's
|
|
30
|
+
unsubscribe link and RFC 8058 headers and is never sent to an address that unsubscribed. Text
|
|
31
|
+
and HTML; the app can render the HTML of both mails with its own template (`mailTemplate`).
|
|
32
|
+
- **The ledger follows unsubscribes.** `withdrawWaitlistConsents`, wired as mailing's
|
|
33
|
+
`onUnsubscribed`, records a withdrawal of every scope the address still grants, in the opt-out's
|
|
34
|
+
transaction. A new sign-up lifts the address's own opt-out (`liftSuppression`) in its
|
|
35
|
+
transaction and, after one, stores exactly the scopes checked this time.
|
|
36
|
+
- **A rate-limited public action**: bucket `waitlist` per client, `waitlist-email` per address.
|
|
37
|
+
- `<Waitlist placement="hero" />` (`/next`), the form wired in one line, and `WaitlistForm` (`/ui`)
|
|
38
|
+
with slots, `unstyled` and messages for an app that composes its own.
|
|
39
|
+
- Export and deletion of the account's sign-up (`@softure-ai/privacy`), a health check for
|
|
40
|
+
`GET /api/health`, and `listSignups` for a launch mail.
|
|
41
|
+
|
|
42
|
+
## 2. Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install @softure-ai/waitlist @softure-ai/mailing @softure-ai/privacy @softure-ai/auth @softure-ai/security @softure-ai/core @softure-ai/db @softure-ai/ui drizzle-orm zod
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Peer dependencies: `next` 16, `react` 19, `drizzle-orm`. The module depends on `security`,
|
|
49
|
+
`mailing` and `privacy` (which needs `auth`); a configuration without them fails at startup.
|
|
50
|
+
|
|
51
|
+
## 3. Configuration
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { auth, AUTH_RATE_LIMIT_BUCKETS } from "@softure-ai/auth";
|
|
55
|
+
import { mailing, resend } from "@softure-ai/mailing";
|
|
56
|
+
import { privacy } from "@softure-ai/privacy";
|
|
57
|
+
import { cloudflareIp, security } from "@softure-ai/security";
|
|
58
|
+
import { waitlist, WAITLIST_RATE_LIMIT_BUCKETS } from "@softure-ai/waitlist";
|
|
59
|
+
import { withdrawWaitlistConsents } from "@softure-ai/waitlist/server";
|
|
60
|
+
import { en } from "./messages/en";
|
|
61
|
+
import { pl } from "./messages/pl";
|
|
62
|
+
|
|
63
|
+
// in defineSoftureConfig({ modules: [...] }):
|
|
64
|
+
security({ clientIp: cloudflareIp(), buckets: { ...AUTH_RATE_LIMIT_BUCKETS, ...WAITLIST_RATE_LIMIT_BUCKETS } }),
|
|
65
|
+
auth({ ... }),
|
|
66
|
+
// An unsubscribe withdraws the waitlist's consents (section 10):
|
|
67
|
+
mailing({ from: "Acme <hello@mail.acme.com>", provider: resend(), onUnsubscribed: withdrawWaitlistConsents }),
|
|
68
|
+
privacy({ documents: [{ id: "privacy-policy", version: "2026-10-01" }] }),
|
|
69
|
+
waitlist({
|
|
70
|
+
scopes: [
|
|
71
|
+
{ id: "launch", required: true, document: "privacy-policy", label: { en: en.waitlist.launch, pl: pl.waitlist.launch } },
|
|
72
|
+
{ id: "newsletter", label: { en: en.waitlist.newsletter, pl: pl.waitlist.newsletter } },
|
|
73
|
+
],
|
|
74
|
+
placements: ["hero", "footer"],
|
|
75
|
+
}),
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
| Option | Type | Default | Meaning |
|
|
79
|
+
| --- | --- | --- | --- |
|
|
80
|
+
| `scopes` | `{ id, required?, document?, label: { en, pl? } }[]`, 1 to 16 | required | The checkboxes, in order. `id` is kebab-case (at most 64 characters) and becomes the consent purpose in `privacy.consents`. `required` scopes must be checked; without any required scope, at least one must be. `document` names a document of `privacy({ documents })`, whose version is recorded with the consent. |
|
|
81
|
+
| `placements` | kebab-case `string[]` | `["default"]` | Where the app embeds the form. Each sign-up stores the placement of its first form. |
|
|
82
|
+
| `welcomeMail` | `boolean` | `true` | Sends the welcome mail (with double opt-in, after the confirmation). Off, the app sends its own (or none). |
|
|
83
|
+
| `doubleOptIn` | `boolean` or `{ expiresInHours? }` | `false` | A sign-up counts only after the link in a confirmation mail is used. `true` keeps the link working for 168 hours (7 days); `expiresInHours` sets 1 to 720. Section 10. |
|
|
84
|
+
| `mailTemplate` | `(mail) => string` | — | Renders the HTML body of the welcome and confirmation mails (below). Without it, a plain HTML body built from the same copy. |
|
|
85
|
+
| `onJoined` | `(event, ctx) => void \| Promise<void>` | — | Called in the sign-up's transaction when a sign-up counts for the first time, e.g. to count it in the analytics funnel. Section 10. |
|
|
86
|
+
| `rewriteConfirmationLink` | `(path, { config }) => string \| Promise<string>` | — | Rewrites the confirmation link's path, e.g. to keep the analytics channel tag through the mail. Section 10. |
|
|
87
|
+
| `routes` | `{ confirm? }` | `{ confirm: "/waitlist/confirm" }` | The path of the confirmation page, when the app mounts it elsewhere. |
|
|
88
|
+
| `messages` | partial `en` / `pl` | — | Copy overrides, the welcome mail's subject and text included. |
|
|
89
|
+
|
|
90
|
+
`WAITLIST_RATE_LIMIT_BUCKETS` is `{ waitlist: { limit: 10, windowMinutes: 15 }, "waitlist-email": { limit: 3, windowMinutes: 60 } }`.
|
|
91
|
+
Every attempt counts per client before anything is checked; the per-address bucket stops one
|
|
92
|
+
address from being signed up over and over. The first sign-up checks once that both buckets are
|
|
93
|
+
configured and that every scope's document is declared, and throws naming what is missing.
|
|
94
|
+
|
|
95
|
+
The welcome mail is list mail, so mailing needs `MAILING_UNSUBSCRIBE_SECRET` (mailing README §6);
|
|
96
|
+
without it the mail is refused and the sign-up still succeeds. The confirmation mail is
|
|
97
|
+
transactional and needs no secret.
|
|
98
|
+
|
|
99
|
+
Both mails carry a text body and an HTML body built from the same copy: each paragraph (blocks
|
|
100
|
+
split by a blank line) escaped in `<p>`, and in the confirmation mail the link as an anchor
|
|
101
|
+
labelled `confirmationMail.action`. Mailing adds its unsubscribe footer to both bodies of the
|
|
102
|
+
welcome mail (inside `<body>` when the HTML is a whole document). To use the app's own layout, pass
|
|
103
|
+
a template; it gets `kind` (`welcome` or `confirmation`), `locale`, `subject`, `paragraphs` (plain
|
|
104
|
+
text: escape them with `escapeHtml`), `body` (the default HTML, safe to wrap) and, for the
|
|
105
|
+
confirmation mail, `action: { href, label }`:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { escapeHtml, waitlist, type WaitlistMailTemplate } from "@softure-ai/waitlist";
|
|
109
|
+
|
|
110
|
+
const mailTemplate: WaitlistMailTemplate = (mail) =>
|
|
111
|
+
`<!doctype html><html lang="${mail.locale}"><body><h1>${escapeHtml(mail.subject)}</h1>${mail.body}</body></html>`;
|
|
112
|
+
|
|
113
|
+
waitlist({ scopes: [...], mailTemplate });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The template runs when the mail is sent, after the response to the form or the link. One that
|
|
117
|
+
throws, or returns blank HTML (the module then throws an error naming `mailTemplate`), sends no
|
|
118
|
+
mail: the sign-up stands, and the error reaches the app's logs.
|
|
119
|
+
|
|
120
|
+
## 4. Mounting
|
|
121
|
+
|
|
122
|
+
The form posts to a server action that ships in the package. Embed it in any server component:
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
import { Waitlist } from "@softure-ai/waitlist/next";
|
|
126
|
+
|
|
127
|
+
<Waitlist placement="hero" />
|
|
128
|
+
// a label with a link, in place of the config's text:
|
|
129
|
+
<Waitlist placement="footer" consentLabels={{ launch: <>Tell me when it opens (<a href="/legal/privacy">privacy policy</a>).</> }} />
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
With double opt-in, mount the confirmation page (the link in the mail opens it):
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
// app/waitlist/confirm/page.tsx
|
|
136
|
+
export { ConfirmSignupPage as default } from "@softure-ai/waitlist/next";
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Opening the page changes nothing (mail scanners open links): it shows a button that posts
|
|
140
|
+
`confirmSignupAction`, which confirms and redirects back with `?status=done` (or `invalid`,
|
|
141
|
+
`expired`, `limited`, `failed`; the last two keep the link for a retry).
|
|
142
|
+
|
|
143
|
+
An app that composes its own form passes `joinWaitlistAction` (`/next`) to `WaitlistForm` (`/ui`)
|
|
144
|
+
with the scopes it prepared (`{ id, label, required }`), the placement and the messages.
|
|
145
|
+
|
|
146
|
+
Server functions, for scripts and other hosts (`@softure-ai/waitlist/server`):
|
|
147
|
+
`joinWaitlist(ctx, { email, scopes, placement, clientKey })` returns
|
|
148
|
+
`Ok<{ status: "joined", signup, isNew, recordedScopes }>` (applied at once),
|
|
149
|
+
`Ok<{ status: "confirmation_required", signup, isNew, token, expiresAt }>` (double opt-in: pass
|
|
150
|
+
`signup` and `token` to `deliverConfirmationMail(ctx, signup, token)`, never to the client) or
|
|
151
|
+
`Err<waitlist.email_invalid | waitlist.consent_required | waitlist.form_invalid | security.rate_limited>`;
|
|
152
|
+
`confirmSignup(ctx, { token, clientKey })` returns `Ok<{ signup, recordedScopes, isFirstConfirmation }>`
|
|
153
|
+
or `Err<waitlist.confirmation_invalid | waitlist.confirmation_expired | security.rate_limited>`;
|
|
154
|
+
`pruneUnconfirmedSignups(ctx)` deletes sign-ups whose link expired unused (for a scheduled job;
|
|
155
|
+
returns the count); `getConfirmationLink(config, token)` builds the link;
|
|
156
|
+
`withdrawWaitlistConsents(event, ctx)` is mailing's `onUnsubscribed` handler (section 10);
|
|
157
|
+
`deliverWelcomeMail(ctx, signup)` returns mailing's `DeliveryOutcome` or `{ status: "skipped" }`;
|
|
158
|
+
`getSignup(ctx, email)` (confirmed or not, see `confirmedAt`); `listSignups(ctx, { scope?, placement? })`,
|
|
159
|
+
the confirmed sign-ups oldest first. A launch mail is
|
|
160
|
+
a loop over `listSignups(ctx, { scope: "launch" })` with mailing's `deliverOnce` (or a mailing
|
|
161
|
+
campaign): unsubscribed addresses are refused there.
|
|
162
|
+
|
|
163
|
+
## 5. Migrations and tables
|
|
164
|
+
|
|
165
|
+
`migrations/0001_create_signups.sql` creates `waitlist.signups` and the function
|
|
166
|
+
`waitlist.is_scope_list(text[])` its check uses; `0002_add_confirmation.sql` adds the double opt-in
|
|
167
|
+
columns, with checks that an unconfirmed row has a pending request and a pending request has a link:
|
|
168
|
+
|
|
169
|
+
| Column | Meaning |
|
|
170
|
+
| --- | --- |
|
|
171
|
+
| `id` | `uuid`, the sign-up; the welcome mail's delivery scope names it. |
|
|
172
|
+
| `email` | Trimmed and lowercased (a CHECK), unique (`signups_email_key`). |
|
|
173
|
+
| `scopes` | `text[]`: 1 to 16 distinct kebab-case ids of at most 64 characters, in the config's order. GIN index for lists by scope. |
|
|
174
|
+
| `placement` | Kebab-case, the first sign-up's form. |
|
|
175
|
+
| `locale` | The app's locale at the first sign-up: the welcome mail's language. |
|
|
176
|
+
| `created_at`, `updated_at` | The first sign-up and the last change. |
|
|
177
|
+
| `confirmed_at` | When the sign-up first counted (at once without double opt-in); NULL while its first request waits for the link. Rows from before migration 2 are confirmed at `created_at`. |
|
|
178
|
+
| `pending_scopes` | The scopes of a request that waits for its link (a first sign-up, or more scopes later), or NULL. |
|
|
179
|
+
| `confirmation_token_hash`, `confirmation_expires_at` | sha256 (hex, unique) of the latest link's token and its expiry; set together. A used link keeps its hash until the next request replaces it, so a second click answers "confirmed". |
|
|
180
|
+
|
|
181
|
+
Scope ids are validated text, not an enum or a CHECK list: they are the app's, and a list in the
|
|
182
|
+
database would need a module migration for every app's change of copy.
|
|
183
|
+
|
|
184
|
+
## 6. Environment variables
|
|
185
|
+
|
|
186
|
+
None of its own. The welcome mail needs mailing's `MAILING_UNSUBSCRIBE_SECRET`; the confirmation
|
|
187
|
+
link is a random token stored as a hash, so double opt-in needs no secret.
|
|
188
|
+
|
|
189
|
+
## 7. Switches
|
|
190
|
+
|
|
191
|
+
None. `welcomeMail: false` turns the mail off; `doubleOptIn` is an option, not a switch, because
|
|
192
|
+
turning it off while requests wait would leave them unconfirmed (a later sign-up of the same
|
|
193
|
+
address applies them).
|
|
194
|
+
|
|
195
|
+
## 8. Appearance
|
|
196
|
+
|
|
197
|
+
`WaitlistForm` uses the `@softure-ai/ui` primitives (TextField, Checkbox, FormError, Button) and
|
|
198
|
+
takes `classNames` for its slots `root`, `form`, `scopes` and `notice`, or `unstyled`. `Waitlist`
|
|
199
|
+
passes both through.
|
|
200
|
+
|
|
201
|
+
## 9. Copy
|
|
202
|
+
|
|
203
|
+
`waitlistMessages` (`en`, `pl`): `form` (field label, button, pending text, the confirmation and,
|
|
204
|
+
with double opt-in, `confirmationSent`), `welcomeMail` (subject and text; mailing appends the
|
|
205
|
+
unsubscribe footer), `confirmationMail` (subject, text and `action`, the link's label in the HTML
|
|
206
|
+
body; in the text body the link follows the text), `confirm`
|
|
207
|
+
(the confirmation page: its states and button) and `errors`. Override
|
|
208
|
+
them with `waitlist({ messages: { pl: { welcomeMail: { subject: "…" } } } })`. Scope labels are
|
|
209
|
+
the app's, in `scopes[].label` or `consentLabels`.
|
|
210
|
+
|
|
211
|
+
## 10. Hooks
|
|
212
|
+
|
|
213
|
+
`onJoined(event, ctx)` runs when a sign-up counts for the first time, inside its transaction (`ctx`
|
|
214
|
+
is the module context with the transaction as `db`, like auth's `onRegistered`): a new sign-up
|
|
215
|
+
without double opt-in (`via: "join"`), or the first use of its link with it (`via: "confirmation"`).
|
|
216
|
+
`event.signup` is the sign-up as it stands. A repeat request that only widens the scopes, a second
|
|
217
|
+
link, or a link used again does not call it: the consent ledger records those. An error the hook
|
|
218
|
+
throws rolls the sign-up back (the form or the confirmation page answers the generic error, and the
|
|
219
|
+
link stays usable), so a hook that must never refuse a sign-up catches its own errors, as
|
|
220
|
+
analytics' `countFunnelStep` does:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { countFunnelStep, tagRedirect } from "@softure-ai/analytics/next";
|
|
224
|
+
|
|
225
|
+
waitlist({
|
|
226
|
+
scopes: [...],
|
|
227
|
+
doubleOptIn: true,
|
|
228
|
+
// Counts each sign-up as the funnel's `waitlist` step (a `server` step) under the visit's channel.
|
|
229
|
+
onJoined: countFunnelStep("waitlist"),
|
|
230
|
+
// With double opt-in the sign-up counts on the confirmation page, opened from a mail: the link
|
|
231
|
+
// carries the channel of the form's page so the count keeps it.
|
|
232
|
+
rewriteConfirmationLink: tagRedirect,
|
|
233
|
+
}),
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`rewriteConfirmationLink(path, { config })` gets the link's path (the confirm route with its
|
|
237
|
+
token) where the confirmation mail is built: in the join action, after the response, where Next
|
|
238
|
+
still exposes the request's headers. Its result is used only when it is the confirm route on this
|
|
239
|
+
app with the same token; anything else, or an error, mails the module's own link with a log line.
|
|
240
|
+
`resolveConfirmationLink(config, token)` from `/server` gives the link as it will be mailed.
|
|
241
|
+
|
|
242
|
+
`joinWaitlist` also returns `isNew` and `recordedScopes` (and `confirmSignup` returns
|
|
243
|
+
`isFirstConfirmation`) for an app that reacts to every request in its own server code.
|
|
244
|
+
|
|
245
|
+
The waitlist plugs into mailing's `onUnsubscribed` with `withdrawWaitlistConsents`. Mailing's
|
|
246
|
+
opt-out covers every list mail and its link names only the recipient key, so the handler withdraws
|
|
247
|
+
each declared scope whose latest record grants it (`granted: false`, source `unsubscribe`, subject
|
|
248
|
+
`{ emailKey }`); a repeated unsubscribe adds nothing. Without it, the ledger keeps showing granted
|
|
249
|
+
consents for an address that unsubscribed, and a later sign-up's lift erases the only record of
|
|
250
|
+
the opt-out. If the app has other mail consents, compose: `onUnsubscribed: async (event, ctx) => {
|
|
251
|
+
await withdrawWaitlistConsents(event, ctx); await withdrawMine(event, ctx); }`.
|
|
252
|
+
|
|
253
|
+
A sign-up is an explicit consent: applying it calls mailing's `liftSuppression` in its
|
|
254
|
+
transaction, which removes an opt-out the person made themselves (never an operator's). When it
|
|
255
|
+
removed one, the sign-up's scopes become the ones checked now instead of the union, because the
|
|
256
|
+
opt-out withdrew all of them. Without double opt-in this happens in `joinWaitlist`; with it, only
|
|
257
|
+
in `confirmSignup`, so typing someone's address cannot undo their opt-out.
|
|
258
|
+
|
|
259
|
+
### Double opt-in
|
|
260
|
+
|
|
261
|
+
With `doubleOptIn` on, `joinWaitlist` stores the request on the row (`pending_scopes`) with a new
|
|
262
|
+
single-use link that replaces any earlier one, and the join action mails it as transactional mail
|
|
263
|
+
(it must reach an address that opted out and signs up again). Nothing else happens until the link
|
|
264
|
+
is used: no consent row, no lift, no welcome mail, and `listSignups` leaves a sign-up that never
|
|
265
|
+
counted out. A repeat request on an unconfirmed sign-up replaces its scopes; on a confirmed one it
|
|
266
|
+
waits beside the granted scopes, which stay until its link is used. `confirmSignup` applies the
|
|
267
|
+
request as above, records the consents (with the document version in force at confirmation), sets
|
|
268
|
+
`confirmed_at` the first time, and the confirm action sends the welcome mail. Consents are recorded
|
|
269
|
+
only at confirmation: the ledger is insert-only, and a row written before would claim a consent
|
|
270
|
+
nobody proved. Run `pruneUnconfirmedSignups(ctx)` from a scheduled job to delete sign-ups whose link
|
|
271
|
+
expired unused; until then they count nowhere, and a new sign-up of the address reuses the row.
|
|
272
|
+
|
|
273
|
+
## 11. GDPR
|
|
274
|
+
|
|
275
|
+
- The waitlist contributes to `@softure-ai/privacy`: the export of an account holds the sign-up of
|
|
276
|
+
its email address (scopes, placement, dates, `confirmedAt`, a pending request's scopes), and
|
|
277
|
+
deleting the account deletes that sign-up.
|
|
278
|
+
Privacy's own part covers the consents the sign-up recorded.
|
|
279
|
+
- Consents are recorded per scope with the document version in force, so the app can show what a
|
|
280
|
+
person agreed to and when (`listConsents` of `@softure-ai/privacy/server`, subject `{ email }`).
|
|
281
|
+
With double opt-in that is the version in force when the link is used; a document changed
|
|
282
|
+
between the form and the link (at most the link's lifetime) is recorded in its newer version.
|
|
283
|
+
- With double opt-in, an address that never confirms is deleted by `pruneUnconfirmedSignups` once
|
|
284
|
+
its link expires.
|
|
285
|
+
- A person without an account who asks for erasure: delete their row with
|
|
286
|
+
`DELETE FROM waitlist.signups WHERE email = lower(btrim($1))` and their consents in
|
|
287
|
+
`privacy.consents` by `email_key` (an operator task; there is no self-service page without an account).
|
|
288
|
+
|
|
289
|
+
## 12. Limitations
|
|
290
|
+
|
|
291
|
+
- Double opt-in is off by default: without it a sign-up counts at once, so a typo or a third
|
|
292
|
+
party's address joins the list, and anyone who types an address can lift that address's opt-out
|
|
293
|
+
by signing it up (bounded by the `waitlist-email` bucket).
|
|
294
|
+
- With double opt-in, anyone can make the module send a confirmation mail to any address, also one
|
|
295
|
+
that opted out (it is transactional): bounded by the `waitlist-email` bucket, 3 per hour per
|
|
296
|
+
address. The mail carries only the link and says to ignore it.
|
|
297
|
+
- Expired unconfirmed sign-ups stay until the app runs `pruneUnconfirmedSignups`; the module has
|
|
298
|
+
no scheduler of its own.
|
|
299
|
+
- With double opt-in, a sign-up's channel reaches `onJoined` only through the confirmation link
|
|
300
|
+
(`rewriteConfirmationLink`, section 10); a link opened on another device still carries it, a
|
|
301
|
+
sign-up confirmed from an untagged link counts without one.
|
|
302
|
+
- The placement is stored per sign-up; handing placement counts to `@softure-ai/analytics` belongs
|
|
303
|
+
to the analytics roadmap.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { CoreErrorCode } from "@softure-ai/core";
|
|
2
|
+
export type WaitlistErrorCode =
|
|
3
|
+
/** Not an email address (or longer than 254 characters). */
|
|
4
|
+
"waitlist.email_invalid"
|
|
5
|
+
/** A required scope was not checked, or no scope at all. */
|
|
6
|
+
| "waitlist.consent_required"
|
|
7
|
+
/** A scope or placement the config does not declare: a tampered or outdated form. */
|
|
8
|
+
| "waitlist.form_invalid";
|
|
9
|
+
/** Why a confirmation link was refused. */
|
|
10
|
+
export type WaitlistConfirmationErrorCode =
|
|
11
|
+
/** Missing, malformed, or replaced by a newer link. */
|
|
12
|
+
"waitlist.confirmation_invalid"
|
|
13
|
+
/** Past its expiry; signing up again sends a new one. */
|
|
14
|
+
| "waitlist.confirmation_expired";
|
|
15
|
+
/** Every code the form can show: its own, the client and rate limit refusals, the generic ones. */
|
|
16
|
+
export type WaitlistFormErrorCode = WaitlistErrorCode | "security.rate_limited" | "security.client_unidentified" | CoreErrorCode;
|
|
17
|
+
export type WaitlistFormField = "email" | "consent";
|
|
18
|
+
/** What the join action returns to its form (`useActionState`). */
|
|
19
|
+
export interface WaitlistFormState {
|
|
20
|
+
/** `ok`: the sign-up counts; `confirmation_sent`: it waits for the link mailed to the address. */
|
|
21
|
+
readonly status: "idle" | "ok" | "confirmation_sent" | "error";
|
|
22
|
+
readonly error?: WaitlistFormErrorCode;
|
|
23
|
+
/** The field the error belongs to; none for a form-level error. */
|
|
24
|
+
readonly field?: WaitlistFormField;
|
|
25
|
+
/** The email as typed, to fill the field again after an error. */
|
|
26
|
+
readonly email?: string;
|
|
27
|
+
/** The scopes checked, to check them again after an error. */
|
|
28
|
+
readonly scopes?: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
export declare const INITIAL_WAITLIST_FORM_STATE: WaitlistFormState;
|
|
31
|
+
/** One sign-up as the module returns it. */
|
|
32
|
+
export interface WaitlistSignup {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
/** Trimmed and lowercased. */
|
|
35
|
+
readonly email: string;
|
|
36
|
+
/** Granted scopes, in the config's order. */
|
|
37
|
+
readonly scopes: readonly string[];
|
|
38
|
+
/** The placement of the first sign-up. */
|
|
39
|
+
readonly placement: string;
|
|
40
|
+
/** The app's locale at the first sign-up. */
|
|
41
|
+
readonly locale: string;
|
|
42
|
+
readonly createdAt: Date;
|
|
43
|
+
readonly updatedAt: Date;
|
|
44
|
+
/** When it first counted; null while its first request waits for the confirmation link. */
|
|
45
|
+
readonly confirmedAt: Date | null;
|
|
46
|
+
}
|
|
47
|
+
/** What `onJoined` receives: a sign-up that counts for the first time, and how it came to count. */
|
|
48
|
+
export interface WaitlistJoinedEvent {
|
|
49
|
+
readonly signup: WaitlistSignup;
|
|
50
|
+
/** `join`: applied when it was made (no double opt-in); `confirmation`: its link was used. */
|
|
51
|
+
readonly via: "join" | "confirmation";
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=contract.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,MAAM,MAAM,iBAAiB;AAC3B,4DAA4D;AAC1D,wBAAwB;AAC1B,4DAA4D;GAC1D,2BAA2B;AAC7B,qFAAqF;GACnF,uBAAuB,CAAC;AAE5B,2CAA2C;AAC3C,MAAM,MAAM,6BAA6B;AACvC,uDAAuD;AACrD,+BAA+B;AACjC,yDAAyD;GACvD,+BAA+B,CAAC;AAEpC,mGAAmG;AACnG,MAAM,MAAM,qBAAqB,GAAG,iBAAiB,GAAG,uBAAuB,GAAG,8BAA8B,GAAG,aAAa,CAAC;AAEjI,MAAM,MAAM,iBAAiB,GAAG,OAAO,GAAG,SAAS,CAAC;AAEpD,mEAAmE;AACnE,MAAM,WAAW,iBAAiB;IAChC,kGAAkG;IAClG,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,mBAAmB,GAAG,OAAO,CAAC;IAC/D,QAAQ,CAAC,KAAK,CAAC,EAAE,qBAAqB,CAAC;IACvC,mEAAmE;IACnE,QAAQ,CAAC,KAAK,CAAC,EAAE,iBAAiB,CAAC;IACnC,kEAAkE;IAClE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,8DAA8D;IAC9D,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,eAAO,MAAM,2BAA2B,EAAE,iBAAsC,CAAC;AAEjF,4CAA4C;AAC5C,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,8BAA8B;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,6CAA6C;IAC7C,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,0CAA0C;IAC1C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,6CAA6C;IAC7C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,2FAA2F;IAC3F,QAAQ,CAAC,WAAW,EAAE,IAAI,GAAG,IAAI,CAAC;CACnC;AAED,oGAAoG;AACpG,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,8FAA8F;IAC9F,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,CAAC;CACvC"}
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contract.js","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAqCA,MAAM,CAAC,MAAM,2BAA2B,GAAsB,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC"}
|
package/dist/fields.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The field with the address. */
|
|
2
|
+
export declare const EMAIL_FIELD = "email";
|
|
3
|
+
/** The hidden field with the form's placement. */
|
|
4
|
+
export declare const PLACEMENT_FIELD = "placement";
|
|
5
|
+
/** The field name of a scope's checkbox. */
|
|
6
|
+
export declare function getScopeFieldName(scopeId: string): string;
|
|
7
|
+
//# sourceMappingURL=fields.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fields.d.ts","sourceRoot":"","sources":["../src/fields.ts"],"names":[],"mappings":"AAGA,kCAAkC;AAClC,eAAO,MAAM,WAAW,UAAU,CAAC;AACnC,kDAAkD;AAClD,eAAO,MAAM,eAAe,cAAc,CAAC;AAE3C,4CAA4C;AAC5C,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEzD"}
|
package/dist/fields.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The field names the waitlist form sends and the join action reads, shared by both sides (the
|
|
2
|
+
// form is a client module, so the action cannot import from it).
|
|
3
|
+
/** The field with the address. */
|
|
4
|
+
export const EMAIL_FIELD = "email";
|
|
5
|
+
/** The hidden field with the form's placement. */
|
|
6
|
+
export const PLACEMENT_FIELD = "placement";
|
|
7
|
+
/** The field name of a scope's checkbox. */
|
|
8
|
+
export function getScopeFieldName(scopeId) {
|
|
9
|
+
return `scope.${scopeId}`;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=fields.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fields.js","sourceRoot":"","sources":["../src/fields.ts"],"names":[],"mappings":"AAAA,+FAA+F;AAC/F,iEAAiE;AAEjE,kCAAkC;AAClC,MAAM,CAAC,MAAM,WAAW,GAAG,OAAO,CAAC;AACnC,kDAAkD;AAClD,MAAM,CAAC,MAAM,eAAe,GAAG,WAAW,CAAC;AAE3C,4CAA4C;AAC5C,MAAM,UAAU,iBAAiB,CAAC,OAAe;IAC/C,OAAO,SAAS,OAAO,EAAE,CAAC;AAC5B,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export declare const MODULE_ID = "waitlist";
|
|
2
|
+
/**
|
|
3
|
+
* Rate limit buckets the waitlist consumes, with defaults to spread into `security({ buckets })`:
|
|
4
|
+
* `waitlist` per client address and `waitlist-email` per signed-up address (a form sent for
|
|
5
|
+
* someone else's address many times).
|
|
6
|
+
*/
|
|
7
|
+
export declare const WAITLIST_RATE_LIMIT_BUCKETS: {
|
|
8
|
+
readonly waitlist: {
|
|
9
|
+
readonly limit: 10;
|
|
10
|
+
readonly windowMinutes: 15;
|
|
11
|
+
};
|
|
12
|
+
readonly "waitlist-email": {
|
|
13
|
+
readonly limit: 3;
|
|
14
|
+
readonly windowMinutes: 60;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Enables the waitlist in `softure.config.ts` (after `security`, `mailing` and `privacy`):
|
|
19
|
+
* `waitlist({ scopes: [{ id: "launch", required: true, document: "privacy-policy", label: { en: "Tell me when it opens." } }], placements: ["hero", "footer"] })`.
|
|
20
|
+
*/
|
|
21
|
+
export declare const waitlist: import("@softure-ai/core").ModuleFactory<{
|
|
22
|
+
confirm: string;
|
|
23
|
+
}, {
|
|
24
|
+
form: {
|
|
25
|
+
email: string;
|
|
26
|
+
submit: string;
|
|
27
|
+
pending: string;
|
|
28
|
+
success: string;
|
|
29
|
+
confirmationSent: string;
|
|
30
|
+
};
|
|
31
|
+
confirmationMail: {
|
|
32
|
+
subject: string;
|
|
33
|
+
text: string;
|
|
34
|
+
action: string;
|
|
35
|
+
};
|
|
36
|
+
confirm: {
|
|
37
|
+
title: string;
|
|
38
|
+
lead: string;
|
|
39
|
+
submit: string;
|
|
40
|
+
doneTitle: string;
|
|
41
|
+
doneBody: string;
|
|
42
|
+
invalidTitle: string;
|
|
43
|
+
invalidBody: string;
|
|
44
|
+
expiredTitle: string;
|
|
45
|
+
expiredBody: string;
|
|
46
|
+
failed: string;
|
|
47
|
+
limited: string;
|
|
48
|
+
};
|
|
49
|
+
welcomeMail: {
|
|
50
|
+
subject: string;
|
|
51
|
+
text: string;
|
|
52
|
+
};
|
|
53
|
+
errors: {
|
|
54
|
+
waitlist: {
|
|
55
|
+
email_invalid: string;
|
|
56
|
+
consent_required: string;
|
|
57
|
+
form_invalid: string;
|
|
58
|
+
};
|
|
59
|
+
security: {
|
|
60
|
+
rate_limited: string;
|
|
61
|
+
client_unidentified: string;
|
|
62
|
+
};
|
|
63
|
+
core: {
|
|
64
|
+
database_failed: string;
|
|
65
|
+
unexpected: string;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
}, import("zod").ZodObject<{
|
|
69
|
+
scopes: import("zod").ZodArray<import("zod").ZodObject<{
|
|
70
|
+
id: import("zod").ZodString;
|
|
71
|
+
required: import("zod").ZodDefault<import("zod").ZodBoolean>;
|
|
72
|
+
document: import("zod").ZodOptional<import("zod").ZodString>;
|
|
73
|
+
label: import("zod").ZodRecord<import("zod").ZodEnum<{
|
|
74
|
+
en: "en";
|
|
75
|
+
pl: "pl";
|
|
76
|
+
}> & import("zod/v4/core").$partial, import("zod").ZodString>;
|
|
77
|
+
}, import("zod/v4/core").$strict>>;
|
|
78
|
+
placements: import("zod").ZodDefault<import("zod").ZodArray<import("zod").ZodString>>;
|
|
79
|
+
welcomeMail: import("zod").ZodDefault<import("zod").ZodBoolean>;
|
|
80
|
+
doubleOptIn: import("zod").ZodPipe<import("zod").ZodDefault<import("zod").ZodUnion<readonly [import("zod").ZodBoolean, import("zod").ZodObject<{
|
|
81
|
+
expiresInHours: import("zod").ZodDefault<import("zod").ZodNumber>;
|
|
82
|
+
}, import("zod/v4/core").$strict>]>>, import("zod").ZodTransform<{
|
|
83
|
+
expiresInHours: number;
|
|
84
|
+
} | null, boolean | {
|
|
85
|
+
expiresInHours: number;
|
|
86
|
+
}>>;
|
|
87
|
+
mailTemplate: import("zod").ZodOptional<import("zod").ZodCustom<import("./mail-template.js").WaitlistMailTemplate, import("./mail-template.js").WaitlistMailTemplate>>;
|
|
88
|
+
onJoined: import("zod").ZodOptional<import("zod").ZodCustom<import("./options.js").OnJoinedHook, import("./options.js").OnJoinedHook>>;
|
|
89
|
+
rewriteConfirmationLink: import("zod").ZodOptional<import("zod").ZodCustom<import("./options.js").RewriteConfirmationLink, import("./options.js").RewriteConfirmationLink>>;
|
|
90
|
+
}, import("zod/v4/core").$strict>>;
|
|
91
|
+
export { INITIAL_WAITLIST_FORM_STATE, type WaitlistConfirmationErrorCode, type WaitlistErrorCode, type WaitlistFormErrorCode, type WaitlistFormField, type WaitlistFormState, type WaitlistJoinedEvent, type WaitlistSignup, } from "./contract.js";
|
|
92
|
+
export { EMAIL_FIELD, getScopeFieldName, PLACEMENT_FIELD } from "./fields.js";
|
|
93
|
+
export { escapeHtml, type ConfirmationMailTemplateInput, type WaitlistMailTemplate, type WaitlistMailTemplateInput, type WelcomeMailTemplateInput, } from "./mail-template.js";
|
|
94
|
+
export { getWaitlistErrorMessage, waitlistMessages, type WaitlistMessages } from "./messages/index.js";
|
|
95
|
+
export { DEFAULT_CONFIRMATION_HOURS, MAX_CONFIRMATION_HOURS, MAX_NAME_LENGTH, MAX_SCOPES, NAME_PATTERN, type OnJoinedHook, type RewriteConfirmationLink, type WaitlistOptions, type WaitlistOptionsInput, type WaitlistScope, type WaitlistScopeInput, } from "./options.js";
|
|
96
|
+
export { signups, waitlistSchema } from "./schema.js";
|
|
97
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,SAAS,aAAa,CAAC;AAEpC;;;;GAIG;AACH,eAAO,MAAM,2BAA2B;;;;;;;;;CAG9B,CAAC;AAEX;;;GAGG;AACH,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kCAkBnB,CAAC;AAEH,OAAO,EACL,2BAA2B,EAC3B,KAAK,6BAA6B,EAClC,KAAK,iBAAiB,EACtB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,EACxB,KAAK,cAAc,GACpB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9E,OAAO,EACL,UAAU,EACV,KAAK,6BAA6B,EAClC,KAAK,oBAAoB,EACzB,KAAK,yBAAyB,EAC9B,KAAK,wBAAwB,GAC9B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACvG,OAAO,EACL,0BAA0B,EAC1B,sBAAsB,EACtB,eAAe,EACf,UAAU,EACV,YAAY,EACZ,KAAK,YAAY,EACjB,KAAK,uBAAuB,EAC5B,KAAK,eAAe,EACpB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAClB,KAAK,kBAAkB,GACxB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Public API of @softure-ai/waitlist: the module factory for softure.config.ts, its rate limit
|
|
2
|
+
// buckets, types, messages and the sign-ups table. Joining, confirming, the mails and reading
|
|
3
|
+
// sign-ups are in `@softure-ai/waitlist/server`, the Next.js adapter (actions, `Waitlist` component,
|
|
4
|
+
// confirmation page) in `/next`, the form in `/ui`.
|
|
5
|
+
import { defineModule, resolveMigrationsDir } from "@softure-ai/core";
|
|
6
|
+
import { waitlistMessages } from "./messages/index.js";
|
|
7
|
+
import { waitlistOptionsSchema } from "./options.js";
|
|
8
|
+
import { checkSignupsTable } from "./server/health.js";
|
|
9
|
+
import { waitlistPrivacyContributor } from "./server/privacy.js";
|
|
10
|
+
export const MODULE_ID = "waitlist";
|
|
11
|
+
/**
|
|
12
|
+
* Rate limit buckets the waitlist consumes, with defaults to spread into `security({ buckets })`:
|
|
13
|
+
* `waitlist` per client address and `waitlist-email` per signed-up address (a form sent for
|
|
14
|
+
* someone else's address many times).
|
|
15
|
+
*/
|
|
16
|
+
export const WAITLIST_RATE_LIMIT_BUCKETS = {
|
|
17
|
+
waitlist: { limit: 10, windowMinutes: 15 },
|
|
18
|
+
"waitlist-email": { limit: 3, windowMinutes: 60 },
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Enables the waitlist in `softure.config.ts` (after `security`, `mailing` and `privacy`):
|
|
22
|
+
* `waitlist({ scopes: [{ id: "launch", required: true, document: "privacy-policy", label: { en: "Tell me when it opens." } }], placements: ["hero", "footer"] })`.
|
|
23
|
+
*/
|
|
24
|
+
export const waitlist = defineModule({
|
|
25
|
+
manifest: {
|
|
26
|
+
id: MODULE_ID,
|
|
27
|
+
version: "0.1.5",
|
|
28
|
+
dependsOn: { security: "^0.1.0", mailing: "^0.1.0", privacy: "^0.1.0" },
|
|
29
|
+
dbSchema: "waitlist",
|
|
30
|
+
tables: ["signups"],
|
|
31
|
+
env: [],
|
|
32
|
+
switches: [],
|
|
33
|
+
routes: { confirm: "/waitlist/confirm" },
|
|
34
|
+
mount: [{ kind: "page", path: "app/waitlist/confirm/page.tsx", export: "ConfirmSignupPage" }],
|
|
35
|
+
privacy: { exports: true, deletes: true },
|
|
36
|
+
},
|
|
37
|
+
messages: waitlistMessages,
|
|
38
|
+
options: waitlistOptionsSchema,
|
|
39
|
+
migrations: { dir: resolveMigrationsDir(import.meta.url, "../migrations/") },
|
|
40
|
+
privacy: waitlistPrivacyContributor,
|
|
41
|
+
health: checkSignupsTable,
|
|
42
|
+
});
|
|
43
|
+
export { INITIAL_WAITLIST_FORM_STATE, } from "./contract.js";
|
|
44
|
+
export { EMAIL_FIELD, getScopeFieldName, PLACEMENT_FIELD } from "./fields.js";
|
|
45
|
+
export { escapeHtml, } from "./mail-template.js";
|
|
46
|
+
export { getWaitlistErrorMessage, waitlistMessages } from "./messages/index.js";
|
|
47
|
+
export { DEFAULT_CONFIRMATION_HOURS, MAX_CONFIRMATION_HOURS, MAX_NAME_LENGTH, MAX_SCOPES, NAME_PATTERN, } from "./options.js";
|
|
48
|
+
export { signups, waitlistSchema } from "./schema.js";
|
|
49
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+FAA+F;AAC/F,8FAA8F;AAC9F,qGAAqG;AACrG,oDAAoD;AACpD,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAEjE,MAAM,CAAC,MAAM,SAAS,GAAG,UAAU,CAAC;AAEpC;;;;GAIG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG;IACzC,QAAQ,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,aAAa,EAAE,EAAE,EAAE;IAC1C,gBAAgB,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,aAAa,EAAE,EAAE,EAAE;CACzC,CAAC;AAEX;;;GAGG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,YAAY,CAAC;IACnC,QAAQ,EAAE;QACR,EAAE,EAAE,SAAS;QACb,OAAO,EAAE,OAAO;QAChB,SAAS,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE;QACvE,QAAQ,EAAE,UAAU;QACpB,MAAM,EAAE,CAAC,SAAS,CAAC;QACnB,GAAG,EAAE,EAAE;QACP,QAAQ,EAAE,EAAE;QACZ,MAAM,EAAE,EAAE,OAAO,EAAE,mBAAmB,EAAE;QACxC,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,+BAA+B,EAAE,MAAM,EAAE,mBAAmB,EAAE,CAAC;QAC7F,OAAO,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE;KAC1C;IACD,QAAQ,EAAE,gBAAgB;IAC1B,OAAO,EAAE,qBAAqB;IAC9B,UAAU,EAAE,EAAE,GAAG,EAAE,oBAAoB,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,gBAAgB,CAAC,EAAE;IAC5E,OAAO,EAAE,0BAA0B;IACnC,MAAM,EAAE,iBAAiB;CAC1B,CAAC,CAAC;AAEH,OAAO,EACL,2BAA2B,GAQ5B,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9E,OAAO,EACL,UAAU,GAKX,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,uBAAuB,EAAE,gBAAgB,EAAyB,MAAM,qBAAqB,CAAC;AACvG,OAAO,EACL,0BAA0B,EAC1B,sBAAsB,EACtB,eAAe,EACf,UAAU,EACV,YAAY,GAOb,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Locale } from "@softure-ai/core";
|
|
2
|
+
interface MailTemplateInputBase {
|
|
3
|
+
/** The language of the copy: the sign-up's locale. */
|
|
4
|
+
readonly locale: Locale;
|
|
5
|
+
readonly subject: string;
|
|
6
|
+
/** The copy's paragraphs as plain text (not escaped; use `escapeHtml` before putting them in HTML). */
|
|
7
|
+
readonly paragraphs: readonly string[];
|
|
8
|
+
/** The module's default HTML body: the paragraphs escaped and, with a link, its anchor. */
|
|
9
|
+
readonly body: string;
|
|
10
|
+
}
|
|
11
|
+
/** The welcome mail, list mail: mailing appends its unsubscribe footer to the HTML returned. */
|
|
12
|
+
export interface WelcomeMailTemplateInput extends MailTemplateInputBase {
|
|
13
|
+
readonly kind: "welcome";
|
|
14
|
+
}
|
|
15
|
+
/** The confirmation mail of double opt-in: `action` is the single-use link and its label. */
|
|
16
|
+
export interface ConfirmationMailTemplateInput extends MailTemplateInputBase {
|
|
17
|
+
readonly kind: "confirmation";
|
|
18
|
+
readonly action: {
|
|
19
|
+
readonly href: string;
|
|
20
|
+
readonly label: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export type WaitlistMailTemplateInput = WelcomeMailTemplateInput | ConfirmationMailTemplateInput;
|
|
24
|
+
/**
|
|
25
|
+
* Renders the HTML body of a waitlist mail, e.g. `({ body }) => brandLayout(body)`. It must return
|
|
26
|
+
* non-blank HTML; a template that throws or returns nothing is a bug and the mail is not sent.
|
|
27
|
+
*/
|
|
28
|
+
export type WaitlistMailTemplate = (mail: WaitlistMailTemplateInput) => string;
|
|
29
|
+
/** `text` safe inside HTML text and attribute values. */
|
|
30
|
+
export declare function escapeHtml(text: string): string;
|
|
31
|
+
/** The paragraphs of a copy text: blocks separated by a blank line, trimmed, empty ones dropped. */
|
|
32
|
+
export declare function splitParagraphs(text: string): string[];
|
|
33
|
+
/** The default body: each paragraph escaped in `<p>` (a line break as `<br>`), then the link's anchor. */
|
|
34
|
+
export declare function renderDefaultMailBody(paragraphs: readonly string[], action: ConfirmationMailTemplateInput["action"] | null): string;
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=mail-template.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mail-template.d.ts","sourceRoot":"","sources":["../src/mail-template.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE/C,UAAU,qBAAqB;IAC7B,sDAAsD;IACtD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uGAAuG;IACvG,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IACvC,2FAA2F;IAC3F,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,gGAAgG;AAChG,MAAM,WAAW,wBAAyB,SAAQ,qBAAqB;IACrE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;CAC1B;AAED,6FAA6F;AAC7F,MAAM,WAAW,6BAA8B,SAAQ,qBAAqB;IAC1E,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CACpE;AAED,MAAM,MAAM,yBAAyB,GAAG,wBAAwB,GAAG,6BAA6B,CAAC;AAEjG;;;GAGG;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,IAAI,EAAE,yBAAyB,KAAK,MAAM,CAAC;AAI/E,yDAAyD;AACzD,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED,oGAAoG;AACpG,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAKtD;AAED,0GAA0G;AAC1G,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,EAAE,MAAM,EAAE,6BAA6B,CAAC,QAAQ,CAAC,GAAG,IAAI,GAAG,MAAM,CAInI"}
|