@vit-foundation/ui 0.1.0 → 0.2.1
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/dist/components/account/AccountPanel.svelte +224 -0
- package/dist/components/account/AccountPanel.svelte.d.ts +37 -0
- package/dist/components/account/NewsletterSignup.svelte +91 -0
- package/dist/components/account/NewsletterSignup.svelte.d.ts +13 -0
- package/dist/components/account/index.d.ts +3 -0
- package/dist/components/account/index.js +2 -0
- package/dist/components/auth/AuthPageShell.svelte +108 -0
- package/dist/components/auth/AuthPageShell.svelte.d.ts +27 -0
- package/dist/components/auth/GoogleAuthForm.svelte +72 -0
- package/dist/components/auth/GoogleAuthForm.svelte.d.ts +26 -0
- package/dist/components/auth/LoginForm.svelte +176 -0
- package/dist/components/auth/LoginForm.svelte.d.ts +37 -0
- package/dist/components/auth/SignupForm.svelte +157 -0
- package/dist/components/auth/SignupForm.svelte.d.ts +27 -0
- package/dist/components/auth/index.d.ts +7 -0
- package/dist/components/auth/index.js +4 -0
- package/dist/components/contact/ContactForm.svelte +148 -0
- package/dist/components/contact/ContactForm.svelte.d.ts +27 -0
- package/dist/components/contact/index.d.ts +2 -0
- package/dist/components/contact/index.js +1 -0
- package/dist/components/ui/Honeypot.svelte +44 -0
- package/dist/components/ui/Honeypot.svelte.d.ts +24 -0
- package/dist/components/ui/LocaleField.svelte +21 -0
- package/dist/components/ui/LocaleField.svelte.d.ts +16 -0
- package/dist/components/ui/NewsletterIntentField.svelte +31 -0
- package/dist/components/ui/NewsletterIntentField.svelte.d.ts +28 -0
- package/dist/components/ui/index.d.ts +3 -0
- package/dist/components/ui/index.js +3 -0
- package/dist/components/weeklies/CommentSection.svelte +229 -0
- package/dist/components/weeklies/CommentSection.svelte.d.ts +30 -0
- package/dist/components/weeklies/ReactionBar.svelte +170 -0
- package/dist/components/weeklies/ReactionBar.svelte.d.ts +31 -0
- package/dist/components/weeklies/index.d.ts +4 -0
- package/dist/components/weeklies/index.js +2 -0
- package/dist/config/messages.js +68 -0
- package/dist/config/types.d.ts +70 -0
- package/dist/content/index.d.ts +2 -1
- package/dist/content/index.js +1 -0
- package/dist/content/types.d.ts +34 -0
- package/dist/content/types.js +4 -1
- package/dist/forms/constraints.d.ts +35 -0
- package/dist/forms/constraints.js +19 -0
- package/dist/forms/index.d.ts +3 -0
- package/dist/forms/index.js +2 -0
- package/dist/forms/transport.d.ts +26 -0
- package/dist/forms/transport.js +32 -0
- package/dist/forms/types.d.ts +54 -0
- package/dist/forms/types.js +13 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/styles/base.css +41 -0
- package/dist/testing/remote-form.d.ts +45 -0
- package/dist/testing/remote-form.js +85 -0
- package/dist/utils/contact.d.ts +4 -0
- package/dist/utils/contact.js +15 -0
- package/package.json +28 -4
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { FormResultOf, RemoteField, RemoteFormInstance } from '../../forms/types.js';
|
|
3
|
+
|
|
4
|
+
/** The preflighted password-login form the host page passes in. */
|
|
5
|
+
export type LoginFormInstance = RemoteFormInstance<
|
|
6
|
+
{ email: RemoteField; password: RemoteField; newsletter: RemoteField; locale: RemoteField },
|
|
7
|
+
FormResultOf<object, 'invalidCredentials'>
|
|
8
|
+
>;
|
|
9
|
+
|
|
10
|
+
/** The preflighted magic-link form. */
|
|
11
|
+
export type MagicLinkFormInstance = RemoteFormInstance<{
|
|
12
|
+
email: RemoteField;
|
|
13
|
+
locale: RemoteField;
|
|
14
|
+
website: RemoteField;
|
|
15
|
+
}>;
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script lang="ts">
|
|
19
|
+
import { getUiConfig } from '../../config/context.js';
|
|
20
|
+
import { EMAIL, LOGIN_PASSWORD } from '../../forms/constraints.js';
|
|
21
|
+
import Button from '../ui/Button.svelte';
|
|
22
|
+
import Field from '../ui/Field.svelte';
|
|
23
|
+
import FormErrorFeedback from '../ui/FormErrorFeedback.svelte';
|
|
24
|
+
import FormFeedback from '../ui/FormFeedback.svelte';
|
|
25
|
+
import Honeypot from '../ui/Honeypot.svelte';
|
|
26
|
+
import LocaleField from '../ui/LocaleField.svelte';
|
|
27
|
+
import NewsletterIntentField from '../ui/NewsletterIntentField.svelte';
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
/**
|
|
31
|
+
* One finished sentence for a bounced auth landing, or null when the
|
|
32
|
+
* visitor arrived normally. The host app owns the closed set of landing
|
|
33
|
+
* failures and the copy for each; this component only renders the one
|
|
34
|
+
* that applies.
|
|
35
|
+
*/
|
|
36
|
+
redirectErrorMessage?: string | null;
|
|
37
|
+
/** True when arriving from the newsletter band (?newsletter=1). */
|
|
38
|
+
newsletterIntent?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The remote forms, already preflighted against the host app's schemas
|
|
41
|
+
* — validation belongs to the app's server contract, so the call site
|
|
42
|
+
* runs `form.preflight(schema)` and hands the result in. Progressive
|
|
43
|
+
* enhancement is unchanged: without JavaScript each form posts natively
|
|
44
|
+
* and the page re-renders with the result.
|
|
45
|
+
*/
|
|
46
|
+
loginForm: LoginFormInstance;
|
|
47
|
+
magicLinkForm: MagicLinkFormInstance;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let {
|
|
51
|
+
redirectErrorMessage = null,
|
|
52
|
+
newsletterIntent = false,
|
|
53
|
+
loginForm,
|
|
54
|
+
magicLinkForm
|
|
55
|
+
}: Props = $props();
|
|
56
|
+
|
|
57
|
+
const config = getUiConfig();
|
|
58
|
+
const msg = $derived(config.messages);
|
|
59
|
+
const login = $derived(loginForm);
|
|
60
|
+
const magic = $derived(magicLinkForm);
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<div class="login">
|
|
64
|
+
<form class="form-stack" {...login}>
|
|
65
|
+
<Field
|
|
66
|
+
id="login-email"
|
|
67
|
+
label={msg.login_emailLabel()}
|
|
68
|
+
field={login.fields.email}
|
|
69
|
+
constraint={EMAIL}
|
|
70
|
+
>
|
|
71
|
+
{#snippet children(attrs)}
|
|
72
|
+
<input
|
|
73
|
+
class="control"
|
|
74
|
+
required
|
|
75
|
+
autocomplete="email"
|
|
76
|
+
{...attrs}
|
|
77
|
+
{...login.fields.email.as('email')}
|
|
78
|
+
/>
|
|
79
|
+
{/snippet}
|
|
80
|
+
</Field>
|
|
81
|
+
|
|
82
|
+
<Field
|
|
83
|
+
id="login-password"
|
|
84
|
+
label={msg.login_passwordLabel()}
|
|
85
|
+
field={login.fields.password}
|
|
86
|
+
constraint={LOGIN_PASSWORD}
|
|
87
|
+
>
|
|
88
|
+
{#snippet children(attrs)}
|
|
89
|
+
<input
|
|
90
|
+
class="control"
|
|
91
|
+
required
|
|
92
|
+
autocomplete="current-password"
|
|
93
|
+
{...attrs}
|
|
94
|
+
{...login.fields.password.as('password')}
|
|
95
|
+
/>
|
|
96
|
+
{/snippet}
|
|
97
|
+
</Field>
|
|
98
|
+
|
|
99
|
+
{#if newsletterIntent}
|
|
100
|
+
<NewsletterIntentField field={login.fields.newsletter} />
|
|
101
|
+
{/if}
|
|
102
|
+
<LocaleField field={login.fields.locale} />
|
|
103
|
+
|
|
104
|
+
<div class="actions">
|
|
105
|
+
<Button type="submit" pending={login.pending}>{msg.login_submit()}</Button>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{#if login.result && !login.result.ok}
|
|
109
|
+
<FormErrorFeedback
|
|
110
|
+
result={login.result}
|
|
111
|
+
messages={{ invalidCredentials: msg.login_error_invalidCredentials() }}
|
|
112
|
+
/>
|
|
113
|
+
{:else if redirectErrorMessage}
|
|
114
|
+
<!-- One sentence per landing, mapped by the host app off its closed
|
|
115
|
+
set of landing failures — a new code fails the build there, not in
|
|
116
|
+
an else arm here. Neither sentence reveals whether an address has
|
|
117
|
+
an account: both are reachable only with a token in hand and
|
|
118
|
+
describe the token's fate, not the address's. -->
|
|
119
|
+
<FormFeedback kind="error">{redirectErrorMessage}</FormFeedback>
|
|
120
|
+
{/if}
|
|
121
|
+
</form>
|
|
122
|
+
|
|
123
|
+
<p class="divider" role="separator">{msg.login_divider()}</p>
|
|
124
|
+
|
|
125
|
+
{#if magic.result?.ok}
|
|
126
|
+
<FormFeedback kind="success">{msg.login_magicLinkSuccess()}</FormFeedback>
|
|
127
|
+
{:else}
|
|
128
|
+
<form class="form-stack" {...magic}>
|
|
129
|
+
<Field
|
|
130
|
+
id="magic-email"
|
|
131
|
+
label={msg.login_emailLabel()}
|
|
132
|
+
field={magic.fields.email}
|
|
133
|
+
constraint={EMAIL}
|
|
134
|
+
>
|
|
135
|
+
{#snippet children(attrs)}
|
|
136
|
+
<input
|
|
137
|
+
class="control"
|
|
138
|
+
required
|
|
139
|
+
autocomplete="email"
|
|
140
|
+
{...attrs}
|
|
141
|
+
{...magic.fields.email.as('email')}
|
|
142
|
+
/>
|
|
143
|
+
{/snippet}
|
|
144
|
+
</Field>
|
|
145
|
+
|
|
146
|
+
<!-- Localizes the /auth/confirm landing via the roundtrip cookie. -->
|
|
147
|
+
<LocaleField field={magic.fields.locale} />
|
|
148
|
+
|
|
149
|
+
<!-- Honeypot: invisible to humans, tempting to bots. This form mails an
|
|
150
|
+
attacker-chosen address unauthenticated, and a tripped trap returns
|
|
151
|
+
the same value a real request does — so the success branch above
|
|
152
|
+
renders identically either way. The password form above carries no
|
|
153
|
+
trap: its success is a redirect, so there is no value to fake. -->
|
|
154
|
+
<Honeypot form={magic.fields} id="magic-website" />
|
|
155
|
+
|
|
156
|
+
<div class="actions">
|
|
157
|
+
<Button type="submit" pending={magic.pending}>{msg.login_magicLinkSubmit()}</Button>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<FormErrorFeedback result={magic.result} />
|
|
161
|
+
</form>
|
|
162
|
+
{/if}
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<style>
|
|
166
|
+
.login {
|
|
167
|
+
display: flex;
|
|
168
|
+
flex-direction: column;
|
|
169
|
+
gap: var(--space-3);
|
|
170
|
+
max-width: 24rem;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
input {
|
|
174
|
+
padding: var(--space-2);
|
|
175
|
+
}
|
|
176
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { FormResultOf, RemoteField, RemoteFormInstance } from '../../forms/types.js';
|
|
2
|
+
/** The preflighted password-login form the host page passes in. */
|
|
3
|
+
export type LoginFormInstance = RemoteFormInstance<{
|
|
4
|
+
email: RemoteField;
|
|
5
|
+
password: RemoteField;
|
|
6
|
+
newsletter: RemoteField;
|
|
7
|
+
locale: RemoteField;
|
|
8
|
+
}, FormResultOf<object, 'invalidCredentials'>>;
|
|
9
|
+
/** The preflighted magic-link form. */
|
|
10
|
+
export type MagicLinkFormInstance = RemoteFormInstance<{
|
|
11
|
+
email: RemoteField;
|
|
12
|
+
locale: RemoteField;
|
|
13
|
+
website: RemoteField;
|
|
14
|
+
}>;
|
|
15
|
+
interface Props {
|
|
16
|
+
/**
|
|
17
|
+
* One finished sentence for a bounced auth landing, or null when the
|
|
18
|
+
* visitor arrived normally. The host app owns the closed set of landing
|
|
19
|
+
* failures and the copy for each; this component only renders the one
|
|
20
|
+
* that applies.
|
|
21
|
+
*/
|
|
22
|
+
redirectErrorMessage?: string | null;
|
|
23
|
+
/** True when arriving from the newsletter band (?newsletter=1). */
|
|
24
|
+
newsletterIntent?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The remote forms, already preflighted against the host app's schemas
|
|
27
|
+
* — validation belongs to the app's server contract, so the call site
|
|
28
|
+
* runs `form.preflight(schema)` and hands the result in. Progressive
|
|
29
|
+
* enhancement is unchanged: without JavaScript each form posts natively
|
|
30
|
+
* and the page re-renders with the result.
|
|
31
|
+
*/
|
|
32
|
+
loginForm: LoginFormInstance;
|
|
33
|
+
magicLinkForm: MagicLinkFormInstance;
|
|
34
|
+
}
|
|
35
|
+
declare const LoginForm: import("svelte").Component<Props, {}, "">;
|
|
36
|
+
type LoginForm = ReturnType<typeof LoginForm>;
|
|
37
|
+
export default LoginForm;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { RemoteField, RemoteFormInstance } from '../../forms/types.js';
|
|
3
|
+
|
|
4
|
+
/** The preflighted signup form the host page passes in. */
|
|
5
|
+
export type SignupFormInstance = RemoteFormInstance<{
|
|
6
|
+
displayName: RemoteField;
|
|
7
|
+
email: RemoteField;
|
|
8
|
+
password: RemoteField;
|
|
9
|
+
terms: RemoteField;
|
|
10
|
+
newsletter: RemoteField;
|
|
11
|
+
locale: RemoteField;
|
|
12
|
+
website: RemoteField;
|
|
13
|
+
}>;
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<script lang="ts">
|
|
17
|
+
import { getUiConfig } from '../../config/context.js';
|
|
18
|
+
import { DISPLAY_NAME, EMAIL, PASSWORD } from '../../forms/constraints.js';
|
|
19
|
+
import Button from '../ui/Button.svelte';
|
|
20
|
+
import Field from '../ui/Field.svelte';
|
|
21
|
+
import FormErrorFeedback from '../ui/FormErrorFeedback.svelte';
|
|
22
|
+
import FormFeedback from '../ui/FormFeedback.svelte';
|
|
23
|
+
import Honeypot from '../ui/Honeypot.svelte';
|
|
24
|
+
import Link from '../ui/Link.svelte';
|
|
25
|
+
import LocaleField from '../ui/LocaleField.svelte';
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
/** True when arriving from the newsletter band (?newsletter=1). */
|
|
29
|
+
newsletterIntent?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Callbacks for the single consent block's live state, so the page can
|
|
32
|
+
* mirror it into the Google flow — one set of checkboxes for both
|
|
33
|
+
* signup methods (callback props per the foundation's convention).
|
|
34
|
+
*/
|
|
35
|
+
onTermsChange?: (accepted: boolean) => void;
|
|
36
|
+
onNewsletterChange?: (checked: boolean) => void;
|
|
37
|
+
/** The remote form, preflighted by the host page against its schema. */
|
|
38
|
+
signupForm: SignupFormInstance;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let { newsletterIntent = false, onTermsChange, onNewsletterChange, signupForm }: Props = $props();
|
|
42
|
+
|
|
43
|
+
const config = getUiConfig();
|
|
44
|
+
const msg = $derived(config.messages);
|
|
45
|
+
const f = $derived(signupForm);
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
{#if f.result?.ok}
|
|
49
|
+
<FormFeedback kind="success">{msg.signup_success()}</FormFeedback>
|
|
50
|
+
{:else}
|
|
51
|
+
<form class="form-stack" {...f}>
|
|
52
|
+
<Field
|
|
53
|
+
id="signup-name"
|
|
54
|
+
label={msg.signup_nameLabel()}
|
|
55
|
+
field={f.fields.displayName}
|
|
56
|
+
constraint={DISPLAY_NAME}
|
|
57
|
+
>
|
|
58
|
+
{#snippet children(attrs)}
|
|
59
|
+
<input
|
|
60
|
+
class="control"
|
|
61
|
+
required
|
|
62
|
+
autocomplete="nickname"
|
|
63
|
+
{...attrs}
|
|
64
|
+
{...f.fields.displayName.as('text')}
|
|
65
|
+
/>
|
|
66
|
+
{/snippet}
|
|
67
|
+
</Field>
|
|
68
|
+
|
|
69
|
+
<Field
|
|
70
|
+
id="signup-email"
|
|
71
|
+
label={msg.login_emailLabel()}
|
|
72
|
+
field={f.fields.email}
|
|
73
|
+
constraint={EMAIL}
|
|
74
|
+
>
|
|
75
|
+
{#snippet children(attrs)}
|
|
76
|
+
<input
|
|
77
|
+
class="control"
|
|
78
|
+
required
|
|
79
|
+
autocomplete="email"
|
|
80
|
+
{...attrs}
|
|
81
|
+
{...f.fields.email.as('email')}
|
|
82
|
+
/>
|
|
83
|
+
{/snippet}
|
|
84
|
+
</Field>
|
|
85
|
+
|
|
86
|
+
<Field
|
|
87
|
+
id="signup-password"
|
|
88
|
+
label={msg.signup_passwordLabel()}
|
|
89
|
+
field={f.fields.password}
|
|
90
|
+
constraint={PASSWORD}
|
|
91
|
+
>
|
|
92
|
+
{#snippet children(attrs)}
|
|
93
|
+
<input
|
|
94
|
+
class="control"
|
|
95
|
+
required
|
|
96
|
+
autocomplete="new-password"
|
|
97
|
+
{...attrs}
|
|
98
|
+
{...f.fields.password.as('password')}
|
|
99
|
+
/>
|
|
100
|
+
{/snippet}
|
|
101
|
+
</Field>
|
|
102
|
+
|
|
103
|
+
<label class="checkbox">
|
|
104
|
+
<input
|
|
105
|
+
required
|
|
106
|
+
onchange={(event) => onTermsChange?.(event.currentTarget.checked)}
|
|
107
|
+
{...f.fields.terms.as('checkbox')}
|
|
108
|
+
/>
|
|
109
|
+
<span>
|
|
110
|
+
{msg.signup_termsPre()}
|
|
111
|
+
<Link href="/legal">{msg.signup_termsLink()}</Link>
|
|
112
|
+
</span>
|
|
113
|
+
</label>
|
|
114
|
+
|
|
115
|
+
<label class="checkbox">
|
|
116
|
+
<input
|
|
117
|
+
onchange={(event) => onNewsletterChange?.(event.currentTarget.checked)}
|
|
118
|
+
{...f.fields.newsletter.as('checkbox', newsletterIntent)}
|
|
119
|
+
/>
|
|
120
|
+
<span>{msg.signup_newsletterLabel()}</span>
|
|
121
|
+
</label>
|
|
122
|
+
|
|
123
|
+
<!-- Localizes the /auth/confirm landing via the roundtrip cookie. -->
|
|
124
|
+
<LocaleField field={f.fields.locale} />
|
|
125
|
+
|
|
126
|
+
<!-- Honeypot: invisible to humans, tempting to bots. -->
|
|
127
|
+
<Honeypot form={f.fields} id="signup-website" />
|
|
128
|
+
|
|
129
|
+
<div class="actions">
|
|
130
|
+
<Button type="submit" pending={f.pending}>{msg.signup_submit()}</Button>
|
|
131
|
+
</div>
|
|
132
|
+
<FormErrorFeedback result={f.result} />
|
|
133
|
+
</form>
|
|
134
|
+
{/if}
|
|
135
|
+
|
|
136
|
+
<style>
|
|
137
|
+
form {
|
|
138
|
+
max-width: 24rem;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
input {
|
|
142
|
+
padding: var(--space-2);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.checkbox {
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: baseline;
|
|
148
|
+
gap: var(--space-2);
|
|
149
|
+
font-weight: 400;
|
|
150
|
+
font-size: var(--text-sm);
|
|
151
|
+
color: var(--color-ink-secondary);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.checkbox input {
|
|
155
|
+
padding: 0;
|
|
156
|
+
}
|
|
157
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RemoteField, RemoteFormInstance } from '../../forms/types.js';
|
|
2
|
+
/** The preflighted signup form the host page passes in. */
|
|
3
|
+
export type SignupFormInstance = RemoteFormInstance<{
|
|
4
|
+
displayName: RemoteField;
|
|
5
|
+
email: RemoteField;
|
|
6
|
+
password: RemoteField;
|
|
7
|
+
terms: RemoteField;
|
|
8
|
+
newsletter: RemoteField;
|
|
9
|
+
locale: RemoteField;
|
|
10
|
+
website: RemoteField;
|
|
11
|
+
}>;
|
|
12
|
+
interface Props {
|
|
13
|
+
/** True when arriving from the newsletter band (?newsletter=1). */
|
|
14
|
+
newsletterIntent?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Callbacks for the single consent block's live state, so the page can
|
|
17
|
+
* mirror it into the Google flow — one set of checkboxes for both
|
|
18
|
+
* signup methods (callback props per the foundation's convention).
|
|
19
|
+
*/
|
|
20
|
+
onTermsChange?: (accepted: boolean) => void;
|
|
21
|
+
onNewsletterChange?: (checked: boolean) => void;
|
|
22
|
+
/** The remote form, preflighted by the host page against its schema. */
|
|
23
|
+
signupForm: SignupFormInstance;
|
|
24
|
+
}
|
|
25
|
+
declare const SignupForm: import("svelte").Component<Props, {}, "">;
|
|
26
|
+
type SignupForm = ReturnType<typeof SignupForm>;
|
|
27
|
+
export default SignupForm;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { default as AuthPageShell } from './AuthPageShell.svelte';
|
|
2
|
+
export { default as GoogleAuthForm } from './GoogleAuthForm.svelte';
|
|
3
|
+
export type { GoogleLoginFormInstance } from './GoogleAuthForm.svelte';
|
|
4
|
+
export { default as LoginForm } from './LoginForm.svelte';
|
|
5
|
+
export type { LoginFormInstance, MagicLinkFormInstance } from './LoginForm.svelte';
|
|
6
|
+
export { default as SignupForm } from './SignupForm.svelte';
|
|
7
|
+
export type { SignupFormInstance } from './SignupForm.svelte';
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { FormResultOf, RemoteField, RemoteFormInstance } from '../../forms/types.js';
|
|
3
|
+
|
|
4
|
+
/** The preflighted contact form the host page passes in. */
|
|
5
|
+
export type ContactFormInstance = RemoteFormInstance<
|
|
6
|
+
{
|
|
7
|
+
category: RemoteField;
|
|
8
|
+
name: RemoteField;
|
|
9
|
+
email: RemoteField;
|
|
10
|
+
message: RemoteField;
|
|
11
|
+
locale: RemoteField;
|
|
12
|
+
website: RemoteField;
|
|
13
|
+
},
|
|
14
|
+
FormResultOf
|
|
15
|
+
> & {
|
|
16
|
+
fields: { allIssues(): Array<{ message: string }> | undefined };
|
|
17
|
+
};
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<script lang="ts">
|
|
21
|
+
import { getUiConfig } from '../../config/context.js';
|
|
22
|
+
import { CONTACT_MESSAGE, CONTACT_NAME, EMAIL } from '../../forms/constraints.js';
|
|
23
|
+
import { CONTACT_CATEGORIES } from '../../content/types.js';
|
|
24
|
+
import { contactCategoryLabel } from '../../utils/contact.js';
|
|
25
|
+
import Button from '../ui/Button.svelte';
|
|
26
|
+
import Field from '../ui/Field.svelte';
|
|
27
|
+
import FormErrorFeedback from '../ui/FormErrorFeedback.svelte';
|
|
28
|
+
import FormFeedback from '../ui/FormFeedback.svelte';
|
|
29
|
+
import Honeypot from '../ui/Honeypot.svelte';
|
|
30
|
+
import LocaleField from '../ui/LocaleField.svelte';
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
/**
|
|
34
|
+
* The remote form, preflighted by the host page against its schema.
|
|
35
|
+
* Progressive enhancement is unchanged: without JavaScript the form
|
|
36
|
+
* posts natively and the page re-renders with the result.
|
|
37
|
+
*/
|
|
38
|
+
form: ContactFormInstance;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let { form }: Props = $props();
|
|
42
|
+
|
|
43
|
+
const config = getUiConfig();
|
|
44
|
+
const msg = $derived(config.messages);
|
|
45
|
+
const f = $derived(form);
|
|
46
|
+
|
|
47
|
+
// Derived, not restated: the package owns which categories exist and in
|
|
48
|
+
// what order (the host schema derives its enum from CONTACT_CATEGORIES),
|
|
49
|
+
// and the messages own the copy for each.
|
|
50
|
+
const categories = CONTACT_CATEGORIES;
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
{#if f.result?.ok}
|
|
54
|
+
<FormFeedback kind="success">{msg.contact_success()}</FormFeedback>
|
|
55
|
+
{:else}
|
|
56
|
+
<form class="form-stack" {...f}>
|
|
57
|
+
<Field
|
|
58
|
+
id="contact-category"
|
|
59
|
+
label={msg.contact_categoryLabel()}
|
|
60
|
+
field={f.fields.category}
|
|
61
|
+
constraint={null}
|
|
62
|
+
>
|
|
63
|
+
{#snippet children(attrs)}
|
|
64
|
+
<select class="control" {...attrs} {...f.fields.category.as('select')}>
|
|
65
|
+
{#each categories as category (category)}
|
|
66
|
+
<option value={category}>{contactCategoryLabel(category, msg)}</option>
|
|
67
|
+
{/each}
|
|
68
|
+
</select>
|
|
69
|
+
{/snippet}
|
|
70
|
+
</Field>
|
|
71
|
+
|
|
72
|
+
<Field
|
|
73
|
+
id="contact-name"
|
|
74
|
+
label={msg.contact_nameLabel()}
|
|
75
|
+
field={f.fields.name}
|
|
76
|
+
constraint={CONTACT_NAME}
|
|
77
|
+
>
|
|
78
|
+
{#snippet children(attrs)}
|
|
79
|
+
<input
|
|
80
|
+
class="control"
|
|
81
|
+
required
|
|
82
|
+
autocomplete="name"
|
|
83
|
+
{...attrs}
|
|
84
|
+
{...f.fields.name.as('text')}
|
|
85
|
+
/>
|
|
86
|
+
{/snippet}
|
|
87
|
+
</Field>
|
|
88
|
+
|
|
89
|
+
<Field
|
|
90
|
+
id="contact-email"
|
|
91
|
+
label={msg.contact_emailLabel()}
|
|
92
|
+
field={f.fields.email}
|
|
93
|
+
constraint={EMAIL}
|
|
94
|
+
>
|
|
95
|
+
{#snippet children(attrs)}
|
|
96
|
+
<input
|
|
97
|
+
class="control"
|
|
98
|
+
required
|
|
99
|
+
autocomplete="email"
|
|
100
|
+
{...attrs}
|
|
101
|
+
{...f.fields.email.as('email')}
|
|
102
|
+
/>
|
|
103
|
+
{/snippet}
|
|
104
|
+
</Field>
|
|
105
|
+
|
|
106
|
+
<Field
|
|
107
|
+
id="contact-message"
|
|
108
|
+
label={msg.contact_messageLabel()}
|
|
109
|
+
field={f.fields.message}
|
|
110
|
+
constraint={CONTACT_MESSAGE}
|
|
111
|
+
>
|
|
112
|
+
{#snippet children(attrs)}
|
|
113
|
+
<textarea class="control" required rows="6" {...attrs} {...f.fields.message.as('text')}
|
|
114
|
+
></textarea>
|
|
115
|
+
{/snippet}
|
|
116
|
+
</Field>
|
|
117
|
+
|
|
118
|
+
<LocaleField field={f.fields.locale} />
|
|
119
|
+
|
|
120
|
+
<Honeypot form={f.fields} id="contact-website" />
|
|
121
|
+
|
|
122
|
+
<div class="actions">
|
|
123
|
+
<Button type="submit" pending={f.pending}>{msg.contact_submit()}</Button>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{#if f.result && !f.result.ok}
|
|
127
|
+
<FormErrorFeedback result={f.result} />
|
|
128
|
+
{:else if f.fields.allIssues()?.length}
|
|
129
|
+
<FormFeedback kind="error">{msg.contact_invalid()}</FormFeedback>
|
|
130
|
+
{/if}
|
|
131
|
+
</form>
|
|
132
|
+
{/if}
|
|
133
|
+
|
|
134
|
+
<style>
|
|
135
|
+
form {
|
|
136
|
+
max-width: 32rem;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
input,
|
|
140
|
+
select,
|
|
141
|
+
textarea {
|
|
142
|
+
padding: var(--space-2);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
textarea {
|
|
146
|
+
resize: vertical;
|
|
147
|
+
}
|
|
148
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { FormResultOf, RemoteField, RemoteFormInstance } from '../../forms/types.js';
|
|
2
|
+
/** The preflighted contact form the host page passes in. */
|
|
3
|
+
export type ContactFormInstance = RemoteFormInstance<{
|
|
4
|
+
category: RemoteField;
|
|
5
|
+
name: RemoteField;
|
|
6
|
+
email: RemoteField;
|
|
7
|
+
message: RemoteField;
|
|
8
|
+
locale: RemoteField;
|
|
9
|
+
website: RemoteField;
|
|
10
|
+
}, FormResultOf> & {
|
|
11
|
+
fields: {
|
|
12
|
+
allIssues(): Array<{
|
|
13
|
+
message: string;
|
|
14
|
+
}> | undefined;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
interface Props {
|
|
18
|
+
/**
|
|
19
|
+
* The remote form, preflighted by the host page against its schema.
|
|
20
|
+
* Progressive enhancement is unchanged: without JavaScript the form
|
|
21
|
+
* posts natively and the page re-renders with the result.
|
|
22
|
+
*/
|
|
23
|
+
form: ContactFormInstance;
|
|
24
|
+
}
|
|
25
|
+
declare const ContactForm: import("svelte").Component<Props, {}, "">;
|
|
26
|
+
type ContactForm = ReturnType<typeof ContactForm>;
|
|
27
|
+
export default ContactForm;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ContactForm } from './ContactForm.svelte';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { HONEYPOT_FIELD } from '../../forms/transport.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Spam trap for the public forms: invisible to humans, tempting to bots.
|
|
6
|
+
* The server half is HONEYPOT_FIELD in ../../forms/transport.js, which the
|
|
7
|
+
* host app's form envelope checks before running any handler.
|
|
8
|
+
*
|
|
9
|
+
* This takes the form and reads that field itself. Taking the field meant
|
|
10
|
+
* both callers named it — `f.fields.website` — so the two halves were
|
|
11
|
+
* joined by a string typed twice rather than by the constant, and nothing
|
|
12
|
+
* outside the schemas and the envelope imported it. A trap whose markup
|
|
13
|
+
* name drifts from the schema's is a trap that silently stops catching.
|
|
14
|
+
*/
|
|
15
|
+
interface HoneypotField {
|
|
16
|
+
as(type: 'text'): Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
/** The remote form; its honeypot field is read by name from the schema. */
|
|
21
|
+
form: Record<typeof HONEYPOT_FIELD, HoneypotField>;
|
|
22
|
+
/** DOM id, unique per page — two honeypot forms can share one. */
|
|
23
|
+
id: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let { form, id }: Props = $props();
|
|
27
|
+
|
|
28
|
+
const field = $derived(form[HONEYPOT_FIELD]);
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<div class="hp" aria-hidden="true">
|
|
32
|
+
<label for={id}>Website</label>
|
|
33
|
+
<input {id} tabindex="-1" autocomplete="off" {...field.as('text')} />
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<style>
|
|
37
|
+
.hp {
|
|
38
|
+
position: absolute;
|
|
39
|
+
left: -9999px;
|
|
40
|
+
width: 1px;
|
|
41
|
+
height: 1px;
|
|
42
|
+
overflow: hidden;
|
|
43
|
+
}
|
|
44
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { HONEYPOT_FIELD } from '../../forms/transport.js';
|
|
2
|
+
/**
|
|
3
|
+
* Spam trap for the public forms: invisible to humans, tempting to bots.
|
|
4
|
+
* The server half is HONEYPOT_FIELD in ../../forms/transport.js, which the
|
|
5
|
+
* host app's form envelope checks before running any handler.
|
|
6
|
+
*
|
|
7
|
+
* This takes the form and reads that field itself. Taking the field meant
|
|
8
|
+
* both callers named it — `f.fields.website` — so the two halves were
|
|
9
|
+
* joined by a string typed twice rather than by the constant, and nothing
|
|
10
|
+
* outside the schemas and the envelope imported it. A trap whose markup
|
|
11
|
+
* name drifts from the schema's is a trap that silently stops catching.
|
|
12
|
+
*/
|
|
13
|
+
interface HoneypotField {
|
|
14
|
+
as(type: 'text'): Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
interface Props {
|
|
17
|
+
/** The remote form; its honeypot field is read by name from the schema. */
|
|
18
|
+
form: Record<typeof HONEYPOT_FIELD, HoneypotField>;
|
|
19
|
+
/** DOM id, unique per page — two honeypot forms can share one. */
|
|
20
|
+
id: string;
|
|
21
|
+
}
|
|
22
|
+
declare const Honeypot: import("svelte").Component<Props, {}, "">;
|
|
23
|
+
type Honeypot = ReturnType<typeof Honeypot>;
|
|
24
|
+
export default Honeypot;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getUiConfig } from '../../config/context.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The hidden `locale` input every form carries. Without it a no-JS
|
|
6
|
+
* submission is stored under the base locale, because the unprefixed POST
|
|
7
|
+
* target resolves there before the locale cookie is consulted. Rendering it
|
|
8
|
+
* from one module makes the invariant enforceable instead of restated at
|
|
9
|
+
* every form.
|
|
10
|
+
*/
|
|
11
|
+
interface Props {
|
|
12
|
+
/** The form's `locale` field: `form.fields.locale`. */
|
|
13
|
+
field: { as(type: 'hidden', value: string): Record<string, unknown> };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { field }: Props = $props();
|
|
17
|
+
|
|
18
|
+
const config = getUiConfig();
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<input {...field.as('hidden', config.locale())} />
|