create-landing-app 0.2.5 → 0.2.6
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/prompts.js +4 -0
- package/dist/scaffold.js +2 -0
- package/package.json +1 -1
- package/templates/nextjs/optional/sections/contact/files/app/api/contact/route.ts +24 -0
- package/templates/nextjs/optional/sections/contact/files/components/sections/contact-form-schema.ts +14 -0
- package/templates/nextjs/optional/sections/contact/files/components/sections/contact-form.tsx +74 -0
- package/templates/nextjs/optional/sections/contact/files/components/sections/contact-section.tsx +2 -57
- package/templates/nextjs/optional/sections/contact/files/components/sections/use-contact-submit.ts +25 -0
- package/templates/nextjs/optional/sections/contact/files/components/ui/form.tsx +127 -0
- package/templates/nextjs/optional/sections/contact/files/components/ui/label.tsx +20 -0
- package/templates/nextjs/optional/sections/contact/files/lib/apis/contact.ts +11 -0
- package/templates/nextjs/optional/sections/contact/pkg.json +8 -0
package/dist/prompts.js
CHANGED
|
@@ -10,6 +10,9 @@ export async function runPrompts() {
|
|
|
10
10
|
const blog = await confirm({ message: "Include Blog section?" });
|
|
11
11
|
if (isCancel(blog))
|
|
12
12
|
return null;
|
|
13
|
+
const contact = await confirm({ message: "Include Contact section? (form + API route)" });
|
|
14
|
+
if (isCancel(contact))
|
|
15
|
+
return null;
|
|
13
16
|
const docker = await confirm({ message: "Include Docker setup? (for VPS deploy)" });
|
|
14
17
|
if (isCancel(docker))
|
|
15
18
|
return null;
|
|
@@ -20,6 +23,7 @@ export async function runPrompts() {
|
|
|
20
23
|
stateManagement: "zustand",
|
|
21
24
|
dataFetching: "tanstack-query",
|
|
22
25
|
blog: Boolean(blog),
|
|
26
|
+
contact: Boolean(contact),
|
|
23
27
|
docker: Boolean(docker),
|
|
24
28
|
};
|
|
25
29
|
}
|
package/dist/scaffold.js
CHANGED
|
@@ -30,6 +30,8 @@ export async function scaffold(config, targetDir) {
|
|
|
30
30
|
optionals.push("sections/about");
|
|
31
31
|
if (config.blog)
|
|
32
32
|
optionals.push("sections/blog");
|
|
33
|
+
if (config.contact)
|
|
34
|
+
optionals.push("sections/contact");
|
|
33
35
|
if (config.docker)
|
|
34
36
|
optionals.push("docker");
|
|
35
37
|
// 3. Apply each optional feature (files + inject markers)
|
package/package.json
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { contactFormSchema } from "@/components/sections/contact-form-schema";
|
|
3
|
+
|
|
4
|
+
export async function POST(req: NextRequest) {
|
|
5
|
+
const body = await req.json();
|
|
6
|
+
const parsed = contactFormSchema.safeParse(body);
|
|
7
|
+
|
|
8
|
+
if (!parsed.success) {
|
|
9
|
+
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// TODO: connect your email service (Resend recommended)
|
|
13
|
+
// import { Resend } from "resend";
|
|
14
|
+
// const resend = new Resend(process.env.RESEND_API_KEY);
|
|
15
|
+
// await resend.emails.send({
|
|
16
|
+
// from: process.env.RESEND_FROM_EMAIL!,
|
|
17
|
+
// to: process.env.RESEND_TO_EMAIL!,
|
|
18
|
+
// subject: `New contact from ${parsed.data.name}`,
|
|
19
|
+
// text: `Phone: ${parsed.data.phone}\nEmail: ${parsed.data.email}\nMessage: ${parsed.data.message ?? ""}`,
|
|
20
|
+
// });
|
|
21
|
+
|
|
22
|
+
console.log("Contact submission:", parsed.data);
|
|
23
|
+
return NextResponse.json({ ok: true });
|
|
24
|
+
}
|
package/templates/nextjs/optional/sections/contact/files/components/sections/contact-form-schema.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const contactFormSchema = z.object({
|
|
4
|
+
name: z.string().trim().min(1, "Name is required").max(100),
|
|
5
|
+
phone: z.string().trim().regex(/^\+?[\d\s\-().]{7,20}$/, "Invalid phone number"),
|
|
6
|
+
email: z.string().trim().email("Invalid email address"),
|
|
7
|
+
message: z.string().trim().optional(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export type ContactFormValues = z.infer<typeof contactFormSchema>;
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_VALUES: ContactFormValues = {
|
|
13
|
+
name: "", phone: "", email: "", message: "",
|
|
14
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useForm } from "react-hook-form";
|
|
3
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
|
|
6
|
+
import { contactFormSchema, type ContactFormValues, DEFAULT_VALUES } from "./contact-form-schema";
|
|
7
|
+
import { useContactSubmit } from "./use-contact-submit";
|
|
8
|
+
|
|
9
|
+
export function ContactForm() {
|
|
10
|
+
const form = useForm<ContactFormValues>({
|
|
11
|
+
resolver: zodResolver(contactFormSchema),
|
|
12
|
+
defaultValues: DEFAULT_VALUES,
|
|
13
|
+
});
|
|
14
|
+
const { isSubmitting, onSubmit } = useContactSubmit();
|
|
15
|
+
|
|
16
|
+
async function handleSubmit(data: ContactFormValues) {
|
|
17
|
+
const ok = await onSubmit(data);
|
|
18
|
+
if (ok) form.reset();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Form {...form}>
|
|
23
|
+
<form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-4">
|
|
24
|
+
<FormField control={form.control} name="name" render={({ field }) => (
|
|
25
|
+
<FormItem>
|
|
26
|
+
<FormLabel>Name *</FormLabel>
|
|
27
|
+
<FormControl>
|
|
28
|
+
<input {...field} placeholder="Your name"
|
|
29
|
+
className="h-12 w-full rounded-lg border border-border bg-background px-4 focus:outline-none focus:ring-2 focus:ring-ring" />
|
|
30
|
+
</FormControl>
|
|
31
|
+
<FormMessage />
|
|
32
|
+
</FormItem>
|
|
33
|
+
)} />
|
|
34
|
+
|
|
35
|
+
<FormField control={form.control} name="phone" render={({ field }) => (
|
|
36
|
+
<FormItem>
|
|
37
|
+
<FormLabel>Phone *</FormLabel>
|
|
38
|
+
<FormControl>
|
|
39
|
+
<input {...field} type="tel" placeholder="+84 xxx xxx xxx"
|
|
40
|
+
className="h-12 w-full rounded-lg border border-border bg-background px-4 focus:outline-none focus:ring-2 focus:ring-ring" />
|
|
41
|
+
</FormControl>
|
|
42
|
+
<FormMessage />
|
|
43
|
+
</FormItem>
|
|
44
|
+
)} />
|
|
45
|
+
|
|
46
|
+
<FormField control={form.control} name="email" render={({ field }) => (
|
|
47
|
+
<FormItem>
|
|
48
|
+
<FormLabel>Email *</FormLabel>
|
|
49
|
+
<FormControl>
|
|
50
|
+
<input {...field} type="email" placeholder="you@example.com"
|
|
51
|
+
className="h-12 w-full rounded-lg border border-border bg-background px-4 focus:outline-none focus:ring-2 focus:ring-ring" />
|
|
52
|
+
</FormControl>
|
|
53
|
+
<FormMessage />
|
|
54
|
+
</FormItem>
|
|
55
|
+
)} />
|
|
56
|
+
|
|
57
|
+
<FormField control={form.control} name="message" render={({ field }) => (
|
|
58
|
+
<FormItem>
|
|
59
|
+
<FormLabel>Message</FormLabel>
|
|
60
|
+
<FormControl>
|
|
61
|
+
<textarea {...field} rows={4} placeholder="How can we help?"
|
|
62
|
+
className="w-full resize-none rounded-lg border border-border bg-background px-4 py-3 focus:outline-none focus:ring-2 focus:ring-ring" />
|
|
63
|
+
</FormControl>
|
|
64
|
+
<FormMessage />
|
|
65
|
+
</FormItem>
|
|
66
|
+
)} />
|
|
67
|
+
|
|
68
|
+
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
|
69
|
+
{isSubmitting ? "Sending..." : "Send Message"}
|
|
70
|
+
</Button>
|
|
71
|
+
</form>
|
|
72
|
+
</Form>
|
|
73
|
+
);
|
|
74
|
+
}
|
package/templates/nextjs/optional/sections/contact/files/components/sections/contact-section.tsx
CHANGED
|
@@ -1,34 +1,7 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
import { useState } from "react";
|
|
3
1
|
import { motion } from "motion/react";
|
|
4
|
-
import {
|
|
5
|
-
import { toast } from "sonner";
|
|
6
|
-
|
|
7
|
-
interface FormData {
|
|
8
|
-
name: string;
|
|
9
|
-
email: string;
|
|
10
|
-
message: string;
|
|
11
|
-
}
|
|
2
|
+
import { ContactForm } from "./contact-form";
|
|
12
3
|
|
|
13
4
|
export default function ContactSection() {
|
|
14
|
-
const [form, setForm] = useState<FormData>({ name: "", email: "", message: "" });
|
|
15
|
-
const [submitting, setSubmitting] = useState(false);
|
|
16
|
-
|
|
17
|
-
const handleSubmit = async (e: React.FormEvent) => {
|
|
18
|
-
e.preventDefault();
|
|
19
|
-
setSubmitting(true);
|
|
20
|
-
try {
|
|
21
|
-
// Replace with your actual form submission logic
|
|
22
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
23
|
-
toast.success("Message sent! We'll get back to you soon.");
|
|
24
|
-
setForm({ name: "", email: "", message: "" });
|
|
25
|
-
} catch {
|
|
26
|
-
toast.error("Failed to send. Please try again.");
|
|
27
|
-
} finally {
|
|
28
|
-
setSubmitting(false);
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
|
|
32
5
|
return (
|
|
33
6
|
<section id="contact" className="bg-secondary/30 py-24">
|
|
34
7
|
<div className="content-container max-w-2xl">
|
|
@@ -43,35 +16,7 @@ export default function ContactSection() {
|
|
|
43
16
|
<h2 className="text-4xl font-bold">Get in touch</h2>
|
|
44
17
|
<p className="text-lg text-muted-foreground">We'd love to hear from you.</p>
|
|
45
18
|
</div>
|
|
46
|
-
<
|
|
47
|
-
<input
|
|
48
|
-
type="text"
|
|
49
|
-
placeholder="Your name"
|
|
50
|
-
required
|
|
51
|
-
value={form.name}
|
|
52
|
-
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
53
|
-
className="w-full rounded-lg border border-border bg-background px-4 py-3 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
54
|
-
/>
|
|
55
|
-
<input
|
|
56
|
-
type="email"
|
|
57
|
-
placeholder="Email address"
|
|
58
|
-
required
|
|
59
|
-
value={form.email}
|
|
60
|
-
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
|
61
|
-
className="w-full rounded-lg border border-border bg-background px-4 py-3 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
62
|
-
/>
|
|
63
|
-
<textarea
|
|
64
|
-
placeholder="Your message"
|
|
65
|
-
required
|
|
66
|
-
rows={5}
|
|
67
|
-
value={form.message}
|
|
68
|
-
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
|
69
|
-
className="w-full resize-none rounded-lg border border-border bg-background px-4 py-3 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
70
|
-
/>
|
|
71
|
-
<Button type="submit" className="w-full" disabled={submitting}>
|
|
72
|
-
{submitting ? "Sending..." : "Send message"}
|
|
73
|
-
</Button>
|
|
74
|
-
</form>
|
|
19
|
+
<ContactForm />
|
|
75
20
|
</motion.div>
|
|
76
21
|
</div>
|
|
77
22
|
</section>
|
package/templates/nextjs/optional/sections/contact/files/components/sections/use-contact-submit.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import { sendContact } from "@/lib/apis/contact";
|
|
5
|
+
import type { ContactFormValues } from "./contact-form-schema";
|
|
6
|
+
|
|
7
|
+
export function useContactSubmit() {
|
|
8
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
9
|
+
|
|
10
|
+
async function onSubmit(data: ContactFormValues) {
|
|
11
|
+
setIsSubmitting(true);
|
|
12
|
+
try {
|
|
13
|
+
await sendContact(data);
|
|
14
|
+
toast.success("Message sent successfully!");
|
|
15
|
+
return true;
|
|
16
|
+
} catch {
|
|
17
|
+
toast.error("Failed to send. Please try again.");
|
|
18
|
+
return false;
|
|
19
|
+
} finally {
|
|
20
|
+
setIsSubmitting(false);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { isSubmitting, onSubmit };
|
|
25
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import * as LabelPrimitive from "@radix-ui/react-label";
|
|
4
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
5
|
+
import {
|
|
6
|
+
Controller,
|
|
7
|
+
type ControllerProps,
|
|
8
|
+
type FieldPath,
|
|
9
|
+
type FieldValues,
|
|
10
|
+
FormProvider,
|
|
11
|
+
useFormContext,
|
|
12
|
+
} from "react-hook-form";
|
|
13
|
+
import { cn } from "@/lib/utils";
|
|
14
|
+
import { Label } from "@/components/ui/label";
|
|
15
|
+
|
|
16
|
+
const Form = FormProvider;
|
|
17
|
+
|
|
18
|
+
type FormFieldContextValue<
|
|
19
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
20
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
21
|
+
> = { name: TName };
|
|
22
|
+
|
|
23
|
+
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
|
24
|
+
|
|
25
|
+
const FormField = <
|
|
26
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
27
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
28
|
+
>({
|
|
29
|
+
...props
|
|
30
|
+
}: ControllerProps<TFieldValues, TName>) => (
|
|
31
|
+
<FormFieldContext.Provider value={{ name: props.name }}>
|
|
32
|
+
<Controller {...props} />
|
|
33
|
+
</FormFieldContext.Provider>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
type FormItemContextValue = { id: string };
|
|
37
|
+
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
|
38
|
+
|
|
39
|
+
const useFormField = () => {
|
|
40
|
+
const fieldContext = React.useContext(FormFieldContext);
|
|
41
|
+
const itemContext = React.useContext(FormItemContext);
|
|
42
|
+
const { getFieldState, formState } = useFormContext();
|
|
43
|
+
const fieldState = getFieldState(fieldContext.name, formState);
|
|
44
|
+
if (!fieldContext) throw new Error("useFormField must be used within <FormField>");
|
|
45
|
+
const { id } = itemContext;
|
|
46
|
+
return {
|
|
47
|
+
id,
|
|
48
|
+
name: fieldContext.name,
|
|
49
|
+
formItemId: `${id}-form-item`,
|
|
50
|
+
formDescriptionId: `${id}-form-item-description`,
|
|
51
|
+
formMessageId: `${id}-form-item-message`,
|
|
52
|
+
...fieldState,
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
57
|
+
({ className, ...props }, ref) => {
|
|
58
|
+
const id = React.useId();
|
|
59
|
+
return (
|
|
60
|
+
<FormItemContext.Provider value={{ id }}>
|
|
61
|
+
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
|
62
|
+
</FormItemContext.Provider>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
FormItem.displayName = "FormItem";
|
|
67
|
+
|
|
68
|
+
const FormLabel = React.forwardRef<
|
|
69
|
+
React.ElementRef<typeof LabelPrimitive.Root>,
|
|
70
|
+
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
|
71
|
+
>(({ className, ...props }, ref) => {
|
|
72
|
+
const { error, formItemId } = useFormField();
|
|
73
|
+
return (
|
|
74
|
+
<Label
|
|
75
|
+
ref={ref}
|
|
76
|
+
className={cn(error && "text-destructive", className)}
|
|
77
|
+
htmlFor={formItemId}
|
|
78
|
+
{...props}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
FormLabel.displayName = "FormLabel";
|
|
83
|
+
|
|
84
|
+
const FormControl = React.forwardRef<
|
|
85
|
+
React.ElementRef<typeof Slot>,
|
|
86
|
+
React.ComponentPropsWithoutRef<typeof Slot>
|
|
87
|
+
>(({ ...props }, ref) => {
|
|
88
|
+
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
|
89
|
+
return (
|
|
90
|
+
<Slot
|
|
91
|
+
ref={ref}
|
|
92
|
+
id={formItemId}
|
|
93
|
+
aria-describedby={!error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`}
|
|
94
|
+
aria-invalid={!!error}
|
|
95
|
+
{...props}
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
FormControl.displayName = "FormControl";
|
|
100
|
+
|
|
101
|
+
const FormDescription = React.forwardRef<
|
|
102
|
+
HTMLParagraphElement,
|
|
103
|
+
React.HTMLAttributes<HTMLParagraphElement>
|
|
104
|
+
>(({ className, ...props }, ref) => {
|
|
105
|
+
const { formDescriptionId } = useFormField();
|
|
106
|
+
return (
|
|
107
|
+
<p ref={ref} id={formDescriptionId} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
FormDescription.displayName = "FormDescription";
|
|
111
|
+
|
|
112
|
+
const FormMessage = React.forwardRef<
|
|
113
|
+
HTMLParagraphElement,
|
|
114
|
+
React.HTMLAttributes<HTMLParagraphElement>
|
|
115
|
+
>(({ className, children, ...props }, ref) => {
|
|
116
|
+
const { error, formMessageId } = useFormField();
|
|
117
|
+
const body = error ? String(error?.message) : children;
|
|
118
|
+
if (!body) return null;
|
|
119
|
+
return (
|
|
120
|
+
<p ref={ref} id={formMessageId} className={cn("text-sm font-medium text-destructive", className)} {...props}>
|
|
121
|
+
{body}
|
|
122
|
+
</p>
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
FormMessage.displayName = "FormMessage";
|
|
126
|
+
|
|
127
|
+
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import * as LabelPrimitive from "@radix-ui/react-label";
|
|
4
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
const labelVariants = cva(
|
|
8
|
+
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const Label = React.forwardRef<
|
|
12
|
+
React.ElementRef<typeof LabelPrimitive.Root>,
|
|
13
|
+
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
|
14
|
+
VariantProps<typeof labelVariants>
|
|
15
|
+
>(({ className, ...props }, ref) => (
|
|
16
|
+
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
|
17
|
+
));
|
|
18
|
+
Label.displayName = LabelPrimitive.Root.displayName;
|
|
19
|
+
|
|
20
|
+
export { Label };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ContactFormValues } from "@/components/sections/contact-form-schema";
|
|
2
|
+
|
|
3
|
+
export async function sendContact(data: ContactFormValues) {
|
|
4
|
+
const res = await fetch("/api/contact", {
|
|
5
|
+
method: "POST",
|
|
6
|
+
headers: { "Content-Type": "application/json" },
|
|
7
|
+
body: JSON.stringify(data),
|
|
8
|
+
});
|
|
9
|
+
if (!res.ok) throw new Error("Contact API error");
|
|
10
|
+
return res.json();
|
|
11
|
+
}
|