feat: update bug fixing content website and landing page
continuous-integration/drone/push Build is passing Details

This commit is contained in:
hanif salafi 2026-04-11 01:54:11 +07:00
parent 4c4ff5c0e8
commit b6fa161efb
9 changed files with 392 additions and 212 deletions

4
.env
View File

@ -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

View File

@ -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,25 +77,29 @@ export default function Header({ hero }: { hero?: CmsHeroContent | null }) {
</p>
) : null}
<div className="flex flex-wrap gap-3">
<Button
size="lg"
onClick={() => setContactOpen(true)}
className="rounded-full bg-[#966314] px-8 py-6 text-base hover:bg-[#7c520f]"
>
{primaryCta}
</Button>
{secondaryCta ? (
<Button
size="lg"
variant="outline"
className="rounded-full border-[#966314] px-8 py-6 text-base text-[#966314]"
type="button"
>
{secondaryCta}
</Button>
) : null}
</div>
{(primaryCta || secondaryCta) ? (
<div className="flex flex-wrap gap-3">
{primaryCta ? (
<Button
size="lg"
onClick={() => setContactOpen(true)}
className="rounded-full bg-[#966314] px-8 py-6 text-base hover:bg-[#7c520f]"
>
{primaryCta}
</Button>
) : null}
{secondaryCta ? (
<Button
size="lg"
variant="outline"
className="rounded-full border-[#966314] px-8 py-6 text-base text-[#966314]"
type="button"
>
{secondaryCta}
</Button>
) : null}
</div>
) : null}
</div>
<div className="relative hidden max-h-[min(90vh,520px)] flex-1 justify-end md:flex">

View File

@ -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>
);
})}

View File

@ -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>
);
})}

View File

@ -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

View File

@ -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 &quot;Label: detail&quot; 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 &quot;Learn More&quot; 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 &quot;Label: detail&quot; 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 &quot;Learn More&quot; 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

View File

@ -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"

View File

@ -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);

View File

@ -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 }[];
};