Update components/kleap-form.tsx

This commit is contained in:
kleap-admin 2026-01-16 10:54:51 +00:00
parent 791c06e560
commit 9f685cd693
1 changed files with 412 additions and 0 deletions

412
components/kleap-form.tsx Normal file
View File

@ -0,0 +1,412 @@
"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 "./ui/button";
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}
className="block w-full bg-white dark:bg-neutral-900 px-4 rounded-md border-0 py-1.5 shadow-aceternity text-black placeholder:text-gray-400 focus:ring-2 focus:ring-neutral-400 focus:outline-none sm:text-sm sm:leading-6 dark:text-white"
{...(formField as any)}
/>
) : field.type === "select" ? (
<select
id={field.name}
className="block w-full bg-white dark:bg-neutral-900 px-4 rounded-md border-0 py-1.5 shadow-aceternity text-black placeholder:text-gray-400 focus:ring-2 focus:ring-neutral-400 focus:outline-none sm:text-sm sm:leading-6 dark:text-white"
{...(formField as any)}
>
<option value="">Select an option</option>
{field.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : field.type === "checkbox" ? (
<div className="flex items-center">
<input
type="checkbox"
id={field.name}
checked={Boolean(formField.value) || false}
onChange={(e) => formField.onChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label
htmlFor={field.name}
className="ml-2 text-sm text-gray-700 dark:text-gray-300"
>
{field.placeholder || field.label}
</label>
</div>
) : field.type === "radio" && field.options ? (
<div className="space-y-2">
{field.options.map((option) => (
<label key={option} className="flex items-center">
<input
type="radio"
value={option}
checked={formField.value === option}
onChange={() => formField.onChange(option)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">
{option}
</span>
</label>
))}
</div>
) : (
<input
id={field.name}
type={field.type}
placeholder={field.placeholder}
className="block w-full bg-white dark:bg-neutral-900 px-4 rounded-md border-0 py-1.5 shadow-aceternity text-black placeholder:text-gray-400 focus:ring-2 focus:ring-neutral-400 focus:outline-none sm:text-sm sm:leading-6 dark:text-white"
{...(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>
);
}