420 lines
12 KiB
TypeScript
420 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { useForm } from "react-hook-form";
|
|
import { z } from "zod";
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
FormItem,
|
|
FormMessage,
|
|
} from "@/components/ui/form";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
interface KleapFormField {
|
|
name: string;
|
|
label: string;
|
|
type:
|
|
| "text"
|
|
| "email"
|
|
| "tel"
|
|
| "textarea"
|
|
| "select"
|
|
| "checkbox"
|
|
| "radio"
|
|
| "url"
|
|
| "number";
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
options?: string[]; // For select, radio
|
|
rows?: number; // For textarea
|
|
validation?: {
|
|
min?: number;
|
|
max?: number;
|
|
pattern?: RegExp;
|
|
message?: string;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* KleapForm - Universal form component with automatic submission to Kleap API
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <KleapForm
|
|
* formId="contact"
|
|
* title="Contact Us"
|
|
* fields={[
|
|
* { name: "name", label: "Name", type: "text", required: true },
|
|
* { name: "email", label: "Email", type: "email", required: true },
|
|
* { name: "message", label: "Message", type: "textarea", required: true }
|
|
* ]}
|
|
* submitText="Send Message" // ⚠️ Use submitText, NOT submitButtonText
|
|
* successMessage="Thank you! We'll get back to you soon."
|
|
* />
|
|
* ```
|
|
*/
|
|
interface KleapFormProps {
|
|
formId: string;
|
|
title?: string;
|
|
description?: string;
|
|
fields: KleapFormField[];
|
|
/** Text for submit button - Use "submitText" NOT "submitButtonText" */
|
|
submitText?: string;
|
|
/** Message shown after successful submission */
|
|
successMessage?: string;
|
|
className?: string;
|
|
honeypot?: boolean; // Anti-spam honeypot field
|
|
}
|
|
|
|
export function KleapForm({
|
|
formId,
|
|
title,
|
|
description,
|
|
fields,
|
|
submitText = "Submit",
|
|
successMessage = "Thank you! Your submission has been received.",
|
|
className = "",
|
|
honeypot = true,
|
|
}: KleapFormProps) {
|
|
const [submitted, setSubmitted] = useState(false);
|
|
const [submitError, setSubmitError] = useState("");
|
|
|
|
// Dynamically create Zod schema based on fields
|
|
const schemaShape: Record<string, z.ZodType> = {};
|
|
|
|
fields.forEach((field) => {
|
|
let fieldSchema: z.ZodType;
|
|
|
|
switch (field.type) {
|
|
case "email":
|
|
fieldSchema = z
|
|
.string()
|
|
.email(field.validation?.message || "Please enter a valid email");
|
|
break;
|
|
case "number":
|
|
fieldSchema = z.coerce.number();
|
|
if (field.validation?.min !== undefined) {
|
|
fieldSchema = (fieldSchema as z.ZodNumber).min(
|
|
field.validation.min,
|
|
field.validation.message,
|
|
);
|
|
}
|
|
if (field.validation?.max !== undefined) {
|
|
fieldSchema = (fieldSchema as z.ZodNumber).max(
|
|
field.validation.max,
|
|
field.validation.message,
|
|
);
|
|
}
|
|
break;
|
|
case "url":
|
|
fieldSchema = z
|
|
.string()
|
|
.url(field.validation?.message || "Please enter a valid URL");
|
|
break;
|
|
case "checkbox":
|
|
fieldSchema = z.boolean();
|
|
break;
|
|
default:
|
|
fieldSchema = z.string();
|
|
if (field.validation?.min !== undefined) {
|
|
fieldSchema = (fieldSchema as z.ZodString).min(
|
|
field.validation.min,
|
|
field.validation.message,
|
|
);
|
|
}
|
|
if (field.validation?.max !== undefined) {
|
|
fieldSchema = (fieldSchema as z.ZodString).max(
|
|
field.validation.max,
|
|
field.validation.message,
|
|
);
|
|
}
|
|
if (field.validation?.pattern) {
|
|
fieldSchema = (fieldSchema as z.ZodString).regex(
|
|
field.validation.pattern,
|
|
field.validation.message,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (field.required && field.type !== "checkbox") {
|
|
fieldSchema = fieldSchema.refine((val) => val !== "", {
|
|
message: `${field.label} is required`,
|
|
});
|
|
}
|
|
|
|
schemaShape[field.name] =
|
|
field.required || field.type === "checkbox"
|
|
? fieldSchema
|
|
: fieldSchema.optional();
|
|
});
|
|
|
|
const formSchema = z.object(schemaShape);
|
|
type FormData = z.infer<typeof formSchema>;
|
|
|
|
const form = useForm<FormData>({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: fields.reduce(
|
|
(acc, field) => {
|
|
acc[field.name] = field.type === "checkbox" ? false : "";
|
|
return acc;
|
|
},
|
|
{} as Record<string, any>,
|
|
),
|
|
});
|
|
|
|
async function onSubmit(values: FormData) {
|
|
setSubmitError("");
|
|
|
|
try {
|
|
// Get app_id from environment or URL
|
|
const envAppId = process.env.NEXT_PUBLIC_APP_ID;
|
|
const urlAppId = new URLSearchParams(window.location.search).get(
|
|
"app_id",
|
|
);
|
|
const appId = envAppId || urlAppId || "";
|
|
|
|
// 🔍 DEBUG HELPER - Log app_id sources
|
|
console.group("🔍 KLEAP FORM DEBUG - App ID Detection");
|
|
|
|
if (!appId) {
|
|
console.error(
|
|
"🚨 CRITICAL: app_id is empty! Form submission will fail.",
|
|
);
|
|
}
|
|
console.groupEnd();
|
|
|
|
// Create form data
|
|
const formData = new FormData();
|
|
formData.append("app_id", appId);
|
|
formData.append("form_id", formId);
|
|
formData.append("form_name", title || formId);
|
|
|
|
// Add all form values
|
|
Object.entries(values).forEach(([key, value]) => {
|
|
formData.append(key, String(value));
|
|
});
|
|
|
|
// 📤 DEBUG HELPER - Log request details
|
|
console.group("📤 KLEAP FORM REQUEST");
|
|
for (const [_key, _value] of formData.entries()) {
|
|
// Debug logging entries
|
|
}
|
|
console.groupEnd();
|
|
|
|
// Submit to Kleap's form API
|
|
const response = await fetch("https://form.kleap.co", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
// 📥 DEBUG HELPER - Log response details
|
|
const responseText = await response.text();
|
|
console.group("📥 KLEAP FORM RESPONSE");
|
|
|
|
try {
|
|
const responseJson = JSON.parse(responseText);
|
|
if (!responseJson.success && responseJson.error) {
|
|
console.error("❌ API Error:", responseJson.error);
|
|
}
|
|
} catch {
|
|
// Handle error silently
|
|
}
|
|
console.groupEnd();
|
|
|
|
if (response.ok) {
|
|
setSubmitted(true);
|
|
form.reset();
|
|
} else {
|
|
throw new Error(
|
|
`Failed to submit form: ${response.status} ${responseText}`,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
console.error("Form submission error:", e);
|
|
setSubmitError(
|
|
"Sorry, there was an error submitting your form. Please try again.",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (submitted) {
|
|
return (
|
|
<div
|
|
className={`p-8 bg-green-50 dark:bg-green-900/20 rounded-lg text-center ${className}`}
|
|
>
|
|
<div className="text-green-600 dark:text-green-400 mb-4">
|
|
<svg
|
|
className="w-16 h-16 mx-auto"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M5 13l4 4L19 7"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<p className="text-lg font-medium text-green-800 dark:text-green-300">
|
|
{successMessage}
|
|
</p>
|
|
<Button
|
|
onClick={() => setSubmitted(false)}
|
|
variant="outline"
|
|
className="mt-4"
|
|
>
|
|
Submit Another Response
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<form
|
|
onSubmit={form.handleSubmit(onSubmit)}
|
|
className={`space-y-6 ${className}`}
|
|
>
|
|
{title && (
|
|
<h2 className="text-2xl font-bold leading-9 tracking-tight text-black dark:text-white">
|
|
{title}
|
|
</h2>
|
|
)}
|
|
{description && (
|
|
<p className="text-muted dark:text-muted-dark text-sm max-w-sm">
|
|
{description}
|
|
</p>
|
|
)}
|
|
|
|
{fields.map((field) => (
|
|
<FormField
|
|
key={field.name}
|
|
control={form.control}
|
|
name={field.name}
|
|
render={({ field: formField }) => (
|
|
<FormItem>
|
|
<Label
|
|
htmlFor={field.name}
|
|
className="block text-sm font-medium leading-6 text-neutral-700 dark:text-muted-dark"
|
|
>
|
|
{field.label}
|
|
{field.required && (
|
|
<span className="text-red-500 ml-1">*</span>
|
|
)}
|
|
</Label>
|
|
<FormControl>
|
|
<div className="mt-2">
|
|
{field.type === "textarea" ? (
|
|
<Textarea
|
|
id={field.name}
|
|
rows={field.rows || 5}
|
|
placeholder={field.placeholder}
|
|
{...(formField as any)}
|
|
/>
|
|
) : field.type === "select" ? (
|
|
<Select
|
|
onValueChange={formField.onChange}
|
|
defaultValue={formField.value}
|
|
>
|
|
<SelectTrigger id={field.name}>
|
|
<SelectValue placeholder={field.placeholder || "Select an option"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{field.options?.map((option) => (
|
|
<SelectItem key={option} value={option}>
|
|
{option}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : field.type === "checkbox" ? (
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id={field.name}
|
|
checked={Boolean(formField.value) || false}
|
|
onCheckedChange={formField.onChange}
|
|
/>
|
|
<Label
|
|
htmlFor={field.name}
|
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
>
|
|
{field.placeholder || field.label}
|
|
</Label>
|
|
</div>
|
|
) : field.type === "radio" && field.options ? (
|
|
<RadioGroup
|
|
onValueChange={formField.onChange}
|
|
defaultValue={formField.value}
|
|
className="flex flex-col space-y-1"
|
|
>
|
|
{field.options.map((option) => (
|
|
<div key={option} className="flex items-center space-x-2">
|
|
<RadioGroupItem value={option} id={`${field.name}-${option}`} />
|
|
<Label htmlFor={`${field.name}-${option}`}>{option}</Label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
) : (
|
|
<Input
|
|
id={field.name}
|
|
type={field.type}
|
|
placeholder={field.placeholder}
|
|
{...(formField as any)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
))}
|
|
|
|
{/* Honeypot field to catch bots */}
|
|
{honeypot && (
|
|
<input
|
|
type="text"
|
|
name="_honeypot"
|
|
style={{ display: "none" }}
|
|
tabIndex={-1}
|
|
autoComplete="off"
|
|
/>
|
|
)}
|
|
|
|
{submitError && (
|
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
|
<p className="text-sm text-red-600 dark:text-red-400">
|
|
{submitError}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
type="submit"
|
|
disabled={form.formState.isSubmitting}
|
|
className="w-full"
|
|
>
|
|
{form.formState.isSubmitting ? "Submitting..." : submitText}
|
|
</Button>
|
|
</form>
|
|
</Form>
|
|
);
|
|
}
|