Update components/kleap-form.tsx
This commit is contained in:
parent
4ea0e024ab
commit
a0112afbb8
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue