feat: update bug fixing content website and landing page
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
This commit is contained in:
parent
4c4ff5c0e8
commit
b6fa161efb
4
.env
4
.env
|
|
@ -1,3 +1,3 @@
|
|||
MEDOLS_CLIENT_KEY=bb65b1ad-e954-4a1a-b4d0-74df5bb0b640
|
||||
# NEXT_PUBLIC_API_URL=http://localhost:8800
|
||||
NEXT_PUBLIC_API_URL=https://qudo.id/api
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8800
|
||||
# NEXT_PUBLIC_API_URL=https://qudo.id/api
|
||||
|
|
@ -18,12 +18,16 @@ export default function Header({ hero }: { hero?: CmsHeroContent | null }) {
|
|||
const [open, setOpen] = useState(false);
|
||||
const [contactOpen, setContactOpen] = useState(false);
|
||||
|
||||
const title =
|
||||
hero?.primary_title?.trim() ||
|
||||
"Beyond Expectations to Build Reputation.";
|
||||
// Only main title is required in CMS; optional fields stay empty (no placeholder dashes or fake CTAs).
|
||||
const fallbackHeadline = "Beyond Expectations to Build Reputation.";
|
||||
const title = hero?.primary_title?.trim()
|
||||
? hero.primary_title.trim()
|
||||
: !hero
|
||||
? fallbackHeadline
|
||||
: "";
|
||||
const subtitle = hero?.secondary_title?.trim();
|
||||
const lead = hero?.description?.trim();
|
||||
const primaryCta = hero?.primary_cta?.trim() || "Contact Us";
|
||||
const primaryCta = hero?.primary_cta?.trim();
|
||||
const secondaryCta = hero?.secondary_cta_text?.trim();
|
||||
const heroImg = hero?.images?.[0]?.image_url?.trim();
|
||||
|
||||
|
|
@ -73,7 +77,9 @@ export default function Header({ hero }: { hero?: CmsHeroContent | null }) {
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
{(primaryCta || secondaryCta) ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{primaryCta ? (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => setContactOpen(true)}
|
||||
|
|
@ -81,6 +87,7 @@ export default function Header({ hero }: { hero?: CmsHeroContent | null }) {
|
|||
>
|
||||
{primaryCta}
|
||||
</Button>
|
||||
) : null}
|
||||
{secondaryCta ? (
|
||||
<Button
|
||||
size="lg"
|
||||
|
|
@ -92,6 +99,7 @@ export default function Header({ hero }: { hero?: CmsHeroContent | null }) {
|
|||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="relative hidden max-h-[min(90vh,520px)] flex-1 justify-end md:flex">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Check } from "lucide-react";
|
||||
import type { CmsProductContent } from "@/types/cms-landing";
|
||||
|
||||
|
|
@ -11,6 +12,129 @@ function cardImageUrl(p: CmsProductContent) {
|
|||
return p.images?.[0]?.image_url?.trim() || "/image/p1.png";
|
||||
}
|
||||
|
||||
/** Normalize CMS link: relative paths and full URLs; bare domains get https:// */
|
||||
function resolveProductLink(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 = resolveProductLink(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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders "Label: rest" with a bold label when a colon is present (one colon split). */
|
||||
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 ProductImageBlock({
|
||||
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 ProductOrderBadge({ order }: { order: number }) {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 flex h-12 w-12 items-center justify-center rounded-xl bg-[#fdecc8]"
|
||||
aria-hidden
|
||||
>
|
||||
<span className="text-lg font-bold tabular-nums text-[#966314]">{order}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductTextBlock({ p, order }: { p: CmsProductContent; order: number }) {
|
||||
const rawDesc = p.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">
|
||||
<ProductOrderBadge order={order} />
|
||||
<h3 className="mb-4 text-2xl font-bold text-gray-900 md:text-3xl whitespace-pre-line">
|
||||
{p.primary_title?.trim() || "Product"}
|
||||
</h3>
|
||||
{p.secondary_title?.trim() ? (
|
||||
<p className="mb-6 text-base leading-relaxed text-gray-600">
|
||||
{p.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={p.link_url} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductSection({
|
||||
products,
|
||||
}: {
|
||||
|
|
@ -20,9 +144,9 @@ export default function ProductSection({
|
|||
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<section className="bg-white py-32">
|
||||
<section className="bg-white py-24 md:py-32">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="mb-20 text-center">
|
||||
<div className="mb-16 text-center md:mb-20">
|
||||
<p className="mb-4 text-sm font-semibold uppercase tracking-widest text-gray-400">
|
||||
Our Product
|
||||
</p>
|
||||
|
|
@ -30,37 +154,19 @@ export default function ProductSection({
|
|||
{DEFAULT_TITLE}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 items-center gap-16 md:grid-cols-2">
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/image/p1.png"
|
||||
alt="Product illustration"
|
||||
width={520}
|
||||
height={420}
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-xl">
|
||||
<div className="flex flex-col items-center gap-12 md:flex-row md:items-center">
|
||||
<ProductImageBlock imgSrc="/image/p1.png" alt="Product illustration" />
|
||||
<div className="w-full max-w-xl md:flex-1">
|
||||
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-xl bg-[#fdecc8]">
|
||||
<Image
|
||||
src="/image/product-icon.png"
|
||||
alt=""
|
||||
width={22}
|
||||
height={22}
|
||||
/>
|
||||
<Image src="/image/product-icon.png" alt="" width={22} height={22} />
|
||||
</div>
|
||||
<h3 className="mb-4 text-2xl font-bold text-gray-900">
|
||||
<h3 className="mb-4 text-2xl font-bold text-gray-900 md:text-3xl">
|
||||
MediaHUB Content Aggregator
|
||||
</h3>
|
||||
<p className="mb-8 text-sm leading-relaxed text-gray-600 whitespace-pre-line">
|
||||
<p className="mb-8 text-sm leading-relaxed text-gray-600 md:text-base whitespace-pre-line">
|
||||
{DEFAULT_BODY}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-semibold text-[#966314] hover:underline"
|
||||
>
|
||||
Learn More →
|
||||
</button>
|
||||
<LearnMoreCta href={null} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -68,100 +174,35 @@ export default function ProductSection({
|
|||
);
|
||||
}
|
||||
|
||||
const first = list[0];
|
||||
const sectionTitle =
|
||||
first?.primary_title?.trim() || "Products tailored to your business";
|
||||
const subtitle = first?.secondary_title?.trim();
|
||||
|
||||
return (
|
||||
<section className="bg-white py-32">
|
||||
<section className="bg-white py-24 md:py-32">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="mb-16 text-center">
|
||||
<div className="mb-16 text-center md:mb-20">
|
||||
<p className="mb-4 text-sm font-semibold uppercase tracking-widest text-gray-400">
|
||||
Our Product
|
||||
</p>
|
||||
<h2 className="mx-auto max-w-3xl text-4xl font-extrabold leading-tight md:text-5xl whitespace-pre-line">
|
||||
{list.length > 1 ? "Our Products" : sectionTitle}
|
||||
<h2 className="mx-auto max-w-3xl text-4xl font-extrabold leading-tight md:text-5xl">
|
||||
The product we offer is
|
||||
<span className="relative inline-block">
|
||||
<span className="absolute bottom-2 left-0 h-3 w-full bg-[#f5d28a]"></span>
|
||||
<span className="relative italic text-[#966314]">designed</span></span> to meet your business needs.
|
||||
</h2>
|
||||
{subtitle && list.length > 1 ? (
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-gray-600">{subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-10 md:grid-cols-2 lg:grid-cols-3">
|
||||
{list.map((p) => {
|
||||
<div className="flex flex-col gap-20 md:gap-28">
|
||||
{list.map((p, index) => {
|
||||
const imgSrc = cardImageUrl(p);
|
||||
const external = /^https?:\/\//i.test(imgSrc);
|
||||
const rawDesc = p.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 ?? "";
|
||||
|
||||
const alt = p.primary_title?.trim() || "Product";
|
||||
const reverse = index % 2 === 1;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex flex-col overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm"
|
||||
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" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex aspect-[4/3] items-center justify-center bg-gray-50">
|
||||
{external ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
width={480}
|
||||
height={360}
|
||||
className="object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-6">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-[#fdecc8]">
|
||||
<Image
|
||||
src="/image/product-icon.png"
|
||||
alt=""
|
||||
width={22}
|
||||
height={22}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-gray-900 whitespace-pre-line">
|
||||
{p.primary_title?.trim() || "Product"}
|
||||
</h3>
|
||||
{p.secondary_title?.trim() ? (
|
||||
<p className="mb-3 text-sm font-medium text-gray-500">
|
||||
{p.secondary_title.trim()}
|
||||
</p>
|
||||
) : null}
|
||||
{useBullets ? (
|
||||
<ul className="mb-4 space-y-3">
|
||||
{lines.map((item) => (
|
||||
<li key={item} className="flex gap-3 text-sm text-gray-600">
|
||||
<span className="mt-1 flex h-8 w-16 shrink-0 items-center justify-center rounded-full bg-[#fdecc8]">
|
||||
<Check size={12} className="text-[#966314]" />
|
||||
</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mb-4 flex-1 text-sm leading-relaxed text-gray-600 whitespace-pre-line line-clamp-6">
|
||||
{bodyText}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-auto text-left text-sm font-semibold text-[#966314] hover:underline"
|
||||
>
|
||||
Learn More →
|
||||
</button>
|
||||
</div>
|
||||
<ProductImageBlock imgSrc={imgSrc} alt={alt} />
|
||||
<ProductTextBlock p={p} order={index + 1} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,114 @@
|
|||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Check } from "lucide-react";
|
||||
import type { CmsServiceContent } from "@/types/cms-landing";
|
||||
|
||||
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.";
|
||||
|
||||
function bannerUrl(s: CmsServiceContent) {
|
||||
function cardImageUrl(s: CmsServiceContent) {
|
||||
return s.images?.[0]?.image_url?.trim() || "/image/s1.png";
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServiceSection({
|
||||
services,
|
||||
}: {
|
||||
|
|
@ -17,28 +117,25 @@ export default function ServiceSection({
|
|||
const list = services?.filter((s) => s.id) ?? [];
|
||||
|
||||
if (list.length === 0) {
|
||||
const imgSrc = "/image/s1.png";
|
||||
return (
|
||||
<section className="bg-white py-20">
|
||||
<section className="bg-white py-24 md:py-32">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="mb-16 text-center">
|
||||
<p className="text-sm uppercase tracking-widest text-gray-400">Our Services</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-gray-900 md:text-4xl whitespace-pre-line">
|
||||
<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}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid items-center gap-12 md:grid-cols-2">
|
||||
<div className="overflow-hidden rounded-2xl shadow-lg">
|
||||
<Image
|
||||
src={imgSrc}
|
||||
alt="Service"
|
||||
width={600}
|
||||
height={400}
|
||||
className="h-auto w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="leading-relaxed text-gray-600 whitespace-pre-line">{DEFAULT_BODY}</p>
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -47,62 +144,31 @@ export default function ServiceSection({
|
|||
}
|
||||
|
||||
return (
|
||||
<section className="bg-white py-20">
|
||||
<section className="bg-white py-24 md:py-32">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="mb-16 text-center">
|
||||
<p className="text-sm uppercase tracking-widest text-gray-400">Our Services</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-gray-900 md:text-4xl whitespace-pre-line">
|
||||
{list.length > 1 ? "What we deliver" : list[0]?.primary_title?.trim() || DEFAULT_HEADING}
|
||||
<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}
|
||||
</h2>
|
||||
{list.length > 1 && list[0]?.secondary_title?.trim() ? (
|
||||
<p className="mx-auto mt-4 max-w-2xl text-gray-600">
|
||||
{list[0].secondary_title.trim()}
|
||||
</p>
|
||||
) : list.length === 1 && list[0]?.secondary_title?.trim() ? (
|
||||
<p className="mx-auto mt-4 max-w-2xl text-gray-600">
|
||||
{list[0].secondary_title.trim()}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{list.map((s) => {
|
||||
const imgSrc = bannerUrl(s);
|
||||
const external = /^https?:\/\//i.test(imgSrc);
|
||||
const body = s.description?.trim() || DEFAULT_BODY;
|
||||
<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;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex flex-col overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-md"
|
||||
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" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="relative aspect-video w-full overflow-hidden bg-gray-100">
|
||||
{external ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
width={600}
|
||||
height={400}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
width={600}
|
||||
height={400}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
<h3 className="text-lg font-bold text-gray-900 whitespace-pre-line">
|
||||
{s.primary_title?.trim() || "Service"}
|
||||
</h3>
|
||||
<p className="mt-2 line-clamp-4 text-sm leading-relaxed text-gray-600 whitespace-pre-line">
|
||||
{body}
|
||||
</p>
|
||||
</div>
|
||||
<ServiceImageBlock imgSrc={imgSrc} alt={alt} />
|
||||
<ServiceTextBlock s={s} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export default function Technology({
|
|||
</p>
|
||||
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<div className="flex w-max animate-tech-scroll gap-14">
|
||||
<div className="flex w-max animate-tech-scroll gap-2">
|
||||
{loop.map((tech, index) => (
|
||||
<div
|
||||
key={`${tech.name}-${index}`}
|
||||
|
|
@ -44,8 +44,8 @@ export default function Technology({
|
|||
src={tech.src}
|
||||
alt={tech.name}
|
||||
width={120}
|
||||
height={50}
|
||||
className="max-h-[50px] object-contain"
|
||||
height={80}
|
||||
className="max-h-[80px] object-contain"
|
||||
/>
|
||||
) : tech.src ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ export default function ContentWebsite() {
|
|||
const [productPrimary, setProductPrimary] = useState("");
|
||||
const [productSecondary, setProductSecondary] = useState("");
|
||||
const [productDesc, setProductDesc] = useState("");
|
||||
const [productLinkUrl, setProductLinkUrl] = useState("");
|
||||
const [productRemoteUrl, setProductRemoteUrl] = useState("");
|
||||
const [productPendingFile, setProductPendingFile] = useState<File | null>(
|
||||
null,
|
||||
|
|
@ -134,6 +135,7 @@ export default function ContentWebsite() {
|
|||
const [servicePrimary, setServicePrimary] = useState("");
|
||||
const [serviceSecondary, setServiceSecondary] = useState("");
|
||||
const [serviceDesc, setServiceDesc] = useState("");
|
||||
const [serviceLinkUrl, setServiceLinkUrl] = useState("");
|
||||
const [serviceRemoteUrl, setServiceRemoteUrl] = useState("");
|
||||
const [servicePendingFile, setServicePendingFile] = useState<File | null>(
|
||||
null,
|
||||
|
|
@ -374,6 +376,7 @@ export default function ContentWebsite() {
|
|||
setProductPrimary("");
|
||||
setProductSecondary("");
|
||||
setProductDesc("");
|
||||
setProductLinkUrl("");
|
||||
revokeBlobRef(productBlobUrlRef);
|
||||
setProductPendingFile(null);
|
||||
setProductRemoteUrl("");
|
||||
|
|
@ -384,6 +387,7 @@ export default function ContentWebsite() {
|
|||
setProductPrimary(p.primary_title ?? "");
|
||||
setProductSecondary(p.secondary_title ?? "");
|
||||
setProductDesc(p.description ?? "");
|
||||
setProductLinkUrl(p.link_url ?? "");
|
||||
const im = p.images?.[0];
|
||||
revokeBlobRef(productBlobUrlRef);
|
||||
setProductPendingFile(null);
|
||||
|
|
@ -412,6 +416,7 @@ export default function ContentWebsite() {
|
|||
primary_title: productPrimary,
|
||||
secondary_title: productSecondary,
|
||||
description: productDesc,
|
||||
link_url: productLinkUrl,
|
||||
};
|
||||
let pid = productEditId;
|
||||
if (!pid) {
|
||||
|
|
@ -485,6 +490,7 @@ export default function ContentWebsite() {
|
|||
setServicePrimary("");
|
||||
setServiceSecondary("");
|
||||
setServiceDesc("");
|
||||
setServiceLinkUrl("");
|
||||
revokeBlobRef(serviceBlobUrlRef);
|
||||
setServicePendingFile(null);
|
||||
setServiceRemoteUrl("");
|
||||
|
|
@ -495,6 +501,7 @@ export default function ContentWebsite() {
|
|||
setServicePrimary(s.primary_title ?? "");
|
||||
setServiceSecondary(s.secondary_title ?? "");
|
||||
setServiceDesc(s.description ?? "");
|
||||
setServiceLinkUrl(s.link_url ?? "");
|
||||
const im = s.images?.[0];
|
||||
revokeBlobRef(serviceBlobUrlRef);
|
||||
setServicePendingFile(null);
|
||||
|
|
@ -798,12 +805,21 @@ export default function ContentWebsite() {
|
|||
if (!ok.isConfirmed) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await deletePopupNews(id);
|
||||
const res = await deletePopupNews(id);
|
||||
if (res?.error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Delete failed",
|
||||
text: String(res.message ?? "Could not delete pop up."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (popupEditId === id) {
|
||||
beginEditPopup(null);
|
||||
setPopupModalOpen(false);
|
||||
}
|
||||
await loadAll();
|
||||
await Swal.fire({ icon: "success", title: "Deleted", timer: 1200, showConfirmButton: false });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -870,7 +886,9 @@ export default function ContentWebsite() {
|
|||
<CardContent className="space-y-6 p-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Main Title</label>
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
Main Title <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="mt-2"
|
||||
value={heroPrimary}
|
||||
|
|
@ -879,21 +897,26 @@ export default function ContentWebsite() {
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Subtitle</label>
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
Subtitle <span className="text-xs font-normal text-slate-500">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
className="mt-2"
|
||||
value={heroSecondary}
|
||||
onChange={(e) => setHeroSecondary(e.target.value)}
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Description</label>
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
Description <span className="text-xs font-normal text-slate-500">(optional)</span>
|
||||
</label>
|
||||
<Textarea
|
||||
className="mt-2 min-h-[120px]"
|
||||
value={heroDesc}
|
||||
onChange={(e) => setHeroDesc(e.target.value)}
|
||||
placeholder="Supporting text"
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -929,19 +952,25 @@ export default function ContentWebsite() {
|
|||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Primary CTA Text</label>
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
Primary CTA Text <span className="text-xs font-normal text-slate-500">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
className="mt-2"
|
||||
value={heroCta1}
|
||||
onChange={(e) => setHeroCta1(e.target.value)}
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Secondary CTA Text</label>
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
Secondary CTA Text <span className="text-xs font-normal text-slate-500">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
className="mt-2"
|
||||
value={heroCta2}
|
||||
onChange={(e) => setHeroCta2(e.target.value)}
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1177,7 +1206,9 @@ export default function ContentWebsite() {
|
|||
{productEditId ? "Edit product" : "Add Product"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Fill in the details below. Image URL is shown on the product card on the homepage.
|
||||
Homepage shows each product as a full-width row (image and text alternate). Use one line per bullet in
|
||||
Description; optional "Label: detail" formats the label in bold. Product link powers the Learn
|
||||
More action.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
|
|
@ -1207,6 +1238,19 @@ export default function ContentWebsite() {
|
|||
onChange={(e) => setProductDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Product link (optional)</label>
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="text"
|
||||
placeholder="https://… or /path on this site"
|
||||
value={productLinkUrl}
|
||||
onChange={(e) => setProductLinkUrl(e.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Shown as "Learn More" on the landing page. Leave empty to disable the link.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Card image</label>
|
||||
<Input
|
||||
|
|
@ -1342,7 +1386,9 @@ export default function ContentWebsite() {
|
|||
{serviceEditId != null ? "Edit service" : "Add Service"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Banner image appears on the service card on the homepage.
|
||||
Same fields as Products: homepage shows each service as a full-width alternating row. One line per
|
||||
bullet in Description; optional "Label: detail" bolds the label. Service link powers Learn
|
||||
More.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
|
|
@ -1372,6 +1418,19 @@ export default function ContentWebsite() {
|
|||
onChange={(e) => setServiceDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Service link (optional)</label>
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="text"
|
||||
placeholder="https://… or /path on this site"
|
||||
value={serviceLinkUrl}
|
||||
onChange={(e) => setServiceLinkUrl(e.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Shown as "Learn More" on the landing page. Leave empty to disable the link.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Banner image</label>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --turbopack --port 4000",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ export async function saveOurProductContent(body: {
|
|||
primary_title: string;
|
||||
secondary_title?: string;
|
||||
description?: string;
|
||||
link_url?: string;
|
||||
}) {
|
||||
return await httpPostInterceptor("/our-product-contents", body);
|
||||
}
|
||||
|
|
@ -135,6 +136,7 @@ export async function updateOurProductContent(
|
|||
primary_title: string;
|
||||
secondary_title?: string;
|
||||
description?: string;
|
||||
link_url?: string;
|
||||
},
|
||||
) {
|
||||
return await httpPutInterceptor(`/our-product-contents/${id}`, body);
|
||||
|
|
@ -183,6 +185,7 @@ export async function saveOurServiceContent(body: {
|
|||
primary_title: string;
|
||||
secondary_title?: string;
|
||||
description?: string;
|
||||
link_url?: string;
|
||||
}) {
|
||||
return await httpPostInterceptor("/our-service-contents", body);
|
||||
}
|
||||
|
|
@ -193,6 +196,7 @@ export async function updateOurServiceContent(
|
|||
primary_title: string;
|
||||
secondary_title?: string;
|
||||
description?: string;
|
||||
link_url?: string;
|
||||
},
|
||||
) {
|
||||
return await httpPutInterceptor(`/our-service-contents/${id}`, body);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export type CmsProductContent = {
|
|||
primary_title: string;
|
||||
secondary_title: string;
|
||||
description: string;
|
||||
link_url?: string;
|
||||
images?: { id: string; image_url: string; image_path: string }[];
|
||||
};
|
||||
|
||||
|
|
@ -38,6 +39,7 @@ export type CmsServiceContent = {
|
|||
primary_title: string;
|
||||
secondary_title: string;
|
||||
description: string;
|
||||
link_url?: string;
|
||||
images?: { id: string; image_url: string; image_path: string }[];
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue