@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.
Files changed (75) hide show
  1. package/components.json +102 -0
  2. package/dist/context/ShipSiteProvider.d.ts +13 -0
  3. package/dist/context/ShipSiteProvider.d.ts.map +1 -1
  4. package/dist/context/ShipSiteProvider.js.map +1 -1
  5. package/dist/index.d.ts +5 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +6 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/layout/Header.d.ts.map +1 -1
  10. package/dist/layout/Header.js +22 -2
  11. package/dist/layout/Header.js.map +1 -1
  12. package/dist/lib/use-form-submit.d.ts +7 -0
  13. package/dist/lib/use-form-submit.d.ts.map +1 -0
  14. package/dist/lib/use-form-submit.js +47 -0
  15. package/dist/lib/use-form-submit.js.map +1 -0
  16. package/dist/marketing/ContactForm.d.ts +16 -0
  17. package/dist/marketing/ContactForm.d.ts.map +1 -0
  18. package/dist/marketing/ContactForm.js +29 -0
  19. package/dist/marketing/ContactForm.js.map +1 -0
  20. package/dist/marketing/Form.d.ts +25 -0
  21. package/dist/marketing/Form.d.ts.map +1 -0
  22. package/dist/marketing/Form.js +18 -0
  23. package/dist/marketing/Form.js.map +1 -0
  24. package/dist/marketing/FormClient.d.ts +23 -0
  25. package/dist/marketing/FormClient.d.ts.map +1 -0
  26. package/dist/marketing/FormClient.js +41 -0
  27. package/dist/marketing/FormClient.js.map +1 -0
  28. package/dist/marketing/FormEmbed.d.ts +13 -0
  29. package/dist/marketing/FormEmbed.d.ts.map +1 -0
  30. package/dist/marketing/FormEmbed.js +27 -0
  31. package/dist/marketing/FormEmbed.js.map +1 -0
  32. package/dist/marketing/NewsletterForm.d.ts +13 -0
  33. package/dist/marketing/NewsletterForm.d.ts.map +1 -0
  34. package/dist/marketing/NewsletterForm.js +23 -0
  35. package/dist/marketing/NewsletterForm.js.map +1 -0
  36. package/dist/marketing/WaitlistForm.d.ts +16 -0
  37. package/dist/marketing/WaitlistForm.d.ts.map +1 -0
  38. package/dist/marketing/WaitlistForm.js +27 -0
  39. package/dist/marketing/WaitlistForm.js.map +1 -0
  40. package/dist/ui/input.d.ts +5 -0
  41. package/dist/ui/input.d.ts.map +1 -0
  42. package/dist/ui/input.js +7 -0
  43. package/dist/ui/input.js.map +1 -0
  44. package/dist/ui/label.d.ts +5 -0
  45. package/dist/ui/label.d.ts.map +1 -0
  46. package/dist/ui/label.js +9 -0
  47. package/dist/ui/label.js.map +1 -0
  48. package/dist/ui/navigation-menu.d.ts +15 -0
  49. package/dist/ui/navigation-menu.d.ts.map +1 -0
  50. package/dist/ui/navigation-menu.js +32 -0
  51. package/dist/ui/navigation-menu.js.map +1 -0
  52. package/dist/ui/select.d.ts +14 -0
  53. package/dist/ui/select.d.ts.map +1 -0
  54. package/dist/ui/select.js +28 -0
  55. package/dist/ui/select.js.map +1 -0
  56. package/dist/ui/textarea.d.ts +5 -0
  57. package/dist/ui/textarea.d.ts.map +1 -0
  58. package/dist/ui/textarea.js +7 -0
  59. package/dist/ui/textarea.js.map +1 -0
  60. package/package.json +4 -1
  61. package/src/context/ShipSiteProvider.tsx +13 -1
  62. package/src/index.ts +7 -0
  63. package/src/layout/Header.tsx +155 -19
  64. package/src/lib/use-form-submit.ts +53 -0
  65. package/src/marketing/ContactForm.tsx +157 -0
  66. package/src/marketing/Form.tsx +41 -0
  67. package/src/marketing/FormClient.tsx +194 -0
  68. package/src/marketing/FormEmbed.tsx +118 -0
  69. package/src/marketing/NewsletterForm.tsx +102 -0
  70. package/src/marketing/WaitlistForm.tsx +145 -0
  71. package/src/ui/input.tsx +21 -0
  72. package/src/ui/label.tsx +24 -0
  73. package/src/ui/navigation-menu.tsx +168 -0
  74. package/src/ui/select.tsx +160 -0
  75. package/src/ui/textarea.tsx +20 -0
@@ -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
- {navigation.items.map((item) => (
44
- <a
45
- key={item.href}
46
- href={resolveHref(item.href)}
47
- className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
48
- >
49
- {item.label}
50
- </a>
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
- <a
79
- key={item.href}
80
- href={resolveHref(item.href)}
81
- className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
82
- >
83
- {item.label}
84
- </a>
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
+ }