Update components/kleap-form.tsx
This commit is contained in:
parent
33f86fd9d4
commit
0be67388ad
|
|
@ -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