@shipsite.dev/components 0.2.53 → 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/context/ShipSiteProvider.d.ts +13 -0
- package/dist/context/ShipSiteProvider.d.ts.map +1 -1
- package/dist/context/ShipSiteProvider.js.map +1 -1
- 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/layout/Header.d.ts.map +1 -1
- package/dist/layout/Header.js +22 -2
- package/dist/layout/Header.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/navigation-menu.d.ts +15 -0
- package/dist/ui/navigation-menu.d.ts.map +1 -0
- package/dist/ui/navigation-menu.js +32 -0
- package/dist/ui/navigation-menu.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 +4 -1
- package/src/context/ShipSiteProvider.tsx +13 -1
- package/src/index.ts +7 -0
- package/src/layout/Header.tsx +155 -19
- 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/navigation-menu.tsx +168 -0
- package/src/ui/select.tsx +160 -0
- package/src/ui/textarea.tsx +20 -0
package/src/layout/Header.tsx
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React from 'react';
|
|
4
|
-
import { Menu } from 'lucide-react';
|
|
4
|
+
import { ChevronDown, Menu } from 'lucide-react';
|
|
5
5
|
import { useShipSite, useResolveHref } from '../context/ShipSiteProvider';
|
|
6
6
|
import { cn } from '../lib/utils';
|
|
7
7
|
import { Button } from '../ui/button';
|
|
8
8
|
import { Navbar, NavbarLeft, NavbarRight } from '../ui/navbar';
|
|
9
|
+
import {
|
|
10
|
+
NavigationMenu,
|
|
11
|
+
NavigationMenuContent,
|
|
12
|
+
NavigationMenuItem,
|
|
13
|
+
NavigationMenuLink,
|
|
14
|
+
NavigationMenuList,
|
|
15
|
+
NavigationMenuTrigger,
|
|
16
|
+
} from '../ui/navigation-menu';
|
|
9
17
|
import {
|
|
10
18
|
Sheet,
|
|
11
19
|
SheetTrigger,
|
|
@@ -15,6 +23,16 @@ import {
|
|
|
15
23
|
import { ThemeToggle } from '../ui/theme-toggle';
|
|
16
24
|
import { ClientOnly } from '../ui/client-only';
|
|
17
25
|
|
|
26
|
+
type NavItem = { label: string; href: string } | {
|
|
27
|
+
label: string;
|
|
28
|
+
children: Array<{ label: string; href: string; description?: string }>;
|
|
29
|
+
featured?: { title: string; description?: string; href: string; image: string };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function isSubmenu(item: NavItem): item is Extract<NavItem, { children: any }> {
|
|
33
|
+
return 'children' in item;
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
export function Header() {
|
|
19
37
|
const { siteName, logo, navigation, locale, defaultLocale, darkMode } = useShipSite();
|
|
20
38
|
const resolveHref = useResolveHref();
|
|
@@ -40,15 +58,91 @@ export function Header() {
|
|
|
40
58
|
</NavbarLeft>
|
|
41
59
|
|
|
42
60
|
<NavbarRight className="hidden md:flex">
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
<NavigationMenu>
|
|
62
|
+
<NavigationMenuList>
|
|
63
|
+
{navigation.items.map((item, idx) => {
|
|
64
|
+
if (isSubmenu(item)) {
|
|
65
|
+
return (
|
|
66
|
+
<NavigationMenuItem key={idx}>
|
|
67
|
+
<NavigationMenuTrigger>{item.label}</NavigationMenuTrigger>
|
|
68
|
+
<NavigationMenuContent>
|
|
69
|
+
{item.featured ? (
|
|
70
|
+
<div className="grid w-[400px] gap-3 md:w-[500px] md:grid-cols-[.75fr_1fr] lg:w-[600px]">
|
|
71
|
+
<NavigationMenuLink asChild>
|
|
72
|
+
<a
|
|
73
|
+
href={resolveHref(item.featured.href)}
|
|
74
|
+
className="flex flex-col justify-end rounded-md bg-gradient-to-b from-muted/50 to-muted p-4 no-underline outline-none select-none focus:shadow-md"
|
|
75
|
+
>
|
|
76
|
+
<img
|
|
77
|
+
src={item.featured.image}
|
|
78
|
+
alt={item.featured.title}
|
|
79
|
+
className="mb-2 h-24 w-full rounded object-cover"
|
|
80
|
+
/>
|
|
81
|
+
<div className="mb-1 text-sm font-medium leading-none">
|
|
82
|
+
{item.featured.title}
|
|
83
|
+
</div>
|
|
84
|
+
{item.featured.description && (
|
|
85
|
+
<p className="text-xs leading-snug text-muted-foreground">
|
|
86
|
+
{item.featured.description}
|
|
87
|
+
</p>
|
|
88
|
+
)}
|
|
89
|
+
</a>
|
|
90
|
+
</NavigationMenuLink>
|
|
91
|
+
<ul className="flex flex-col gap-1 p-1">
|
|
92
|
+
{item.children.map((child) => (
|
|
93
|
+
<li key={child.href}>
|
|
94
|
+
<NavigationMenuLink asChild>
|
|
95
|
+
<a href={resolveHref(child.href)} className="block rounded-md p-2 hover:bg-foreground/5">
|
|
96
|
+
<div className="text-sm font-medium leading-none">{child.label}</div>
|
|
97
|
+
{child.description && (
|
|
98
|
+
<p className="mt-1 line-clamp-2 text-xs leading-snug text-muted-foreground">
|
|
99
|
+
{child.description}
|
|
100
|
+
</p>
|
|
101
|
+
)}
|
|
102
|
+
</a>
|
|
103
|
+
</NavigationMenuLink>
|
|
104
|
+
</li>
|
|
105
|
+
))}
|
|
106
|
+
</ul>
|
|
107
|
+
</div>
|
|
108
|
+
) : (
|
|
109
|
+
<ul className="grid w-[300px] gap-1 p-1 md:w-[400px] md:grid-cols-2">
|
|
110
|
+
{item.children.map((child) => (
|
|
111
|
+
<li key={child.href}>
|
|
112
|
+
<NavigationMenuLink asChild>
|
|
113
|
+
<a href={resolveHref(child.href)} className="block rounded-md p-2 hover:bg-foreground/5">
|
|
114
|
+
<div className="text-sm font-medium leading-none">{child.label}</div>
|
|
115
|
+
{child.description && (
|
|
116
|
+
<p className="mt-1 line-clamp-2 text-xs leading-snug text-muted-foreground">
|
|
117
|
+
{child.description}
|
|
118
|
+
</p>
|
|
119
|
+
)}
|
|
120
|
+
</a>
|
|
121
|
+
</NavigationMenuLink>
|
|
122
|
+
</li>
|
|
123
|
+
))}
|
|
124
|
+
</ul>
|
|
125
|
+
)}
|
|
126
|
+
</NavigationMenuContent>
|
|
127
|
+
</NavigationMenuItem>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<NavigationMenuItem key={item.href}>
|
|
133
|
+
<NavigationMenuLink asChild>
|
|
134
|
+
<a
|
|
135
|
+
href={resolveHref(item.href)}
|
|
136
|
+
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors inline-flex h-9 items-center px-4 py-2"
|
|
137
|
+
>
|
|
138
|
+
{item.label}
|
|
139
|
+
</a>
|
|
140
|
+
</NavigationMenuLink>
|
|
141
|
+
</NavigationMenuItem>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</NavigationMenuList>
|
|
145
|
+
</NavigationMenu>
|
|
52
146
|
{darkMode && <ThemeToggle />}
|
|
53
147
|
{navigation.cta && (
|
|
54
148
|
<Button asChild size="sm">
|
|
@@ -74,15 +168,22 @@ export function Header() {
|
|
|
74
168
|
<SheetContent side="right">
|
|
75
169
|
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
|
76
170
|
<nav className="flex flex-col gap-4 mt-8">
|
|
77
|
-
{navigation.items.map((item) =>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
171
|
+
{navigation.items.map((item, idx) => {
|
|
172
|
+
if (isSubmenu(item)) {
|
|
173
|
+
return (
|
|
174
|
+
<MobileSubmenu key={idx} item={item} resolveHref={resolveHref} />
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return (
|
|
178
|
+
<a
|
|
179
|
+
key={item.href}
|
|
180
|
+
href={resolveHref(item.href)}
|
|
181
|
+
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
|
182
|
+
>
|
|
183
|
+
{item.label}
|
|
184
|
+
</a>
|
|
185
|
+
);
|
|
186
|
+
})}
|
|
86
187
|
{navigation.cta && (
|
|
87
188
|
<Button asChild className="mt-2">
|
|
88
189
|
<a href={navigation.cta.href}>
|
|
@@ -100,3 +201,38 @@ export function Header() {
|
|
|
100
201
|
</header>
|
|
101
202
|
);
|
|
102
203
|
}
|
|
204
|
+
|
|
205
|
+
function MobileSubmenu({
|
|
206
|
+
item,
|
|
207
|
+
resolveHref,
|
|
208
|
+
}: {
|
|
209
|
+
item: Extract<NavItem, { children: any }>;
|
|
210
|
+
resolveHref: (href: string) => string;
|
|
211
|
+
}) {
|
|
212
|
+
const [open, setOpen] = React.useState(false);
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div>
|
|
216
|
+
<button
|
|
217
|
+
onClick={() => setOpen(!open)}
|
|
218
|
+
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors w-full"
|
|
219
|
+
>
|
|
220
|
+
{item.label}
|
|
221
|
+
<ChevronDown className={cn("size-3.5 transition-transform", open && "rotate-180")} />
|
|
222
|
+
</button>
|
|
223
|
+
{open && (
|
|
224
|
+
<div className="mt-2 ml-3 flex flex-col gap-2 border-l border-border pl-3">
|
|
225
|
+
{item.children.map((child) => (
|
|
226
|
+
<a
|
|
227
|
+
key={child.href}
|
|
228
|
+
href={resolveHref(child.href)}
|
|
229
|
+
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
230
|
+
>
|
|
231
|
+
{child.label}
|
|
232
|
+
</a>
|
|
233
|
+
))}
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback } from "react";
|
|
4
|
+
|
|
5
|
+
export type FormStatus = "idle" | "loading" | "success" | "error";
|
|
6
|
+
|
|
7
|
+
export function useFormSubmit(action: string) {
|
|
8
|
+
const [status, setStatus] = useState<FormStatus>("idle");
|
|
9
|
+
const [errorMsg, setErrorMsg] = useState("");
|
|
10
|
+
|
|
11
|
+
const submit = useCallback(
|
|
12
|
+
async (data: Record<string, string>) => {
|
|
13
|
+
setStatus("loading");
|
|
14
|
+
setErrorMsg("");
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(action, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: {
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
Accept: "application/json",
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify(data),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
let msg = "Something went wrong. Please try again.";
|
|
28
|
+
try {
|
|
29
|
+
const body = await res.json();
|
|
30
|
+
// Formspree returns { errors: [{ message }] }
|
|
31
|
+
// Getform/Basin return { message }
|
|
32
|
+
if (body.errors?.[0]?.message) msg = body.errors[0].message;
|
|
33
|
+
else if (body.error) msg = body.error;
|
|
34
|
+
else if (body.message) msg = body.message;
|
|
35
|
+
} catch {
|
|
36
|
+
// ignore JSON parse errors
|
|
37
|
+
}
|
|
38
|
+
setErrorMsg(msg);
|
|
39
|
+
setStatus("error");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setStatus("success");
|
|
44
|
+
} catch {
|
|
45
|
+
setErrorMsg("Network error. Please check your connection and try again.");
|
|
46
|
+
setStatus("error");
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
[action],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return { status, errorMsg, submit };
|
|
53
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
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 { useFormSubmit } from "../lib/use-form-submit";
|
|
11
|
+
|
|
12
|
+
interface ContactFormProps {
|
|
13
|
+
id?: string;
|
|
14
|
+
action: string;
|
|
15
|
+
title?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
nameLabel?: string;
|
|
18
|
+
emailLabel?: string;
|
|
19
|
+
messageLabel?: string;
|
|
20
|
+
submitLabel?: string;
|
|
21
|
+
successTitle?: string;
|
|
22
|
+
successMessage?: string;
|
|
23
|
+
variant?: "section" | "card";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ContactForm({
|
|
27
|
+
id,
|
|
28
|
+
action,
|
|
29
|
+
title = "Get in Touch",
|
|
30
|
+
description,
|
|
31
|
+
nameLabel = "Name",
|
|
32
|
+
emailLabel = "Email",
|
|
33
|
+
messageLabel = "Message",
|
|
34
|
+
submitLabel = "Send Message",
|
|
35
|
+
successTitle = "Message Sent!",
|
|
36
|
+
successMessage = "We'll get back to you as soon as possible.",
|
|
37
|
+
variant = "section",
|
|
38
|
+
}: ContactFormProps) {
|
|
39
|
+
const uid = useId();
|
|
40
|
+
const { status, errorMsg, submit } = useFormSubmit(action);
|
|
41
|
+
const [fields, setFields] = useState({ name: "", email: "", message: "" });
|
|
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
|
+
submit(fields);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const successContent = (
|
|
53
|
+
<div className="text-center py-12">
|
|
54
|
+
<div className="w-16 h-16 rounded-full bg-primary/10 mx-auto mb-4 flex items-center justify-center">
|
|
55
|
+
<CheckCircle className="w-8 h-8 text-primary" />
|
|
56
|
+
</div>
|
|
57
|
+
<h3 className="text-xl font-semibold text-foreground mb-2">{successTitle}</h3>
|
|
58
|
+
<p className="text-muted-foreground">{successMessage}</p>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const formContent =
|
|
63
|
+
status === "success" ? (
|
|
64
|
+
successContent
|
|
65
|
+
) : (
|
|
66
|
+
<form onSubmit={handleSubmit} className="space-y-6">
|
|
67
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
68
|
+
<div className="space-y-2">
|
|
69
|
+
<Label htmlFor={`${uid}-name`}>{nameLabel}</Label>
|
|
70
|
+
<Input
|
|
71
|
+
id={`${uid}-name`}
|
|
72
|
+
name="name"
|
|
73
|
+
placeholder="Your name"
|
|
74
|
+
value={fields.name}
|
|
75
|
+
onChange={(e) => update("name", e.target.value)}
|
|
76
|
+
required
|
|
77
|
+
disabled={status === "loading"}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="space-y-2">
|
|
81
|
+
<Label htmlFor={`${uid}-email`}>{emailLabel}</Label>
|
|
82
|
+
<Input
|
|
83
|
+
id={`${uid}-email`}
|
|
84
|
+
name="email"
|
|
85
|
+
type="email"
|
|
86
|
+
placeholder="you@example.com"
|
|
87
|
+
value={fields.email}
|
|
88
|
+
onChange={(e) => update("email", e.target.value)}
|
|
89
|
+
required
|
|
90
|
+
disabled={status === "loading"}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
<div className="space-y-2">
|
|
95
|
+
<Label htmlFor={`${uid}-message`}>{messageLabel}</Label>
|
|
96
|
+
<Textarea
|
|
97
|
+
id={`${uid}-message`}
|
|
98
|
+
name="message"
|
|
99
|
+
placeholder="Your message..."
|
|
100
|
+
value={fields.message}
|
|
101
|
+
onChange={(e) => update("message", e.target.value)}
|
|
102
|
+
required
|
|
103
|
+
disabled={status === "loading"}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
<Button
|
|
107
|
+
type="submit"
|
|
108
|
+
variant="default"
|
|
109
|
+
size="lg"
|
|
110
|
+
className="w-full"
|
|
111
|
+
disabled={status === "loading"}
|
|
112
|
+
>
|
|
113
|
+
{status === "loading" ? (
|
|
114
|
+
<>
|
|
115
|
+
<Loader2 className="size-4 animate-spin mr-2" />
|
|
116
|
+
Sending...
|
|
117
|
+
</>
|
|
118
|
+
) : (
|
|
119
|
+
submitLabel
|
|
120
|
+
)}
|
|
121
|
+
</Button>
|
|
122
|
+
{status === "error" && (
|
|
123
|
+
<p className="text-sm text-destructive" aria-live="polite">
|
|
124
|
+
{errorMsg}
|
|
125
|
+
</p>
|
|
126
|
+
)}
|
|
127
|
+
</form>
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (variant === "card") {
|
|
131
|
+
return (
|
|
132
|
+
<div className="glass-2 rounded-2xl p-8">{formContent}</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Section id={id}>
|
|
138
|
+
<div className="container-main max-w-2xl">
|
|
139
|
+
{(title || description) && (
|
|
140
|
+
<div className="text-center mb-12">
|
|
141
|
+
{title && (
|
|
142
|
+
<h2 className="text-3xl md:text-4xl font-bold text-foreground mb-4">
|
|
143
|
+
{title}
|
|
144
|
+
</h2>
|
|
145
|
+
)}
|
|
146
|
+
{description && (
|
|
147
|
+
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
|
148
|
+
{description}
|
|
149
|
+
</p>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
<div className="glass-1 rounded-2xl p-8">{formContent}</div>
|
|
154
|
+
</div>
|
|
155
|
+
</Section>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React, { Children, isValidElement } from "react";
|
|
2
|
+
import { FormClient, type FormFieldDef } from "./FormClient";
|
|
3
|
+
|
|
4
|
+
interface FormFieldProps {
|
|
5
|
+
name: string;
|
|
6
|
+
label: string;
|
|
7
|
+
type?: "text" | "email" | "tel" | "url" | "textarea" | "select";
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
required?: boolean;
|
|
10
|
+
options?: string[];
|
|
11
|
+
colSpan?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function FormField(_props: FormFieldProps) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface FormProps {
|
|
19
|
+
id?: string;
|
|
20
|
+
action: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
columns?: number;
|
|
24
|
+
submitLabel?: string;
|
|
25
|
+
successTitle?: string;
|
|
26
|
+
successMessage?: string;
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function Form({ children, ...rest }: FormProps) {
|
|
31
|
+
const fields: FormFieldDef[] = [];
|
|
32
|
+
|
|
33
|
+
Children.forEach(children, (child) => {
|
|
34
|
+
if (!isValidElement(child)) return;
|
|
35
|
+
if (child.type === FormField) {
|
|
36
|
+
fields.push(child.props as FormFieldDef);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return <FormClient {...rest} fields={fields} />;
|
|
41
|
+
}
|
|
@@ -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
|
+
}
|