qudoco-fe/components/landing-page/service.tsx

180 lines
6.2 KiB
TypeScript
Raw Normal View History

2026-02-17 09:05:22 +00:00
import Image from "next/image";
import Link from "next/link";
import { Check } from "lucide-react";
2026-04-10 07:21:29 +00:00
import type { CmsServiceContent } from "@/types/cms-landing";
2026-02-17 09:05:22 +00:00
2026-04-10 07:21:29 +00:00
const DEFAULT_HEADING = "Innovative solutions for your business growth.";
const DEFAULT_BODY =
"Professional services tailored to your organization. Update this text from the CMS admin under Content Website → Our Services.";
2026-02-17 09:05:22 +00:00
function cardImageUrl(s: CmsServiceContent) {
2026-04-10 07:21:29 +00:00
return s.images?.[0]?.image_url?.trim() || "/image/s1.png";
}
2026-02-17 09:05:22 +00:00
function resolveServiceLink(raw: string | undefined | null): string | null {
const t = raw?.trim();
if (!t) return null;
if (/^(https?:|\/|#|mailto:)/i.test(t)) return t;
return `https://${t}`;
}
function LearnMoreCta({ href }: { href?: string | null }) {
const resolved = resolveServiceLink(href);
const className =
"inline-flex text-sm font-semibold text-[#966314] hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#966314]";
if (!resolved) return null;
if (resolved.startsWith("/")) {
return (
<Link href={resolved} className={className}>
Learn More
</Link>
);
}
return (
<a href={resolved} target="_blank" rel="noopener noreferrer" className={className}>
Learn More
</a>
);
}
function BulletLine({ text }: { text: string }) {
const idx = text.indexOf(":");
if (idx > 0 && idx < text.length - 1) {
const label = text.slice(0, idx).trim();
const rest = text.slice(idx + 1).trim();
return (
<>
<span className="font-semibold text-gray-900">{label}:</span> {rest}
</>
);
}
return <>{text}</>;
}
function ServiceImageBlock({ imgSrc, alt }: { imgSrc: string; alt: string }) {
const external = /^https?:\/\//i.test(imgSrc);
return (
<div className="relative flex w-full max-w-xl justify-center md:max-w-none md:flex-1">
<div className="relative flex aspect-[4/3] w-full max-w-[520px] items-center justify-center rounded-2xl bg-gray-50 md:aspect-[5/4]">
{external ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={imgSrc} alt={alt} className="h-full w-full rounded-2xl object-contain" />
) : (
<Image src={imgSrc} alt={alt} width={520} height={420} className="object-contain" />
)}
</div>
</div>
);
}
function ServiceTextBlock({ s }: { s: CmsServiceContent }) {
const rawDesc = s.description?.trim();
const lines = rawDesc
? rawDesc.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
: [];
const useBullets = lines.length > 1;
const bodyText = lines.length === 0 ? DEFAULT_BODY : rawDesc ?? "";
return (
<div className="w-full max-w-xl md:flex-1">
<h3 className="mb-4 text-2xl font-bold text-gray-900 md:text-3xl whitespace-pre-line">
{s.primary_title?.trim() || "Service"}
</h3>
{s.secondary_title?.trim() ? (
<p className="mb-6 text-base leading-relaxed text-gray-600">{s.secondary_title.trim()}</p>
) : null}
{useBullets ? (
<ul className="mb-8 space-y-4">
{lines.map((item, i) => (
<li
key={`${i}-${item.slice(0, 48)}`}
className="flex gap-4 text-sm leading-relaxed text-gray-600"
>
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#fdecc8]">
<Check size={14} className="text-[#966314]" aria-hidden />
</span>
<span>
<BulletLine text={item} />
</span>
</li>
))}
</ul>
) : (
<p className="mb-8 text-sm leading-relaxed text-gray-600 whitespace-pre-line md:text-base">
{bodyText}
</p>
)}
<LearnMoreCta href={s.link_url} />
</div>
);
}
2026-04-10 07:21:29 +00:00
export default function ServiceSection({
services,
}: {
services?: CmsServiceContent[] | null;
}) {
const list = services?.filter((s) => s.id) ?? [];
2026-02-17 09:05:22 +00:00
2026-04-10 07:21:29 +00:00
if (list.length === 0) {
return (
<section className="bg-white py-24 md:py-32">
2026-04-10 07:21:29 +00:00
<div className="container mx-auto px-6">
<div className="mb-16 text-center md:mb-20">
<p className="mb-4 text-sm font-semibold uppercase tracking-widest text-gray-400">
Our Services
</p>
<h2 className="mx-auto max-w-3xl text-4xl font-extrabold leading-tight md:text-5xl whitespace-pre-line">
2026-04-10 07:21:29 +00:00
{DEFAULT_HEADING}
</h2>
2026-02-17 09:05:22 +00:00
</div>
<div className="flex flex-col items-center gap-12 md:flex-row md:items-center">
<ServiceImageBlock imgSrc="/image/s1.png" alt="Service illustration" />
<div className="w-full max-w-xl md:flex-1">
<h3 className="mb-4 text-2xl font-bold text-gray-900 md:text-3xl">Our services</h3>
<p className="mb-8 text-sm leading-relaxed text-gray-600 md:text-base whitespace-pre-line">
{DEFAULT_BODY}
</p>
<LearnMoreCta href={null} />
2026-04-10 07:21:29 +00:00
</div>
2026-02-17 09:05:22 +00:00
</div>
</div>
2026-04-10 07:21:29 +00:00
</section>
);
}
2026-02-17 09:05:22 +00:00
2026-04-10 07:21:29 +00:00
return (
<section className="bg-white py-24 md:py-32">
2026-04-10 07:21:29 +00:00
<div className="container mx-auto px-6">
<div className="mb-16 text-center md:mb-20">
<p className="mb-4 text-sm font-semibold uppercase tracking-widest text-gray-400">
Our Services
</p>
<h2 className="mx-auto max-w-3xl text-4xl font-extrabold leading-tight md:text-5xl whitespace-pre-line">
{DEFAULT_HEADING}
2026-04-10 07:21:29 +00:00
</h2>
2026-02-17 09:05:22 +00:00
</div>
<div className="flex flex-col gap-20 md:gap-28">
{list.map((s, index) => {
const imgSrc = cardImageUrl(s);
const alt = s.primary_title?.trim() || "Service";
const reverse = index % 2 === 1;
2026-04-10 07:21:29 +00:00
return (
<div
key={s.id}
className={`flex flex-col items-stretch gap-12 md:flex-row md:items-center md:gap-16 lg:gap-24 ${
reverse ? "md:flex-row-reverse" : ""
}`}
2026-04-10 07:21:29 +00:00
>
<ServiceImageBlock imgSrc={imgSrc} alt={alt} />
<ServiceTextBlock s={s} />
2026-04-10 07:21:29 +00:00
</div>
);
})}
2026-02-17 09:05:22 +00:00
</div>
</div>
</section>
);
}