@shipsite.dev/components 0.2.55 → 0.2.60
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/components.json +102 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/use-form-submit.d.ts +7 -0
- package/dist/lib/use-form-submit.d.ts.map +1 -0
- package/dist/lib/use-form-submit.js +47 -0
- package/dist/lib/use-form-submit.js.map +1 -0
- package/dist/marketing/ContactForm.d.ts +16 -0
- package/dist/marketing/ContactForm.d.ts.map +1 -0
- package/dist/marketing/ContactForm.js +29 -0
- package/dist/marketing/ContactForm.js.map +1 -0
- package/dist/marketing/Form.d.ts +25 -0
- package/dist/marketing/Form.d.ts.map +1 -0
- package/dist/marketing/Form.js +18 -0
- package/dist/marketing/Form.js.map +1 -0
- package/dist/marketing/FormClient.d.ts +23 -0
- package/dist/marketing/FormClient.d.ts.map +1 -0
- package/dist/marketing/FormClient.js +41 -0
- package/dist/marketing/FormClient.js.map +1 -0
- package/dist/marketing/FormEmbed.d.ts +13 -0
- package/dist/marketing/FormEmbed.d.ts.map +1 -0
- package/dist/marketing/FormEmbed.js +27 -0
- package/dist/marketing/FormEmbed.js.map +1 -0
- package/dist/marketing/NewsletterForm.d.ts +13 -0
- package/dist/marketing/NewsletterForm.d.ts.map +1 -0
- package/dist/marketing/NewsletterForm.js +23 -0
- package/dist/marketing/NewsletterForm.js.map +1 -0
- package/dist/marketing/WaitlistForm.d.ts +16 -0
- package/dist/marketing/WaitlistForm.d.ts.map +1 -0
- package/dist/marketing/WaitlistForm.js +27 -0
- package/dist/marketing/WaitlistForm.js.map +1 -0
- package/dist/ui/input.d.ts +5 -0
- package/dist/ui/input.d.ts.map +1 -0
- package/dist/ui/input.js +7 -0
- package/dist/ui/input.js.map +1 -0
- package/dist/ui/label.d.ts +5 -0
- package/dist/ui/label.d.ts.map +1 -0
- package/dist/ui/label.js +9 -0
- package/dist/ui/label.js.map +1 -0
- package/dist/ui/select.d.ts +14 -0
- package/dist/ui/select.d.ts.map +1 -0
- package/dist/ui/select.js +28 -0
- package/dist/ui/select.js.map +1 -0
- package/dist/ui/textarea.d.ts +5 -0
- package/dist/ui/textarea.d.ts.map +1 -0
- package/dist/ui/textarea.js +7 -0
- package/dist/ui/textarea.js.map +1 -0
- package/package.json +3 -1
- package/src/index.ts +7 -0
- package/src/lib/use-form-submit.ts +53 -0
- package/src/marketing/ContactForm.tsx +157 -0
- package/src/marketing/Form.tsx +41 -0
- package/src/marketing/FormClient.tsx +194 -0
- package/src/marketing/FormEmbed.tsx +118 -0
- package/src/marketing/NewsletterForm.tsx +102 -0
- package/src/marketing/WaitlistForm.tsx +145 -0
- package/src/ui/input.tsx +21 -0
- package/src/ui/label.tsx +24 -0
- package/src/ui/select.tsx +160 -0
- package/src/ui/textarea.tsx +20 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { FormEvent, useId, useState } from "react";
|
|
4
|
+
import { CheckCircle, Loader2 } from "lucide-react";
|
|
5
|
+
import { Section } from "../ui/section";
|
|
6
|
+
import { Input } from "../ui/input";
|
|
7
|
+
import { Textarea } from "../ui/textarea";
|
|
8
|
+
import { Label } from "../ui/label";
|
|
9
|
+
import { Button } from "../ui/button";
|
|
10
|
+
import {
|
|
11
|
+
Select,
|
|
12
|
+
SelectContent,
|
|
13
|
+
SelectItem,
|
|
14
|
+
SelectTrigger,
|
|
15
|
+
SelectValue,
|
|
16
|
+
} from "../ui/select";
|
|
17
|
+
import { cn } from "../lib/utils";
|
|
18
|
+
import { useFormSubmit } from "../lib/use-form-submit";
|
|
19
|
+
|
|
20
|
+
const gridColsMap: Record<number, string> = {
|
|
21
|
+
2: "md:grid-cols-2",
|
|
22
|
+
3: "md:grid-cols-3",
|
|
23
|
+
4: "md:grid-cols-2 lg:grid-cols-4",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const colSpanMap: Record<number, string> = {
|
|
27
|
+
2: "md:col-span-2",
|
|
28
|
+
3: "md:col-span-3",
|
|
29
|
+
4: "md:col-span-4",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface FormFieldDef {
|
|
33
|
+
name: string;
|
|
34
|
+
label: string;
|
|
35
|
+
type?: "text" | "email" | "tel" | "url" | "textarea" | "select";
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
required?: boolean;
|
|
38
|
+
options?: string[];
|
|
39
|
+
colSpan?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface FormClientProps {
|
|
43
|
+
id?: string;
|
|
44
|
+
action: string;
|
|
45
|
+
title?: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
columns?: number;
|
|
48
|
+
submitLabel?: string;
|
|
49
|
+
successTitle?: string;
|
|
50
|
+
successMessage?: string;
|
|
51
|
+
fields: FormFieldDef[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function FormClient({
|
|
55
|
+
id,
|
|
56
|
+
action,
|
|
57
|
+
title,
|
|
58
|
+
description,
|
|
59
|
+
columns = 1,
|
|
60
|
+
submitLabel = "Submit",
|
|
61
|
+
successTitle = "Submitted!",
|
|
62
|
+
successMessage = "Thank you. We'll be in touch soon.",
|
|
63
|
+
fields,
|
|
64
|
+
}: FormClientProps) {
|
|
65
|
+
const uid = useId();
|
|
66
|
+
const { status, errorMsg, submit } = useFormSubmit(action);
|
|
67
|
+
const [values, setValues] = useState<Record<string, string>>(() => {
|
|
68
|
+
const init: Record<string, string> = {};
|
|
69
|
+
for (const f of fields) init[f.name] = "";
|
|
70
|
+
return init;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function update(name: string, value: string) {
|
|
74
|
+
setValues((prev) => ({ ...prev, [name]: value }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function handleSubmit(e: FormEvent) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
submit(values);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Section id={id}>
|
|
84
|
+
<div className="container-main max-w-3xl">
|
|
85
|
+
{(title || description) && (
|
|
86
|
+
<div className="text-center mb-12">
|
|
87
|
+
{title && (
|
|
88
|
+
<h2 className="text-3xl md:text-4xl font-bold text-foreground mb-4">
|
|
89
|
+
{title}
|
|
90
|
+
</h2>
|
|
91
|
+
)}
|
|
92
|
+
{description && (
|
|
93
|
+
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
|
94
|
+
{description}
|
|
95
|
+
</p>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{status === "success" ? (
|
|
101
|
+
<div className="text-center py-12">
|
|
102
|
+
<div className="w-16 h-16 rounded-full bg-primary/10 mx-auto mb-4 flex items-center justify-center">
|
|
103
|
+
<CheckCircle className="w-8 h-8 text-primary" />
|
|
104
|
+
</div>
|
|
105
|
+
<h3 className="text-xl font-semibold text-foreground mb-2">
|
|
106
|
+
{successTitle}
|
|
107
|
+
</h3>
|
|
108
|
+
<p className="text-muted-foreground">{successMessage}</p>
|
|
109
|
+
</div>
|
|
110
|
+
) : (
|
|
111
|
+
<form onSubmit={handleSubmit} className="glass-1 rounded-2xl p-8">
|
|
112
|
+
<div
|
|
113
|
+
className={cn("grid grid-cols-1 gap-4", gridColsMap[columns])}
|
|
114
|
+
>
|
|
115
|
+
{fields.map((field) => (
|
|
116
|
+
<div
|
|
117
|
+
key={field.name}
|
|
118
|
+
className={cn("space-y-2", field.colSpan && colSpanMap[field.colSpan])}
|
|
119
|
+
>
|
|
120
|
+
<Label htmlFor={`${uid}-${field.name}`}>{field.label}</Label>
|
|
121
|
+
{field.type === "textarea" ? (
|
|
122
|
+
<Textarea
|
|
123
|
+
id={`${uid}-${field.name}`}
|
|
124
|
+
name={field.name}
|
|
125
|
+
placeholder={field.placeholder}
|
|
126
|
+
required={field.required}
|
|
127
|
+
value={values[field.name] ?? ""}
|
|
128
|
+
onChange={(e) => update(field.name, e.target.value)}
|
|
129
|
+
disabled={status === "loading"}
|
|
130
|
+
/>
|
|
131
|
+
) : field.type === "select" && field.options ? (
|
|
132
|
+
<Select
|
|
133
|
+
value={values[field.name] ?? ""}
|
|
134
|
+
onValueChange={(v) => update(field.name, v)}
|
|
135
|
+
required={field.required}
|
|
136
|
+
disabled={status === "loading"}
|
|
137
|
+
>
|
|
138
|
+
<SelectTrigger id={`${uid}-${field.name}`}>
|
|
139
|
+
<SelectValue
|
|
140
|
+
placeholder={field.placeholder || "Select..."}
|
|
141
|
+
/>
|
|
142
|
+
</SelectTrigger>
|
|
143
|
+
<SelectContent>
|
|
144
|
+
{field.options.map((opt) => (
|
|
145
|
+
<SelectItem key={opt} value={opt}>
|
|
146
|
+
{opt}
|
|
147
|
+
</SelectItem>
|
|
148
|
+
))}
|
|
149
|
+
</SelectContent>
|
|
150
|
+
</Select>
|
|
151
|
+
) : (
|
|
152
|
+
<Input
|
|
153
|
+
id={`${uid}-${field.name}`}
|
|
154
|
+
name={field.name}
|
|
155
|
+
type={field.type || "text"}
|
|
156
|
+
placeholder={field.placeholder}
|
|
157
|
+
required={field.required}
|
|
158
|
+
value={values[field.name] ?? ""}
|
|
159
|
+
onChange={(e) => update(field.name, e.target.value)}
|
|
160
|
+
disabled={status === "loading"}
|
|
161
|
+
/>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
<div className="mt-6">
|
|
167
|
+
<Button
|
|
168
|
+
type="submit"
|
|
169
|
+
variant="default"
|
|
170
|
+
size="lg"
|
|
171
|
+
className="w-full"
|
|
172
|
+
disabled={status === "loading"}
|
|
173
|
+
>
|
|
174
|
+
{status === "loading" ? (
|
|
175
|
+
<>
|
|
176
|
+
<Loader2 className="size-4 animate-spin mr-2" />
|
|
177
|
+
Submitting...
|
|
178
|
+
</>
|
|
179
|
+
) : (
|
|
180
|
+
submitLabel
|
|
181
|
+
)}
|
|
182
|
+
</Button>
|
|
183
|
+
{status === "error" && (
|
|
184
|
+
<p className="text-sm text-destructive mt-2" aria-live="polite">
|
|
185
|
+
{errorMsg}
|
|
186
|
+
</p>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
</form>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
</Section>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
5
|
+
import { XIcon } from "lucide-react";
|
|
6
|
+
import { Section } from "../ui/section";
|
|
7
|
+
import { Button } from "../ui/button";
|
|
8
|
+
|
|
9
|
+
function resolveUrl(src: string, provider: "tally" | "typeform" | "custom"): string {
|
|
10
|
+
switch (provider) {
|
|
11
|
+
case "tally":
|
|
12
|
+
return `https://tally.so/embed/${src}?transparentBackground=1`;
|
|
13
|
+
case "typeform":
|
|
14
|
+
return `https://form.typeform.com/to/${src}`;
|
|
15
|
+
case "custom":
|
|
16
|
+
return src;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FormEmbedProps {
|
|
21
|
+
id?: string;
|
|
22
|
+
src: string;
|
|
23
|
+
provider?: "tally" | "typeform" | "custom";
|
|
24
|
+
title?: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
height?: number;
|
|
27
|
+
mode?: "iframe" | "popup";
|
|
28
|
+
buttonLabel?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function FormEmbed({
|
|
32
|
+
id,
|
|
33
|
+
src,
|
|
34
|
+
provider = "custom",
|
|
35
|
+
title,
|
|
36
|
+
description,
|
|
37
|
+
height = 500,
|
|
38
|
+
mode = "iframe",
|
|
39
|
+
buttonLabel = "Open Form",
|
|
40
|
+
}: FormEmbedProps) {
|
|
41
|
+
const resolvedUrl = resolveUrl(src, provider);
|
|
42
|
+
|
|
43
|
+
if (mode === "popup") {
|
|
44
|
+
return <FormEmbedPopup url={resolvedUrl} buttonLabel={buttonLabel} height={height} />;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Section id={id}>
|
|
49
|
+
<div className="container-main max-w-3xl">
|
|
50
|
+
{(title || description) && (
|
|
51
|
+
<div className="text-center mb-12">
|
|
52
|
+
{title && (
|
|
53
|
+
<h2 className="text-3xl md:text-4xl font-bold text-foreground mb-4">
|
|
54
|
+
{title}
|
|
55
|
+
</h2>
|
|
56
|
+
)}
|
|
57
|
+
{description && (
|
|
58
|
+
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
|
59
|
+
{description}
|
|
60
|
+
</p>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
<div className="glass-1 rounded-2xl overflow-hidden">
|
|
65
|
+
<iframe
|
|
66
|
+
src={resolvedUrl}
|
|
67
|
+
height={height}
|
|
68
|
+
className="w-full border-0"
|
|
69
|
+
loading="lazy"
|
|
70
|
+
title={title || "Embedded form"}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</Section>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function FormEmbedPopup({
|
|
79
|
+
url,
|
|
80
|
+
buttonLabel,
|
|
81
|
+
height,
|
|
82
|
+
}: {
|
|
83
|
+
url: string;
|
|
84
|
+
buttonLabel: string;
|
|
85
|
+
height: number;
|
|
86
|
+
}) {
|
|
87
|
+
return (
|
|
88
|
+
<DialogPrimitive.Root>
|
|
89
|
+
<DialogPrimitive.Trigger asChild>
|
|
90
|
+
<Button variant="default" size="lg">
|
|
91
|
+
{buttonLabel}
|
|
92
|
+
</Button>
|
|
93
|
+
</DialogPrimitive.Trigger>
|
|
94
|
+
<DialogPrimitive.Portal>
|
|
95
|
+
<DialogPrimitive.Overlay className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80" />
|
|
96
|
+
<DialogPrimitive.Content className="bg-background border-border dark:border-border/15 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-1/2 left-1/2 z-50 w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-2xl border p-0 shadow-lg overflow-hidden">
|
|
97
|
+
<DialogPrimitive.Title className="sr-only">
|
|
98
|
+
{buttonLabel}
|
|
99
|
+
</DialogPrimitive.Title>
|
|
100
|
+
<DialogPrimitive.Description className="sr-only">
|
|
101
|
+
Embedded form
|
|
102
|
+
</DialogPrimitive.Description>
|
|
103
|
+
<iframe
|
|
104
|
+
src={url}
|
|
105
|
+
height={height}
|
|
106
|
+
className="w-full border-0"
|
|
107
|
+
loading="lazy"
|
|
108
|
+
title={buttonLabel}
|
|
109
|
+
/>
|
|
110
|
+
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring absolute top-4 right-4 z-[100] rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden">
|
|
111
|
+
<XIcon className="size-5" />
|
|
112
|
+
<span className="sr-only">Close</span>
|
|
113
|
+
</DialogPrimitive.Close>
|
|
114
|
+
</DialogPrimitive.Content>
|
|
115
|
+
</DialogPrimitive.Portal>
|
|
116
|
+
</DialogPrimitive.Root>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { FormEvent, useState } from "react";
|
|
4
|
+
import { CheckCircle, Loader2 } from "lucide-react";
|
|
5
|
+
import { Section } from "../ui/section";
|
|
6
|
+
import { Input } from "../ui/input";
|
|
7
|
+
import { Button } from "../ui/button";
|
|
8
|
+
import { useFormSubmit } from "../lib/use-form-submit";
|
|
9
|
+
|
|
10
|
+
interface NewsletterFormProps {
|
|
11
|
+
id?: string;
|
|
12
|
+
action: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
submitLabel?: string;
|
|
17
|
+
successMessage?: string;
|
|
18
|
+
variant?: "section" | "inline";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function NewsletterForm({
|
|
22
|
+
id,
|
|
23
|
+
action,
|
|
24
|
+
title,
|
|
25
|
+
description,
|
|
26
|
+
placeholder = "Enter your email",
|
|
27
|
+
submitLabel = "Subscribe",
|
|
28
|
+
successMessage = "You're subscribed!",
|
|
29
|
+
variant = "section",
|
|
30
|
+
}: NewsletterFormProps) {
|
|
31
|
+
const { status, errorMsg, submit } = useFormSubmit(action);
|
|
32
|
+
const [email, setEmail] = useState("");
|
|
33
|
+
|
|
34
|
+
function handleSubmit(e: FormEvent) {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
submit({ email });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const successContent = (
|
|
40
|
+
<div className="flex items-center justify-center gap-2 text-sm text-primary">
|
|
41
|
+
<CheckCircle className="size-4" />
|
|
42
|
+
<span>{successMessage}</span>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const formContent =
|
|
47
|
+
status === "success" ? (
|
|
48
|
+
successContent
|
|
49
|
+
) : (
|
|
50
|
+
<>
|
|
51
|
+
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-2 max-w-md mx-auto">
|
|
52
|
+
<Input
|
|
53
|
+
type="email"
|
|
54
|
+
placeholder={placeholder}
|
|
55
|
+
value={email}
|
|
56
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
57
|
+
required
|
|
58
|
+
className="flex-1"
|
|
59
|
+
disabled={status === "loading"}
|
|
60
|
+
/>
|
|
61
|
+
<Button type="submit" variant="default" disabled={status === "loading"}>
|
|
62
|
+
{status === "loading" ? (
|
|
63
|
+
<Loader2 className="size-4 animate-spin" />
|
|
64
|
+
) : (
|
|
65
|
+
submitLabel
|
|
66
|
+
)}
|
|
67
|
+
</Button>
|
|
68
|
+
</form>
|
|
69
|
+
{status === "error" && (
|
|
70
|
+
<p className="text-sm text-destructive text-center mt-2" aria-live="polite">
|
|
71
|
+
{errorMsg}
|
|
72
|
+
</p>
|
|
73
|
+
)}
|
|
74
|
+
</>
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (variant === "inline") {
|
|
78
|
+
return formContent;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Section id={id}>
|
|
83
|
+
<div className="container-main max-w-2xl">
|
|
84
|
+
{(title || description) && (
|
|
85
|
+
<div className="text-center mb-12">
|
|
86
|
+
{title && (
|
|
87
|
+
<h2 className="text-3xl md:text-4xl font-bold text-foreground mb-4">
|
|
88
|
+
{title}
|
|
89
|
+
</h2>
|
|
90
|
+
)}
|
|
91
|
+
{description && (
|
|
92
|
+
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
|
93
|
+
{description}
|
|
94
|
+
</p>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
{formContent}
|
|
99
|
+
</div>
|
|
100
|
+
</Section>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { FormEvent, useId, useState } from "react";
|
|
4
|
+
import { CheckCircle, Loader2 } from "lucide-react";
|
|
5
|
+
import { Section } from "../ui/section";
|
|
6
|
+
import { Input } from "../ui/input";
|
|
7
|
+
import { Label } from "../ui/label";
|
|
8
|
+
import { Button } from "../ui/button";
|
|
9
|
+
import { Badge } from "../ui/badge";
|
|
10
|
+
import { useFormSubmit } from "../lib/use-form-submit";
|
|
11
|
+
|
|
12
|
+
interface WaitlistFormProps {
|
|
13
|
+
id?: string;
|
|
14
|
+
action: string;
|
|
15
|
+
title?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
badge?: string;
|
|
18
|
+
showName?: boolean;
|
|
19
|
+
nameLabel?: string;
|
|
20
|
+
emailLabel?: string;
|
|
21
|
+
submitLabel?: string;
|
|
22
|
+
successTitle?: string;
|
|
23
|
+
successMessage?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function WaitlistForm({
|
|
27
|
+
id,
|
|
28
|
+
action,
|
|
29
|
+
title = "Join the Waitlist",
|
|
30
|
+
description,
|
|
31
|
+
badge,
|
|
32
|
+
showName = false,
|
|
33
|
+
nameLabel = "Name",
|
|
34
|
+
emailLabel = "Email",
|
|
35
|
+
submitLabel = "Join Waitlist",
|
|
36
|
+
successTitle = "You're on the list!",
|
|
37
|
+
successMessage = "We'll notify you when it's your turn.",
|
|
38
|
+
}: WaitlistFormProps) {
|
|
39
|
+
const uid = useId();
|
|
40
|
+
const { status, errorMsg, submit } = useFormSubmit(action);
|
|
41
|
+
const [fields, setFields] = useState({ name: "", email: "" });
|
|
42
|
+
|
|
43
|
+
function update(key: string, value: string) {
|
|
44
|
+
setFields((prev) => ({ ...prev, [key]: value }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function handleSubmit(e: FormEvent) {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
const data: Record<string, string> = { email: fields.email };
|
|
50
|
+
if (showName) data.name = fields.name;
|
|
51
|
+
submit(data);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Section id={id}>
|
|
56
|
+
<div className="container-main max-w-2xl text-center">
|
|
57
|
+
{badge && (
|
|
58
|
+
<div className="mb-4">
|
|
59
|
+
<Badge variant="outline">{badge}</Badge>
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
{(title || description) && (
|
|
63
|
+
<div className="mb-12">
|
|
64
|
+
{title && (
|
|
65
|
+
<h2 className="text-3xl md:text-4xl font-bold text-foreground mb-4">
|
|
66
|
+
{title}
|
|
67
|
+
</h2>
|
|
68
|
+
)}
|
|
69
|
+
{description && (
|
|
70
|
+
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
|
71
|
+
{description}
|
|
72
|
+
</p>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
{status === "success" ? (
|
|
78
|
+
<div className="text-center py-12">
|
|
79
|
+
<div className="w-16 h-16 rounded-full bg-primary/10 mx-auto mb-4 flex items-center justify-center">
|
|
80
|
+
<CheckCircle className="w-8 h-8 text-primary" />
|
|
81
|
+
</div>
|
|
82
|
+
<h3 className="text-xl font-semibold text-foreground mb-2">
|
|
83
|
+
{successTitle}
|
|
84
|
+
</h3>
|
|
85
|
+
<p className="text-muted-foreground">{successMessage}</p>
|
|
86
|
+
</div>
|
|
87
|
+
) : (
|
|
88
|
+
<form
|
|
89
|
+
onSubmit={handleSubmit}
|
|
90
|
+
className="glass-1 rounded-2xl p-8 max-w-md mx-auto space-y-4"
|
|
91
|
+
>
|
|
92
|
+
{showName && (
|
|
93
|
+
<div className="space-y-2 text-left">
|
|
94
|
+
<Label htmlFor={`${uid}-name`}>{nameLabel}</Label>
|
|
95
|
+
<Input
|
|
96
|
+
id={`${uid}-name`}
|
|
97
|
+
name="name"
|
|
98
|
+
placeholder="Your name"
|
|
99
|
+
value={fields.name}
|
|
100
|
+
onChange={(e) => update("name", e.target.value)}
|
|
101
|
+
required
|
|
102
|
+
disabled={status === "loading"}
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
<div className="space-y-2 text-left">
|
|
107
|
+
<Label htmlFor={`${uid}-email`}>{emailLabel}</Label>
|
|
108
|
+
<Input
|
|
109
|
+
id={`${uid}-email`}
|
|
110
|
+
name="email"
|
|
111
|
+
type="email"
|
|
112
|
+
placeholder="you@example.com"
|
|
113
|
+
value={fields.email}
|
|
114
|
+
onChange={(e) => update("email", e.target.value)}
|
|
115
|
+
required
|
|
116
|
+
disabled={status === "loading"}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
<Button
|
|
120
|
+
type="submit"
|
|
121
|
+
variant="default"
|
|
122
|
+
size="lg"
|
|
123
|
+
className="w-full"
|
|
124
|
+
disabled={status === "loading"}
|
|
125
|
+
>
|
|
126
|
+
{status === "loading" ? (
|
|
127
|
+
<>
|
|
128
|
+
<Loader2 className="size-4 animate-spin mr-2" />
|
|
129
|
+
Joining...
|
|
130
|
+
</>
|
|
131
|
+
) : (
|
|
132
|
+
submitLabel
|
|
133
|
+
)}
|
|
134
|
+
</Button>
|
|
135
|
+
{status === "error" && (
|
|
136
|
+
<p className="text-sm text-destructive" aria-live="polite">
|
|
137
|
+
{errorMsg}
|
|
138
|
+
</p>
|
|
139
|
+
)}
|
|
140
|
+
</form>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
</Section>
|
|
144
|
+
);
|
|
145
|
+
}
|
package/src/ui/input.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
|
6
|
+
|
|
7
|
+
function Input({ className, type, ...props }: InputProps) {
|
|
8
|
+
return (
|
|
9
|
+
<input
|
|
10
|
+
type={type}
|
|
11
|
+
data-slot="input"
|
|
12
|
+
className={cn(
|
|
13
|
+
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
|
14
|
+
className,
|
|
15
|
+
)}
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { Input };
|
package/src/ui/label.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as LabelPrimitive from "@radix-ui/react-label";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
|
|
8
|
+
function Label({
|
|
9
|
+
className,
|
|
10
|
+
...props
|
|
11
|
+
}: React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>) {
|
|
12
|
+
return (
|
|
13
|
+
<LabelPrimitive.Root
|
|
14
|
+
data-slot="label"
|
|
15
|
+
className={cn(
|
|
16
|
+
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { Label };
|