Compare commits
10 Commits
62bff676d2
...
85d1fbb20d
| Author | SHA1 | Date | |
|---|---|---|---|
| 85d1fbb20d | |||
| 635ca32b3a | |||
| 1bf3b91c12 | |||
| 04545fc50e | |||
| e63d2d3156 | |||
| 2229ffcd7a | |||
| 8518c2c140 | |||
| 1ee57a4ec1 | |||
| 7b6cd4d965 | |||
| 5fc6469752 |
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
+10
-6
@@ -1,6 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { headers } from "next/headers";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
@@ -19,9 +18,17 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL("https://opplenz.com"),
|
||||||
title: "Opp lens - AI-Powered Tender Management",
|
title: "Opp lens - AI-Powered Tender Management",
|
||||||
description:
|
description:
|
||||||
"Democratizing Tender Access for SMEs with AI-Powered Tender Management.",
|
"Democratizing Tender Access for SMEs with AI-Powered Tender Management.",
|
||||||
|
alternates: {
|
||||||
|
canonical: "/",
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/fav-icon.svg",
|
icon: "/fav-icon.svg",
|
||||||
},
|
},
|
||||||
@@ -73,13 +80,11 @@ function ContactInfo({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const domain = (await headers()).get("host")?.replace("www.", "");
|
|
||||||
|
|
||||||
const contactInfo = [
|
const contactInfo = [
|
||||||
{
|
{
|
||||||
icon: "/Message.svg",
|
icon: "/Message.svg",
|
||||||
@@ -120,7 +125,7 @@ export default async function RootLayout({
|
|||||||
</Link>
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<NavigationBar host={domain ?? "opplens.com"} />
|
<NavigationBar />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -151,7 +156,6 @@ export default async function RootLayout({
|
|||||||
alt="Opp lens logo"
|
alt="Opp lens logo"
|
||||||
width={180}
|
width={180}
|
||||||
height={30}
|
height={30}
|
||||||
priority
|
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-black my-2">
|
<p className="text-black my-2">
|
||||||
|
|||||||
+39
-5
@@ -11,10 +11,24 @@ export const metadata: Metadata = {
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Opp lens - AI-Powered Tender Management",
|
title: "Opp lens - AI-Powered Tender Management",
|
||||||
description: "Win more tenders automatically with our AI-powered platform",
|
description: "Win more tenders automatically with our AI-powered platform",
|
||||||
|
url: "https://opplenz.com/",
|
||||||
type: "website",
|
type: "website",
|
||||||
locale: "en_US",
|
locale: "en_US",
|
||||||
siteName: "Opp lens",
|
siteName: "Opp lens",
|
||||||
},
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "Opp lens - AI-Powered Tender Management",
|
||||||
|
description: "Win more tenders automatically with our AI-powered platform",
|
||||||
|
images: ["/images/opp-lens-mobile.jpg"],
|
||||||
|
},
|
||||||
|
alternates: {
|
||||||
|
canonical: "https://opplenz.com/",
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/fav-icon.svg",
|
icon: "/fav-icon.svg",
|
||||||
},
|
},
|
||||||
@@ -60,7 +74,7 @@ export default function Home() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-12 md:mx-52 lg:mx-28 -z-20 relative flex justify-center h-[650px] lg:h-[505px]">
|
<div className="mx-12 md:mx-52 lg:mx-28 -z-20 relative flex justify-center h-[650px] lg:h-[510px]">
|
||||||
<span className="opacity-0 lg:opacity-100">
|
<span className="opacity-0 lg:opacity-100">
|
||||||
<Image
|
<Image
|
||||||
src={"/dynamic-island.svg"}
|
src={"/dynamic-island.svg"}
|
||||||
@@ -79,14 +93,34 @@ export default function Home() {
|
|||||||
className="absolute top-4 left-1/2 -translate-x-1/2"
|
className="absolute top-4 left-1/2 -translate-x-1/2"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<Image
|
{/* <Image
|
||||||
className="-z-10 border-8 border-black rounded-4xl overflow-hidden h-full w-full"
|
className="-z-10 border-8 border-black rounded-4xl overflow-hidden h-full w-full"
|
||||||
src="/mobile.gif"
|
src="/mobile.gif"
|
||||||
width={300}
|
width={300}
|
||||||
height={920}
|
height={920}
|
||||||
loading="lazy"
|
sizes="(max-width: 1024px) 60vw, 300px"
|
||||||
alt="phone"
|
priority
|
||||||
/>
|
alt="Opp lens application preview on a mobile phone"
|
||||||
|
/> */}
|
||||||
|
<div className="-z-10 border-8 border-black rounded-4xl overflow-hidden h-full w-full">
|
||||||
|
<video
|
||||||
|
className="max-h-full w-full object-cover"
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="auto"
|
||||||
|
aria-label="Opp lens application preview on a mobile phone">
|
||||||
|
<source
|
||||||
|
src="/mobile.webm"
|
||||||
|
type="video/webm"
|
||||||
|
/>
|
||||||
|
<source
|
||||||
|
src="/mobile.mp4"
|
||||||
|
type="video/mp4"
|
||||||
|
/>
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="flex flex-col gap-6 bg-gradient-to-b from-[#011132] to-[#012B80] text-white rounded-[56px] relative py-13 px-14 -mt-36 z-20 md:grid lg:grid-cols-3 lg:flex-row">
|
<section className="flex flex-col gap-6 bg-gradient-to-b from-[#011132] to-[#012B80] text-white rounded-[56px] relative py-13 px-14 -mt-36 z-20 md:grid lg:grid-cols-3 lg:flex-row">
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ToastContainer, Zoom } from "react-toastify";
|
import { ToastContainer, Zoom } from "react-toastify";
|
||||||
|
|
||||||
import "../../globals.css";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import GoogleAnalytics from "@/app/_components/GoogleAnalytics";
|
import GoogleAnalytics from "@/app/_components/GoogleAnalytics";
|
||||||
|
import "@/app/globals.css";
|
||||||
|
import { Suspense } from "react";
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -18,8 +18,14 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
metadataBase: new URL("https://opplenz.com"),
|
||||||
description: "Generated by create next app",
|
title: "Page Not Found | Opp lens",
|
||||||
|
description:
|
||||||
|
"Opp lens helps SMEs find relevant tenders, automate document handling, and get real-time alerts for new opportunities.",
|
||||||
|
robots: {
|
||||||
|
index: false,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/fav-icon.svg",
|
icon: "/fav-icon.svg",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function InputGroup({
|
|||||||
<div>
|
<div>
|
||||||
<div className="relative w-full my-3">
|
<div className="relative w-full my-3">
|
||||||
<input
|
<input
|
||||||
|
autoComplete="off"
|
||||||
type={type}
|
type={type}
|
||||||
className={`border ${
|
className={`border ${
|
||||||
error
|
error
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
const NavigationBar = ({ host }: { host: string }) => {
|
const NavigationBar = ({ host = "opplenz.com" }: { host?: string }) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function TextareaGroup({
|
|||||||
<div>
|
<div>
|
||||||
<div className="relative w-full my-3">
|
<div className="relative w-full my-3">
|
||||||
<textarea
|
<textarea
|
||||||
|
autoComplete="off"
|
||||||
className={`border ${
|
className={`border ${
|
||||||
error
|
error
|
||||||
? "border-red-500"
|
? "border-red-500"
|
||||||
|
|||||||
+23
-62
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { handleApiError } from "@/lib/apiErrorHandler";
|
||||||
|
import { validationRules } from "@/lib/validation";
|
||||||
import api from "@/service/api";
|
import api from "@/service/api";
|
||||||
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
@@ -14,6 +16,9 @@ type TContactUsForm = {
|
|||||||
phone: string;
|
phone: string;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FORM_FIELDS = ["full_name", "email", "phone", "message"];
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
columnsPerRow?: 1 | 2 | 3 | 4;
|
columnsPerRow?: 1 | 2 | 3 | 4;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
@@ -28,9 +33,11 @@ const ContactUsForm = ({
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors, isValid },
|
||||||
reset,
|
reset,
|
||||||
} = useForm<TContactUsForm>();
|
setError,
|
||||||
|
trigger,
|
||||||
|
} = useForm<TContactUsForm>({ mode: "onChange" });
|
||||||
|
|
||||||
const onCaptchaVerify = (token: string) => {
|
const onCaptchaVerify = (token: string) => {
|
||||||
setHcaptchaToken(token);
|
setHcaptchaToken(token);
|
||||||
@@ -41,6 +48,11 @@ const ContactUsForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (data: TContactUsForm) => {
|
const onSubmit = async (data: TContactUsForm) => {
|
||||||
|
const isFormValid = await trigger();
|
||||||
|
if (!isFormValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!hcaptchaToken) {
|
if (!hcaptchaToken) {
|
||||||
toast.error("Please complete the captcha verification");
|
toast.error("Please complete the captcha verification");
|
||||||
return;
|
return;
|
||||||
@@ -55,9 +67,7 @@ const ContactUsForm = ({
|
|||||||
setHcaptchaToken(null);
|
setHcaptchaToken(null);
|
||||||
captchaRef.current?.resetCaptcha();
|
captchaRef.current?.resetCaptcha();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
handleApiError(error, setError, toast.error, FORM_FIELDS);
|
||||||
toast.error("Something went wrong");
|
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(() => false);
|
setIsLoading(() => false);
|
||||||
}
|
}
|
||||||
@@ -84,20 +94,7 @@ const ContactUsForm = ({
|
|||||||
id="full_name"
|
id="full_name"
|
||||||
label="Full Name"
|
label="Full Name"
|
||||||
register={register}
|
register={register}
|
||||||
validation={{
|
validation={validationRules.fullName}
|
||||||
required: {
|
|
||||||
message: "This field is required",
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
minLength: {
|
|
||||||
value: 2,
|
|
||||||
message: "Please enter at least 2 characters",
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 100,
|
|
||||||
message: "Please enter at most 100 characters",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
type="text"
|
type="text"
|
||||||
error={errors.full_name?.message}
|
error={errors.full_name?.message}
|
||||||
/>
|
/>
|
||||||
@@ -105,16 +102,7 @@ const ContactUsForm = ({
|
|||||||
id="email"
|
id="email"
|
||||||
label="Email"
|
label="Email"
|
||||||
register={register}
|
register={register}
|
||||||
validation={{
|
validation={validationRules.email}
|
||||||
required: {
|
|
||||||
message: "This field is required",
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
pattern: {
|
|
||||||
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
||||||
message: "Please enter a valid email address",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
type="email"
|
type="email"
|
||||||
error={errors.email?.message}
|
error={errors.email?.message}
|
||||||
/>
|
/>
|
||||||
@@ -122,24 +110,7 @@ const ContactUsForm = ({
|
|||||||
id="phone"
|
id="phone"
|
||||||
label="Phone"
|
label="Phone"
|
||||||
register={register}
|
register={register}
|
||||||
validation={{
|
validation={validationRules.phone}
|
||||||
required: {
|
|
||||||
message: "This field is required",
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
minLength: {
|
|
||||||
value: 10,
|
|
||||||
message: "Please enter at least 10 characters",
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 20,
|
|
||||||
message: "Please enter at most 20 characters",
|
|
||||||
},
|
|
||||||
pattern: {
|
|
||||||
value: /^[+\d]+$/,
|
|
||||||
message: "Please enter a valid phone number",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
type="text"
|
type="text"
|
||||||
error={errors.phone?.message}
|
error={errors.phone?.message}
|
||||||
/>
|
/>
|
||||||
@@ -148,20 +119,7 @@ const ContactUsForm = ({
|
|||||||
label="Message"
|
label="Message"
|
||||||
register={register}
|
register={register}
|
||||||
error={errors.message?.message}
|
error={errors.message?.message}
|
||||||
validation={{
|
validation={validationRules.message}
|
||||||
required: {
|
|
||||||
value: true,
|
|
||||||
message: "This field is required",
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 1000,
|
|
||||||
message: "Please enter at most 1000 characters",
|
|
||||||
},
|
|
||||||
minLength: {
|
|
||||||
value: 10,
|
|
||||||
message: "Please enter at least 10 characters",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className={`${colSpanClass}`}>
|
<div className={`${colSpanClass}`}>
|
||||||
<HCaptcha
|
<HCaptcha
|
||||||
@@ -172,7 +130,10 @@ const ContactUsForm = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${colSpanClass} flex justify-end`}>
|
<div className={`${colSpanClass} flex justify-end`}>
|
||||||
<button className="bg-(--primary) cursor-pointer w-fit text-white rounded-full py-4 px-6 flex justify-center items-center text-center">
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || isLoading || !hcaptchaToken}
|
||||||
|
className="bg-(--primary) cursor-pointer w-fit text-white rounded-full py-4 px-6 flex justify-center items-center text-center disabled:opacity-60 disabled:cursor-not-allowed">
|
||||||
{isLoading ? <Loading /> : buttonText}
|
{isLoading ? <Loading /> : buttonText}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+176
-48
@@ -1,8 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { handleApiError } from "@/lib/apiErrorHandler";
|
||||||
import { sanitizeText } from "@/lib/sanitize";
|
import { sanitizeText } from "@/lib/sanitize";
|
||||||
|
import { createFieldValidation } from "@/lib/validation";
|
||||||
import api from "@/service/api";
|
import api from "@/service/api";
|
||||||
import { CmsContact } from "@/types/TCms";
|
import { CmsContact } from "@/types/TCms";
|
||||||
import { useState } from "react";
|
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import InputGroup from "../_components/InputGroup";
|
import InputGroup from "../_components/InputGroup";
|
||||||
@@ -24,23 +27,108 @@ const InquiresForm = ({
|
|||||||
contactData,
|
contactData,
|
||||||
}: InquiresFormProps) => {
|
}: InquiresFormProps) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [hcaptchaToken, setHcaptchaToken] = useState<string | null>(null);
|
||||||
|
const captchaRef = useRef<HCaptcha>(null);
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors, isValid },
|
||||||
reset,
|
reset,
|
||||||
} = useForm<TInquiresForm>();
|
setError,
|
||||||
|
trigger,
|
||||||
|
} = useForm<TInquiresForm>({ mode: "onChange" });
|
||||||
|
|
||||||
|
const defaultFields = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
name: "full_name",
|
||||||
|
label: "Full Name",
|
||||||
|
placeholder: "",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "company_name",
|
||||||
|
label: "Company Name",
|
||||||
|
placeholder: "",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "work_email",
|
||||||
|
label: "Work Email",
|
||||||
|
placeholder: "",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "phone_number",
|
||||||
|
label: "Phone Number",
|
||||||
|
placeholder: "",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fields = contactData?.fields || defaultFields;
|
||||||
|
const formFieldNames = useMemo(() => fields.map((f) => f.name), [fields]);
|
||||||
|
|
||||||
|
const onCaptchaVerify = (token: string) => {
|
||||||
|
setHcaptchaToken(token);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCaptchaExpire = () => {
|
||||||
|
setHcaptchaToken(null);
|
||||||
|
};
|
||||||
const onSubmit = async (data: TInquiresForm) => {
|
const onSubmit = async (data: TInquiresForm) => {
|
||||||
|
const sanitizedData = Object.fromEntries(
|
||||||
|
Object.entries(data).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
typeof value === "string" ? value.trim() : value,
|
||||||
|
]),
|
||||||
|
) as TInquiresForm;
|
||||||
|
|
||||||
|
const requiredFields = fields.filter((field) => field.required);
|
||||||
|
let hasEmptyRequiredField = false;
|
||||||
|
|
||||||
|
requiredFields.forEach((field) => {
|
||||||
|
const value = sanitizedData[field.name];
|
||||||
|
if (!value) {
|
||||||
|
setError(field.name, {
|
||||||
|
type: "required",
|
||||||
|
message: `${field.label} is required`,
|
||||||
|
});
|
||||||
|
hasEmptyRequiredField = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasEmptyRequiredField) {
|
||||||
|
toast.error("Please complete all required fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFormValid = await trigger(formFieldNames);
|
||||||
|
if (!isFormValid) {
|
||||||
|
toast.error("Please correct invalid form fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hcaptchaToken) {
|
||||||
|
toast.error("Please complete the captcha verification");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = (await api.post("inquiries", { ...data })).data;
|
const response = (
|
||||||
|
await api.post("inquiries", {
|
||||||
|
...sanitizedData,
|
||||||
|
hcaptcha_token: hcaptchaToken,
|
||||||
|
})
|
||||||
|
).data;
|
||||||
toast.success(response.message);
|
toast.success(response.message);
|
||||||
reset();
|
reset();
|
||||||
|
setHcaptchaToken(null);
|
||||||
|
captchaRef.current?.resetCaptcha();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
handleApiError(error, setError, toast.error, formFieldNames);
|
||||||
toast.error("Something went wrong");
|
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -60,32 +148,77 @@ const InquiresForm = ({
|
|||||||
4: "lg:col-span-4",
|
4: "lg:col-span-4",
|
||||||
}[columnsPerRow];
|
}[columnsPerRow];
|
||||||
|
|
||||||
const defaultFields = [
|
|
||||||
{ name: "full_name", label: "Full Name", placeholder: "", required: false },
|
|
||||||
{
|
|
||||||
name: "company_name",
|
|
||||||
label: "Company Name",
|
|
||||||
placeholder: "",
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "work_email",
|
|
||||||
label: "Work Email",
|
|
||||||
placeholder: "",
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "phone_number",
|
|
||||||
label: "Phone Number",
|
|
||||||
placeholder: "",
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const fields = contactData?.fields || defaultFields;
|
|
||||||
const submitText =
|
const submitText =
|
||||||
sanitizeText(contactData?.submit_button_text) || buttonText || "Submit";
|
sanitizeText(contactData?.submit_button_text) || buttonText || "Submit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates validation rules based on field type with XSS protection
|
||||||
|
*/
|
||||||
|
const getFieldValidation = (field: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
}) => {
|
||||||
|
const isEmailField =
|
||||||
|
field.name.includes("email") || field.name === "work_email";
|
||||||
|
const isPhoneField =
|
||||||
|
field.name.includes("phone") || field.name === "phone_number";
|
||||||
|
const isStrictTextField =
|
||||||
|
field.name === "full_name" || field.name === "company_name";
|
||||||
|
|
||||||
|
if (isPhoneField) {
|
||||||
|
return createFieldValidation(field.label, field.required, {
|
||||||
|
pattern: {
|
||||||
|
value: /^[+\d\s()-]+$/,
|
||||||
|
message:
|
||||||
|
"Please enter a valid phone number (digits, +, spaces, parentheses, and dashes only)",
|
||||||
|
},
|
||||||
|
minLength: field.required
|
||||||
|
? { value: 10, message: "Phone number must be at least 10 digits" }
|
||||||
|
: undefined,
|
||||||
|
maxLength: {
|
||||||
|
value: 20,
|
||||||
|
message: "Phone number must not exceed 20 characters",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmailField) {
|
||||||
|
return createFieldValidation(field.label, field.required, {
|
||||||
|
pattern: {
|
||||||
|
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||||
|
message: "Please enter a valid email address",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStrictTextField) {
|
||||||
|
return createFieldValidation(field.label, field.required, {
|
||||||
|
minLength: field.required
|
||||||
|
? {
|
||||||
|
value: 2,
|
||||||
|
message: `${field.label} must be at least 2 characters`,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
maxLength: {
|
||||||
|
value: 200,
|
||||||
|
message: `${field.label} must not exceed 200 characters`,
|
||||||
|
},
|
||||||
|
pattern: {
|
||||||
|
value: /^[\p{L}\p{M}\p{N}\s'.,()&/_-]+$/u,
|
||||||
|
message: `${field.label} may only include letters, numbers, spaces, and common punctuation (no HTML or code)`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createFieldValidation(field.label, field.required, {
|
||||||
|
maxLength: {
|
||||||
|
value: 200,
|
||||||
|
message: `${field.label} must not exceed 200 characters`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className={`w-full px-4 lg:px-16 py-10 grid ${gridColsClass} gap-4`}
|
className={`w-full px-4 lg:px-16 py-10 grid ${gridColsClass} gap-4`}
|
||||||
@@ -95,8 +228,6 @@ const InquiresForm = ({
|
|||||||
field.name.includes("email") || field.name === "work_email"
|
field.name.includes("email") || field.name === "work_email"
|
||||||
? "email"
|
? "email"
|
||||||
: "text";
|
: "text";
|
||||||
const isPhoneField =
|
|
||||||
field.name.includes("phone") || field.name === "phone_number";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputGroup
|
<InputGroup
|
||||||
@@ -107,26 +238,23 @@ const InquiresForm = ({
|
|||||||
register={register}
|
register={register}
|
||||||
type={inputType}
|
type={inputType}
|
||||||
error={errors[field.name]?.message}
|
error={errors[field.name]?.message}
|
||||||
validation={
|
validation={getFieldValidation(field)}
|
||||||
isPhoneField
|
|
||||||
? {
|
|
||||||
pattern: {
|
|
||||||
value: /^[+\d]+$/,
|
|
||||||
message: "Please enter a valid phone number",
|
|
||||||
},
|
|
||||||
required: field.required
|
|
||||||
? "This field is required"
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
: field.required
|
|
||||||
? { required: "This field is required" }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<div className={`${colSpanClass}`}>
|
||||||
|
<HCaptcha
|
||||||
|
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY || ""}
|
||||||
|
onVerify={onCaptchaVerify}
|
||||||
|
onExpire={onCaptchaExpire}
|
||||||
|
ref={captchaRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className={`${colSpanClass} flex justify-end`}>
|
<div className={`${colSpanClass} flex justify-end`}>
|
||||||
<button className="bg-(--primary) cursor-pointer w-fit text-white rounded-full py-4 px-6">
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || isLoading || !hcaptchaToken}
|
||||||
|
className="bg-(--primary) cursor-pointer inline-flex items-center justify-center min-h-14 w-fit text-white rounded-full py-4 px-6 disabled:opacity-60 disabled:cursor-not-allowed">
|
||||||
{isLoading ? <Loading /> : submitText}
|
{isLoading ? <Loading /> : submitText}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const description =
|
||||||
|
"Opp lens helps SMEs find relevant tenders, automate document handling, and get real-time alerts for new opportunities.";
|
||||||
|
|
||||||
|
export default function GlobalNotFound() {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Page Not Found | Opp lens</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content={description}
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="robots"
|
||||||
|
content="noindex, follow"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
href="/fav-icon.svg"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main className="min-h-screen px-6 py-16 flex flex-col items-center justify-center text-center">
|
||||||
|
<h1 className="text-3xl font-bold text-[#013280]">Page not found</h1>
|
||||||
|
<p className="mt-4 max-w-xl text-[#777]">
|
||||||
|
Sorry, the page you are looking for does not exist or has been
|
||||||
|
removed.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="mt-8 rounded-full bg-(--primary) px-8 py-4 text-white">
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
-1
@@ -9,6 +9,10 @@
|
|||||||
body {
|
body {
|
||||||
font-family: "Inter", sans-serif;
|
font-family: "Inter", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
@theme {
|
@theme {
|
||||||
--background: #171717;
|
--background: #171717;
|
||||||
--foreground: #ffffff;
|
--foreground: #ffffff;
|
||||||
@@ -31,7 +35,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark .Toastify__toast {
|
.dark .Toastify__toast {
|
||||||
box-shadow: 0 20px 25px -5px rgb(255 255 255 / 0.07),
|
box-shadow:
|
||||||
|
0 20px 25px -5px rgb(255 255 255 / 0.07),
|
||||||
0 8px 10px -6px rgb(255 255 255 / 0.05) !important;
|
0 8px 10px -6px rgb(255 255 255 / 0.05) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,14 +81,33 @@ export default async function Home({ params }: IProps) {
|
|||||||
height={22}
|
height={22}
|
||||||
className="absolute top-4 left-1/2 -translate-x-1/2"
|
className="absolute top-4 left-1/2 -translate-x-1/2"
|
||||||
/>
|
/>
|
||||||
<Image
|
<div className="-z-10 border-8 border-black rounded-4xl overflow-hidden h-full w-full">
|
||||||
|
<video
|
||||||
|
className="max-h-full w-full object-cover"
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="auto"
|
||||||
|
aria-label="Opp lens application preview on a mobile phone">
|
||||||
|
<source
|
||||||
|
src="/mobile.webm"
|
||||||
|
type="video/webm"
|
||||||
|
/>
|
||||||
|
<source
|
||||||
|
src="/mobile.mp4"
|
||||||
|
type="video/mp4"
|
||||||
|
/>
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
{/* <Image
|
||||||
className="-z-10 border-8 border-black rounded-4xl overflow-hidden h-full w-full"
|
className="-z-10 border-8 border-black rounded-4xl overflow-hidden h-full w-full"
|
||||||
src="/mobile.gif"
|
src="/mobile.gif"
|
||||||
width={300}
|
width={300}
|
||||||
height={920}
|
height={920}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
alt="phone"
|
alt="phone"
|
||||||
/>
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="text-left flex flex-col gap-6 bg-gradient-to-b from-[#011132] to-[#012B80] text-white rounded-[56px] relative p-6 lg:p-14 -mt-16 z-20 lg:ml-28 ">
|
<section className="text-left flex flex-col gap-6 bg-gradient-to-b from-[#011132] to-[#012B80] text-white rounded-[56px] relative p-6 lg:p-14 -mt-16 z-20 lg:ml-28 ">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { ToastContainer, Zoom } from "react-toastify";
|
|
||||||
import "../globals.css";
|
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { ToastContainer, Zoom } from "react-toastify";
|
||||||
import GoogleAnalytics from "../_components/GoogleAnalytics";
|
import GoogleAnalytics from "../_components/GoogleAnalytics";
|
||||||
|
import "../globals.css";
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
|||||||
+28
-7
@@ -142,11 +142,11 @@ export default function Home() {
|
|||||||
</p>
|
</p>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
<div className="flex justify-start text-white">
|
<div className="flex justify-start text-white">
|
||||||
<button
|
<Link
|
||||||
type="button"
|
href={"#footer"}
|
||||||
className="w-2/3 lg:w-fit capitalize bg-(--primary) rounded-4xl py-4 px-6">
|
className="w-2/3 lg:w-fit capitalize bg-(--primary) rounded-4xl py-4 px-6 cursor-pointer">
|
||||||
Get early access
|
Get early access
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div className="m-auto w-xs lg:m-0 lg:w-auto -z-20 relative flex justify-center h-[650px] lg:h-[750px] lg:mr-28">
|
<div className="m-auto w-xs lg:m-0 lg:w-auto -z-20 relative flex justify-center h-[650px] lg:h-[750px] lg:mr-28">
|
||||||
@@ -157,14 +157,33 @@ export default function Home() {
|
|||||||
height={22}
|
height={22}
|
||||||
className="absolute top-4 left-1/2 -translate-x-1/2"
|
className="absolute top-4 left-1/2 -translate-x-1/2"
|
||||||
/>
|
/>
|
||||||
<Image
|
<div className="-z-10 border-8 border-black rounded-4xl overflow-hidden h-full w-full">
|
||||||
|
<video
|
||||||
|
className="max-h-full w-full object-cover"
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="auto"
|
||||||
|
aria-label="Opp lens application preview on a mobile phone">
|
||||||
|
<source
|
||||||
|
src="/mobile.webm"
|
||||||
|
type="video/webm"
|
||||||
|
/>
|
||||||
|
<source
|
||||||
|
src="/mobile.mp4"
|
||||||
|
type="video/mp4"
|
||||||
|
/>
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
{/* <Image
|
||||||
className="-z-10 border-8 border-black rounded-4xl overflow-hidden h-full w-full"
|
className="-z-10 border-8 border-black rounded-4xl overflow-hidden h-full w-full"
|
||||||
src="/mobile.gif"
|
src="/mobile.gif"
|
||||||
width={300}
|
width={300}
|
||||||
height={920}
|
height={920}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
alt="phone"
|
alt="phone"
|
||||||
/>
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -295,7 +314,9 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<footer className="relative h-full bg-[url('/footer-bg.svg')] bg-cover bg-no-repeat mt-96">
|
<footer
|
||||||
|
className="relative h-full bg-[url('/footer-bg.svg')] bg-cover bg-no-repeat mt-96"
|
||||||
|
id="footer">
|
||||||
<div
|
<div
|
||||||
className="absolute w-full -top-[355px] lg:-top-72 h-[600px] z-[-10] bg-[url('/bottom-bg.svg')] bg-contain bg-no-repeat bg-right"
|
className="absolute w-full -top-[355px] lg:-top-72 h-[600px] z-[-10] bg-[url('/bottom-bg.svg')] bg-contain bg-no-repeat bg-right"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
const siteUrl = "https://opplenz.com";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
},
|
||||||
|
sitemap: `${siteUrl}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
const siteUrl = "https://opplenz.com";
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: siteUrl,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/contact-us`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "monthly",
|
||||||
|
priority: 0.7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/marketing`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "monthly",
|
||||||
|
priority: 0.6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
+3
-1
@@ -4,6 +4,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
|
||||||
|
<meta name="description" content="Contact Opp lens to learn how AI-powered tender management helps SMEs find relevant tenders and win more opportunities.">
|
||||||
|
<link rel="canonical" href="https://opplenz.com/contact-us">
|
||||||
<link rel="icon" type="image/x-icon" href="assets/image/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="assets/image/favicon.ico">
|
||||||
<title>Opplens</title>
|
<title>Opplens</title>
|
||||||
<link rel="stylesheet" href="assets/css/style.css">
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
@@ -156,4 +158,4 @@
|
|||||||
<script src="assets/js/main.js"></script>
|
<script src="assets/js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
|
||||||
|
<meta name="description" content="Opp lens helps SMEs compete for public and private tenders with AI-powered discovery, preparation support, and opportunity alerts.">
|
||||||
|
<link rel="canonical" href="https://opplenz.com/email-marketing">
|
||||||
<link rel="icon" type="image/x-icon" href="assets/image/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="assets/image/favicon.ico">
|
||||||
<title>Opplens</title>
|
<title>Opplens</title>
|
||||||
<link rel="stylesheet" href="assets/css/style.css">
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
@@ -258,4 +260,4 @@
|
|||||||
<script src="assets/js/main.js"></script>
|
<script src="assets/js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+3
-1
@@ -4,6 +4,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
|
||||||
|
<meta name="description" content="Automated tender management platform for SMEs. Find tailored tenders, automate document handling, and get real-time alerts.">
|
||||||
|
<link rel="canonical" href="https://opplenz.com/">
|
||||||
<link rel="icon" type="image/x-icon" href="assets/image/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="assets/image/favicon.ico">
|
||||||
<title>Opplens</title>
|
<title>Opplens</title>
|
||||||
<link rel="stylesheet" href="assets/css/style.css">
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
@@ -151,4 +153,4 @@
|
|||||||
<script src="assets/js/main.js"></script>
|
<script src="assets/js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
testEnvironment: "node",
|
||||||
|
roots: ["<rootDir>/lib"],
|
||||||
|
testMatch: ["**/__tests__/**/*.test.ts"],
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^@/(.*)$": "<rootDir>/$1",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { handleApiError, parseApiError } from "../apiErrorHandler";
|
||||||
|
|
||||||
|
describe("API Error Handler", () => {
|
||||||
|
describe("parseApiError", () => {
|
||||||
|
it("should return default message for non-Error objects", () => {
|
||||||
|
const result = parseApiError("string error");
|
||||||
|
expect(result.generalMessage).toBe("Something went wrong. Please try again.");
|
||||||
|
expect(result.fieldErrors).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle network errors (no response)", () => {
|
||||||
|
const error = new Error("Network Error") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = undefined;
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).toContain("Unable to connect");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse 400 errors with field-level errors", () => {
|
||||||
|
const error = new Error("Bad Request") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
errors: {
|
||||||
|
email: "Invalid email format",
|
||||||
|
full_name: ["Name is too short", "Name contains invalid characters"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusText: "Bad Request",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).toContain("highlighted fields");
|
||||||
|
expect(result.fieldErrors.email).toMatch(/email/i);
|
||||||
|
expect(result.fieldErrors.full_name).toMatch(/name|short|character/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should map message-only 400 responses without exposing raw server text", () => {
|
||||||
|
const error = new Error("Bad Request") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
message: "Custom validation error message",
|
||||||
|
},
|
||||||
|
statusText: "Bad Request",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).not.toContain("Custom validation");
|
||||||
|
expect(result.generalMessage.length).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse nested error object with field errors", () => {
|
||||||
|
const error = new Error("Bad Request") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
error: {
|
||||||
|
code: "validation_failed",
|
||||||
|
errors: { work_email: "invalid_email" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusText: "Bad Request",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.fieldErrors.work_email).toMatch(/email/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse FastAPI-style detail arrays", () => {
|
||||||
|
const error = new Error("Unprocessable") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 422,
|
||||||
|
data: {
|
||||||
|
detail: [
|
||||||
|
{ loc: ["body", "phone_number"], msg: "value is not a valid phone" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
statusText: "Unprocessable Entity",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.fieldErrors.phone_number).toMatch(/phone/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle 401 unauthorized errors", () => {
|
||||||
|
const error = new Error("Unauthorized") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 401,
|
||||||
|
data: {},
|
||||||
|
statusText: "Unauthorized",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).toMatch(/Authentication|Sign in/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle 422 unprocessable entity errors", () => {
|
||||||
|
const error = new Error("Unprocessable Entity") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 422,
|
||||||
|
data: {
|
||||||
|
errors: {
|
||||||
|
phone: "invalid_phone",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusText: "Unprocessable Entity",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).toContain("highlighted fields");
|
||||||
|
expect(result.fieldErrors.phone).toContain("phone number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle 429 rate limit errors", () => {
|
||||||
|
const error = new Error("Too Many Requests") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 429,
|
||||||
|
data: {},
|
||||||
|
statusText: "Too Many Requests",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).toMatch(/many|wait/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle 500 server errors", () => {
|
||||||
|
const error = new Error("Internal Server Error") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 500,
|
||||||
|
data: {},
|
||||||
|
statusText: "Internal Server Error",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).toMatch(/server|try again/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should give a client-safe message for unlisted 4xx statuses with a body", () => {
|
||||||
|
const error = new Error("Nope") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 418,
|
||||||
|
data: { message: "I'm a teapot" },
|
||||||
|
statusText: "I'm a teapot",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).not.toContain("teapot");
|
||||||
|
expect(result.generalMessage.length).toBeGreaterThan(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleApiError", () => {
|
||||||
|
it("should call setError for each field error", () => {
|
||||||
|
const mockSetError = jest.fn();
|
||||||
|
const mockShowToast = jest.fn();
|
||||||
|
|
||||||
|
const error = new Error("Bad Request") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
errors: {
|
||||||
|
email: "Invalid email",
|
||||||
|
phone: "Invalid phone",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusText: "Bad Request",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleApiError(error, mockSetError, mockShowToast, ["email", "phone"]);
|
||||||
|
|
||||||
|
expect(mockSetError).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockSetError).toHaveBeenCalledWith(
|
||||||
|
"email",
|
||||||
|
expect.objectContaining({ type: "server", message: expect.any(String) })
|
||||||
|
);
|
||||||
|
expect(mockSetError).toHaveBeenCalledWith(
|
||||||
|
"phone",
|
||||||
|
expect.objectContaining({ type: "server", message: expect.any(String) })
|
||||||
|
);
|
||||||
|
expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining("highlighted fields"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should only set errors for fields in formFields list", () => {
|
||||||
|
const mockSetError = jest.fn();
|
||||||
|
const mockShowToast = jest.fn();
|
||||||
|
|
||||||
|
const error = new Error("Bad Request") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
errors: {
|
||||||
|
email: "Invalid email",
|
||||||
|
unknown_field: "Unknown error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusText: "Bad Request",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleApiError(error, mockSetError, mockShowToast, ["email"]);
|
||||||
|
|
||||||
|
expect(mockSetError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockSetError).toHaveBeenCalledWith(
|
||||||
|
"email",
|
||||||
|
expect.objectContaining({ type: "server" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show general message when no field errors", () => {
|
||||||
|
const mockSetError = jest.fn();
|
||||||
|
const mockShowToast = jest.fn();
|
||||||
|
|
||||||
|
const error = new Error("Internal Server Error") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 500,
|
||||||
|
data: {},
|
||||||
|
statusText: "Internal Server Error",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleApiError(error, mockSetError, mockShowToast, ["email"]);
|
||||||
|
|
||||||
|
expect(mockSetError).not.toHaveBeenCalled();
|
||||||
|
expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(/server|try again/i));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
containsDangerousContent,
|
||||||
|
createFieldValidation,
|
||||||
|
createXSSValidator,
|
||||||
|
validationRules,
|
||||||
|
} from "../validation";
|
||||||
|
|
||||||
|
describe("Validation Functions", () => {
|
||||||
|
describe("containsDangerousContent", () => {
|
||||||
|
it("should detect script tags", () => {
|
||||||
|
expect(containsDangerousContent("<script>alert('XSS')</script>")).toBe(true);
|
||||||
|
expect(containsDangerousContent("<SCRIPT>alert('XSS')</SCRIPT>")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect javascript: protocol", () => {
|
||||||
|
expect(containsDangerousContent("javascript:alert('XSS')")).toBe(true);
|
||||||
|
expect(containsDangerousContent("JAVASCRIPT:alert('XSS')")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect event handlers", () => {
|
||||||
|
expect(containsDangerousContent("onclick=alert('XSS')")).toBe(true);
|
||||||
|
expect(containsDangerousContent("onerror=alert('XSS')")).toBe(true);
|
||||||
|
expect(containsDangerousContent("onload=alert('XSS')")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect iframe tags", () => {
|
||||||
|
expect(containsDangerousContent("<iframe src='evil.com'>")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect data: text/html", () => {
|
||||||
|
expect(containsDangerousContent("data: text/html,<script>")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for safe content", () => {
|
||||||
|
expect(containsDangerousContent("Hello World")).toBe(false);
|
||||||
|
expect(containsDangerousContent("john.doe@example.com")).toBe(false);
|
||||||
|
expect(containsDangerousContent("+1234567890")).toBe(false);
|
||||||
|
expect(containsDangerousContent("This is a normal message.")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty or null input", () => {
|
||||||
|
expect(containsDangerousContent("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createXSSValidator", () => {
|
||||||
|
it("should return error message for dangerous content", () => {
|
||||||
|
const validator = createXSSValidator("Full Name");
|
||||||
|
expect(validator("<script>alert('XSS')</script>")).toContain("Full Name");
|
||||||
|
expect(validator("<script>alert('XSS')</script>")).toContain("invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true for safe content", () => {
|
||||||
|
const validator = createXSSValidator("Full Name");
|
||||||
|
expect(validator("John Doe")).toBe(true);
|
||||||
|
expect(validator("Jane Smith")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createFieldValidation", () => {
|
||||||
|
it("should create validation with XSS check", () => {
|
||||||
|
const validation = createFieldValidation("Email", true);
|
||||||
|
expect(validation).toHaveProperty("validate.noXSS");
|
||||||
|
expect(validation).toHaveProperty("required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include additional rules", () => {
|
||||||
|
const validation = createFieldValidation("Phone", false, {
|
||||||
|
pattern: { value: /^[+\d]+$/, message: "Invalid phone" },
|
||||||
|
});
|
||||||
|
expect(validation).toHaveProperty("pattern");
|
||||||
|
expect(validation).toHaveProperty("validate.noXSS");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validationRules", () => {
|
||||||
|
describe("fullName rules", () => {
|
||||||
|
it("should have required validation", () => {
|
||||||
|
expect(validationRules.fullName.required.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have min/max length", () => {
|
||||||
|
expect(validationRules.fullName.minLength.value).toBe(2);
|
||||||
|
expect(validationRules.fullName.maxLength.value).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have XSS validation", () => {
|
||||||
|
expect(validationRules.fullName.validate.noXSS).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("email rules", () => {
|
||||||
|
it("should have pattern validation", () => {
|
||||||
|
expect(validationRules.email.pattern.value).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have XSS validation", () => {
|
||||||
|
expect(validationRules.email.validate.noXSS).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("phone rules", () => {
|
||||||
|
it("should have pattern validation for phone format", () => {
|
||||||
|
const pattern = validationRules.phone.pattern.value;
|
||||||
|
expect(pattern.test("+1234567890")).toBe(true);
|
||||||
|
expect(pattern.test("(123) 456-7890")).toBe(true);
|
||||||
|
expect(pattern.test("abc123")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("message rules", () => {
|
||||||
|
it("should have XSS validation", () => {
|
||||||
|
expect(validationRules.message.validate.noXSS).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have max length of 1000", () => {
|
||||||
|
expect(validationRules.message.maxLength.value).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
/**
|
||||||
|
* API Error Handler Utility
|
||||||
|
* Parses server error responses and extracts field-level validation errors.
|
||||||
|
* Server text is mapped to safe, user-facing copy — raw messages are not echoed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios, { AxiosError } from "axios";
|
||||||
|
import { FieldValues, Path, UseFormSetError } from "react-hook-form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard API error response structure (and common variants we normalize).
|
||||||
|
*/
|
||||||
|
export interface ApiErrorResponse {
|
||||||
|
message?: string;
|
||||||
|
errors?: Record<string, string | string[] | unknown> | unknown;
|
||||||
|
error?: string | ApiNestedError | Record<string, unknown>;
|
||||||
|
detail?: string | string[] | Record<string, string[] | unknown> | FastApiDetailItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiNestedError {
|
||||||
|
message?: string;
|
||||||
|
code?: string;
|
||||||
|
errors?: Record<string, unknown>;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FastApiDetailItem {
|
||||||
|
loc?: unknown[];
|
||||||
|
msg?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed error result
|
||||||
|
*/
|
||||||
|
export interface ParsedApiError {
|
||||||
|
generalMessage: string;
|
||||||
|
fieldErrors: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_GENERAL = "Something went wrong. Please try again.";
|
||||||
|
|
||||||
|
/** Shown under a field when the server flagged it but we do not expose its raw text. */
|
||||||
|
const GENERIC_FIELD_MESSAGE =
|
||||||
|
"This value could not be accepted. Check for typos and remove any HTML, scripts, or unusual symbols.";
|
||||||
|
|
||||||
|
const FIELD_NAME_MAP: Record<string, string> = {
|
||||||
|
fullName: "full_name",
|
||||||
|
full_name: "full_name",
|
||||||
|
email: "email",
|
||||||
|
phone: "phone",
|
||||||
|
phoneNumber: "phone_number",
|
||||||
|
phone_number: "phone_number",
|
||||||
|
message: "message",
|
||||||
|
companyName: "company_name",
|
||||||
|
company_name: "company_name",
|
||||||
|
workEmail: "work_email",
|
||||||
|
work_email: "work_email",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ERROR_MESSAGES: Record<string, string> = {
|
||||||
|
invalid_input:
|
||||||
|
"That does not look like valid input. Use plain text only — no HTML or scripts.",
|
||||||
|
xss_detected:
|
||||||
|
"Invalid content was detected. Remove any HTML tags, scripts, or code-like text.",
|
||||||
|
validation_failed: "Please check this field and try again.",
|
||||||
|
required: "This field is required.",
|
||||||
|
invalid_email: "Please enter a valid email address.",
|
||||||
|
invalid_phone: "Please enter a valid phone number using digits and common phone symbols only.",
|
||||||
|
too_short: "This value is too short.",
|
||||||
|
too_long: "This value is too long.",
|
||||||
|
duplicate: "This value is already in use. Try a different one.",
|
||||||
|
duplicate_inquiry:
|
||||||
|
"You already have a pending inquiry with this email. Please wait for it to be processed.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a single server token or short code to a friendly message.
|
||||||
|
*/
|
||||||
|
function mapErrorCode(code: string): string | null {
|
||||||
|
const key = code.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
||||||
|
if (ERROR_MESSAGES[key]) return ERROR_MESSAGES[key];
|
||||||
|
const aliases: Record<string, keyof typeof ERROR_MESSAGES> = {
|
||||||
|
invalid_email_format: "invalid_email",
|
||||||
|
email_invalid: "invalid_email",
|
||||||
|
invalid_email: "invalid_email",
|
||||||
|
invalid_phone_format: "invalid_phone",
|
||||||
|
invalid_phone: "invalid_phone",
|
||||||
|
phone_invalid: "invalid_phone",
|
||||||
|
xss: "xss_detected",
|
||||||
|
xss_detected: "xss_detected",
|
||||||
|
malicious_content: "xss_detected",
|
||||||
|
validation_error: "validation_failed",
|
||||||
|
validation_failed: "validation_failed",
|
||||||
|
duplicate_inquiry: "duplicate_inquiry",
|
||||||
|
duplicate: "duplicate",
|
||||||
|
};
|
||||||
|
const mappedKey = aliases[key];
|
||||||
|
return mappedKey ? ERROR_MESSAGES[mappedKey] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infers a safe message from server hint text without echoing the full string.
|
||||||
|
*/
|
||||||
|
function inferMessageFromHint(hint: string): string {
|
||||||
|
const h = hint.toLowerCase();
|
||||||
|
if (/(xss|script|html|tag|malicious|injection)/i.test(hint)) {
|
||||||
|
return ERROR_MESSAGES.xss_detected;
|
||||||
|
}
|
||||||
|
if (/email/.test(h)) return ERROR_MESSAGES.invalid_email;
|
||||||
|
if (/phone|tel|mobile/.test(h)) return ERROR_MESSAGES.invalid_phone;
|
||||||
|
if (/company|organization|organisation/.test(h)) {
|
||||||
|
return "Please use a valid company name (plain text, no HTML or scripts).";
|
||||||
|
}
|
||||||
|
if (/name|full/.test(h)) {
|
||||||
|
return "Please enter a valid name using letters and common characters only.";
|
||||||
|
}
|
||||||
|
if (/required|missing|empty/.test(h)) return ERROR_MESSAGES.required;
|
||||||
|
if (/long|length|exceed/.test(h)) return ERROR_MESSAGES.too_long;
|
||||||
|
if (/short|minimum/.test(h)) return ERROR_MESSAGES.too_short;
|
||||||
|
if (/duplicate|already|exist/.test(h)) return ERROR_MESSAGES.duplicate_inquiry;
|
||||||
|
return GENERIC_FIELD_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFieldName(fieldName: string): string {
|
||||||
|
return FIELD_NAME_MAP[fieldName] || fieldName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||||
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns assorted server error values into a single user-safe string.
|
||||||
|
*/
|
||||||
|
function normalizeFieldErrorValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return GENERIC_FIELD_MESSAGE;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return mapErrorCode(String(value)) ?? GENERIC_FIELD_MESSAGE;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return GENERIC_FIELD_MESSAGE;
|
||||||
|
const byCode = mapErrorCode(trimmed);
|
||||||
|
if (byCode) return byCode;
|
||||||
|
if (/^[a-z0-9_.]+$/i.test(trimmed) && trimmed.length <= 80) {
|
||||||
|
return mapErrorCode(trimmed.replace(/\./g, "_")) ?? GENERIC_FIELD_MESSAGE;
|
||||||
|
}
|
||||||
|
return inferMessageFromHint(trimmed);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const parts = value
|
||||||
|
.map((v) => normalizeFieldErrorValue(v))
|
||||||
|
.filter((s, i, arr) => s && arr.indexOf(s) === i);
|
||||||
|
return parts.length ? parts.join(" ") : GENERIC_FIELD_MESSAGE;
|
||||||
|
}
|
||||||
|
if (isPlainObject(value)) {
|
||||||
|
const msg =
|
||||||
|
typeof value.message === "string"
|
||||||
|
? value.message
|
||||||
|
: typeof value.msg === "string"
|
||||||
|
? value.msg
|
||||||
|
: typeof value.detail === "string"
|
||||||
|
? value.detail
|
||||||
|
: "";
|
||||||
|
if (msg) return inferMessageFromHint(msg);
|
||||||
|
}
|
||||||
|
return GENERIC_FIELD_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeFieldErrors(
|
||||||
|
target: Record<string, string>,
|
||||||
|
source: Record<string, string>
|
||||||
|
): void {
|
||||||
|
for (const [k, v] of Object.entries(source)) {
|
||||||
|
const key = normalizeFieldName(k);
|
||||||
|
target[key] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseErrorsRecord(record: Record<string, unknown>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [field, err] of Object.entries(record)) {
|
||||||
|
out[field] = normalizeFieldErrorValue(err);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFastApiDetailArray(items: FastApiDetailItem[]): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
const loc = Array.isArray(item.loc) ? item.loc : [];
|
||||||
|
const rawKey = loc.filter((x): x is string => typeof x === "string").pop();
|
||||||
|
const field = rawKey ? normalizeFieldName(rawKey) : "_form";
|
||||||
|
const hint = item.msg ?? item.type ?? "";
|
||||||
|
out[field] = hint ? inferMessageFromHint(String(hint)) : GENERIC_FIELD_MESSAGE;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDetail(detail: unknown): Record<string, string> {
|
||||||
|
if (!detail) return {};
|
||||||
|
if (typeof detail === "string") {
|
||||||
|
return { _form: inferMessageFromHint(detail) };
|
||||||
|
}
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
if (detail.length && typeof detail[0] === "object" && detail[0] !== null && "loc" in detail[0]) {
|
||||||
|
return parseFastApiDetailArray(detail as FastApiDetailItem[]);
|
||||||
|
}
|
||||||
|
return { _form: normalizeFieldErrorValue(detail) };
|
||||||
|
}
|
||||||
|
if (isPlainObject(detail)) {
|
||||||
|
return parseErrorsRecord(detail as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks common API shapes and collects field errors + optional form-level key.
|
||||||
|
*/
|
||||||
|
function extractFieldErrorsFromBody(data: unknown): {
|
||||||
|
fieldErrors: Record<string, string>;
|
||||||
|
formLevel?: string;
|
||||||
|
} {
|
||||||
|
const fieldErrors: Record<string, string> = {};
|
||||||
|
let formLevel: string | undefined;
|
||||||
|
|
||||||
|
if (!data) return { fieldErrors };
|
||||||
|
|
||||||
|
if (typeof data === "string") {
|
||||||
|
formLevel = inferMessageFromHint(data);
|
||||||
|
return { fieldErrors, formLevel };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(data)) return { fieldErrors };
|
||||||
|
|
||||||
|
const d = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (isPlainObject(d.errors)) {
|
||||||
|
mergeFieldErrors(fieldErrors, parseErrorsRecord(d.errors as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(d.errors)) {
|
||||||
|
mergeFieldErrors(fieldErrors, { _form: normalizeFieldErrorValue(d.errors) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailParsed = parseDetail(d.detail);
|
||||||
|
mergeFieldErrors(fieldErrors, detailParsed);
|
||||||
|
if (detailParsed._form && !fieldErrors._form) {
|
||||||
|
fieldErrors._form = detailParsed._form;
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = d.error;
|
||||||
|
if (typeof err === "string") {
|
||||||
|
formLevel = inferMessageFromHint(err);
|
||||||
|
} else if (isPlainObject(err)) {
|
||||||
|
const ne = err as ApiNestedError;
|
||||||
|
if (typeof ne.message === "string") {
|
||||||
|
formLevel = inferMessageFromHint(ne.message);
|
||||||
|
}
|
||||||
|
if (typeof ne.code === "string") {
|
||||||
|
const byCode = mapErrorCode(ne.code);
|
||||||
|
if (byCode) formLevel = byCode;
|
||||||
|
}
|
||||||
|
if (isPlainObject(ne.errors)) {
|
||||||
|
mergeFieldErrors(fieldErrors, parseErrorsRecord(ne.errors as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
if (isPlainObject(ne.details)) {
|
||||||
|
mergeFieldErrors(fieldErrors, parseErrorsRecord(ne.details as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof d.message === "string" && !formLevel) {
|
||||||
|
formLevel = inferMessageFromHint(d.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fieldErrors, formLevel };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAxiosResponse(error: unknown): AxiosError<ApiErrorResponse>["response"] | undefined {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
return error.response;
|
||||||
|
}
|
||||||
|
if (error instanceof Error && "isAxiosError" in error && (error as AxiosError).isAxiosError) {
|
||||||
|
return (error as AxiosError<ApiErrorResponse>).response;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses API error response and extracts field-level errors
|
||||||
|
*/
|
||||||
|
export function parseApiError(error: unknown): ParsedApiError {
|
||||||
|
const result: ParsedApiError = {
|
||||||
|
generalMessage: DEFAULT_GENERAL,
|
||||||
|
fieldErrors: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = getAxiosResponse(error);
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
result.generalMessage = "Unable to connect to the server. Check your connection and try again.";
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, data } = response;
|
||||||
|
const extracted = extractFieldErrorsFromBody(data);
|
||||||
|
Object.assign(result.fieldErrors, extracted.fieldErrors);
|
||||||
|
|
||||||
|
const validationSummary =
|
||||||
|
"Some information could not be accepted. Please review the highlighted fields below.";
|
||||||
|
|
||||||
|
if (status === 400 || status === 422) {
|
||||||
|
result.generalMessage = validationSummary;
|
||||||
|
if (extracted.formLevel && Object.keys(result.fieldErrors).length === 0) {
|
||||||
|
result.generalMessage = extracted.formLevel;
|
||||||
|
}
|
||||||
|
} else if (status === 401) {
|
||||||
|
result.generalMessage = "Authentication is required. Sign in and try again.";
|
||||||
|
} else if (status === 403) {
|
||||||
|
result.generalMessage = "You do not have permission to perform this action.";
|
||||||
|
} else if (status === 404) {
|
||||||
|
result.generalMessage = "The requested resource was not found.";
|
||||||
|
} else if (status === 409) {
|
||||||
|
result.generalMessage = ERROR_MESSAGES.duplicate_inquiry;
|
||||||
|
} else if (status === 429) {
|
||||||
|
result.generalMessage = "Too many attempts. Please wait a moment and try again.";
|
||||||
|
} else if (status >= 500) {
|
||||||
|
result.generalMessage = "The server had a problem. Please try again in a few minutes.";
|
||||||
|
} else if (status >= 400 && status < 500) {
|
||||||
|
result.generalMessage =
|
||||||
|
Object.keys(result.fieldErrors).length > 0
|
||||||
|
? validationSummary
|
||||||
|
: extracted.formLevel ?? "We could not complete this request. Please check your input and try again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.fieldErrors._form) {
|
||||||
|
const formOnly = result.fieldErrors._form;
|
||||||
|
delete result.fieldErrors._form;
|
||||||
|
if (Object.keys(result.fieldErrors).length === 0) {
|
||||||
|
result.generalMessage = formOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles API error and sets form field errors using react-hook-form
|
||||||
|
*/
|
||||||
|
export function handleApiError<T extends FieldValues>(
|
||||||
|
error: unknown,
|
||||||
|
setError: UseFormSetError<T>,
|
||||||
|
showToast: (message: string) => void,
|
||||||
|
formFields?: string[]
|
||||||
|
): void {
|
||||||
|
const parsedError = parseApiError(error);
|
||||||
|
|
||||||
|
let hasFieldErrors = false;
|
||||||
|
Object.entries(parsedError.fieldErrors).forEach(([field, message]) => {
|
||||||
|
if (!formFields || formFields.includes(field)) {
|
||||||
|
setError(field as Path<T>, {
|
||||||
|
type: "server",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
hasFieldErrors = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
showToast(parsedError.generalMessage);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Validation utilities for form fields
|
||||||
|
* Detects potentially harmful content (XSS patterns) and provides validation rules
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Patterns that indicate potentially malicious content
|
||||||
|
const DANGEROUS_PATTERNS = [
|
||||||
|
/<script[\s\S]*?>/i,
|
||||||
|
/<\/script>/i,
|
||||||
|
/javascript:/i,
|
||||||
|
/vbscript:/i,
|
||||||
|
/on\w+\s*=/i, // onclick=, onerror=, onload=, etc.
|
||||||
|
/<iframe[\s\S]*?>/i,
|
||||||
|
/<object[\s\S]*?>/i,
|
||||||
|
/<embed[\s\S]*?>/i,
|
||||||
|
/<link[\s\S]*?>/i,
|
||||||
|
/<meta[\s\S]*?>/i,
|
||||||
|
/data:\s*text\/html/i,
|
||||||
|
/expression\s*\(/i, // CSS expression
|
||||||
|
/url\s*\(\s*["']?\s*javascript:/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if text contains potentially dangerous patterns (XSS)
|
||||||
|
* @param text - The text to check
|
||||||
|
* @returns true if dangerous patterns are found
|
||||||
|
*/
|
||||||
|
export function containsDangerousContent(text: string): boolean {
|
||||||
|
if (!text) return false;
|
||||||
|
return DANGEROUS_PATTERNS.some((pattern) => pattern.test(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation function for react-hook-form that checks for XSS patterns
|
||||||
|
* @param fieldName - The human-readable name of the field for error messages
|
||||||
|
* @returns Validation function for react-hook-form
|
||||||
|
*/
|
||||||
|
export function createXSSValidator(fieldName: string) {
|
||||||
|
return (value: string) => {
|
||||||
|
if (containsDangerousContent(value)) {
|
||||||
|
return `${fieldName} contains invalid characters or content. Please remove any special HTML tags or scripts.`;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard validation rules with XSS protection for common field types
|
||||||
|
*/
|
||||||
|
export const validationRules = {
|
||||||
|
fullName: {
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: "Full name is required",
|
||||||
|
},
|
||||||
|
minLength: {
|
||||||
|
value: 2,
|
||||||
|
message: "Full name must be at least 2 characters",
|
||||||
|
},
|
||||||
|
maxLength: {
|
||||||
|
value: 100,
|
||||||
|
message: "Full name must not exceed 100 characters",
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
noXSS: createXSSValidator("Full name"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
email: {
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: "Email is required",
|
||||||
|
},
|
||||||
|
pattern: {
|
||||||
|
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||||
|
message: "Please enter a valid email address",
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
noXSS: createXSSValidator("Email"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
phone: {
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: "Phone number is required",
|
||||||
|
},
|
||||||
|
minLength: {
|
||||||
|
value: 10,
|
||||||
|
message: "Phone number must be at least 10 characters",
|
||||||
|
},
|
||||||
|
maxLength: {
|
||||||
|
value: 20,
|
||||||
|
message: "Phone number must not exceed 20 characters",
|
||||||
|
},
|
||||||
|
pattern: {
|
||||||
|
value: /^[+\d\s()-]+$/,
|
||||||
|
message: "Please enter a valid phone number (digits, +, spaces, parentheses, and dashes only)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
message: {
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: "Message is required",
|
||||||
|
},
|
||||||
|
minLength: {
|
||||||
|
value: 10,
|
||||||
|
message: "Message must be at least 10 characters",
|
||||||
|
},
|
||||||
|
maxLength: {
|
||||||
|
value: 1000,
|
||||||
|
message: "Message must not exceed 1000 characters",
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
noXSS: createXSSValidator("Message"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
companyName: {
|
||||||
|
maxLength: {
|
||||||
|
value: 200,
|
||||||
|
message: "Company name must not exceed 200 characters",
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
noXSS: createXSSValidator("Company name"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates validation rules for a field with optional XSS check
|
||||||
|
* @param fieldName - Human-readable field name
|
||||||
|
* @param isRequired - Whether the field is required
|
||||||
|
* @param additionalRules - Additional validation rules to merge
|
||||||
|
*/
|
||||||
|
export function createFieldValidation(
|
||||||
|
fieldName: string,
|
||||||
|
isRequired: boolean = false,
|
||||||
|
additionalRules: Record<string, unknown> = {}
|
||||||
|
) {
|
||||||
|
const rules: Record<string, unknown> = {
|
||||||
|
validate: {
|
||||||
|
noXSS: createXSSValidator(fieldName),
|
||||||
|
},
|
||||||
|
...additionalRules,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isRequired) {
|
||||||
|
rules.required = `${fieldName} is required`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
+4
-1
@@ -1,7 +1,10 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
experimental: {
|
||||||
|
globalNotFound: true,
|
||||||
|
},
|
||||||
|
htmlLimitedBots: /.*/,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+4052
-4
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -5,7 +5,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build --turbopack",
|
"build": "next build --turbopack",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hcaptcha/react-hcaptcha": "^1.16.0",
|
"@hcaptcha/react-hcaptcha": "^1.16.0",
|
||||||
@@ -24,10 +26,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"jest": "^30.2.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"ts-jest": "^29.4.6",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user