feat: add ui form for content website
continuous-integration/drone/push Build is failing Details

This commit is contained in:
hanif salafi 2026-04-10 14:21:29 +07:00
parent 1a17ce15aa
commit 2eaa34d052
11 changed files with 2385 additions and 521 deletions

View File

@ -5,19 +5,50 @@ import ServiceSection from "@/components/landing-page/service";
import Technology from "@/components/landing-page/technology"; import Technology from "@/components/landing-page/technology";
import Footer from "@/components/landing-page/footer"; import Footer from "@/components/landing-page/footer";
import FloatingMenu from "@/components/landing-page/floating"; import FloatingMenu from "@/components/landing-page/floating";
import PopupNewsBanner from "@/components/landing-page/popup-news";
import { publicFetch } from "@/lib/public-api";
import type {
CmsAboutContent,
CmsHeroContent,
CmsPartnerContent,
CmsPopupContent,
CmsProductContent,
CmsServiceContent,
} from "@/types/cms-landing";
export default async function Home() {
const [hero, aboutList, productList, serviceList, partners, popupList] =
await Promise.all([
publicFetch<CmsHeroContent | null>("/hero-contents"),
publicFetch<CmsAboutContent[]>("/about-us-contents"),
publicFetch<CmsProductContent[] | CmsProductContent>("/our-product-contents"),
publicFetch<CmsServiceContent[] | CmsServiceContent>("/our-service-contents"),
publicFetch<CmsPartnerContent[]>("/partner-contents"),
publicFetch<CmsPopupContent[]>("/popup-news-contents?page=1&limit=20"),
]);
const about = aboutList?.[0] ?? null;
const products = Array.isArray(productList)
? productList
: productList
? [productList]
: [];
const services = Array.isArray(serviceList)
? serviceList
: serviceList
? [serviceList]
: [];
const popups = Array.isArray(popupList) ? popupList : [];
export default function Home() {
return ( return (
<div className="relative min-h-screen bg-white font-[family-name:var(--font-geist-sans)]"> <div className="relative min-h-screen bg-white font-[family-name:var(--font-geist-sans)]">
{/* FIXED MENU */}
<FloatingMenu /> <FloatingMenu />
<PopupNewsBanner popups={popups} />
{/* PAGE CONTENT */} <Header hero={hero} />
<Header /> <AboutSection about={about} />
<AboutSection /> <ProductSection products={products} />
<ProductSection /> <ServiceSection services={services} />
<ServiceSection /> <Technology partners={partners ?? []} />
<Technology />
<Footer /> <Footer />
</div> </div>
); );

View File

@ -2,15 +2,28 @@
import Image from "next/image"; import Image from "next/image";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import type { CmsAboutContent } from "@/types/cms-landing";
export default function AboutSection() { const DEFAULT_KICKER = "About Us";
const socials = [ const DEFAULT_HEADLINE = "Helping you find the right Solution";
{ name: "Facebook", icon: "/image/fb.png" }, const DEFAULT_DESC =
{ name: "Instagram", icon: "/image/ig.png" }, "PT Qudo Buana Nawakara adalah perusahaan nasional Indonesia yang berfokus pada pengembangan aplikasi...";
{ name: "X", icon: "/image/x.png" },
{ name: "Youtube", icon: "/image/yt.png" }, export default function AboutSection({
{ name: "Tiktok", icon: "/image/tt.png" }, about,
]; }: {
about?: CmsAboutContent | null;
}) {
const kicker = about?.secondary_title?.trim() || DEFAULT_KICKER;
const headline = about?.primary_title?.trim() || DEFAULT_HEADLINE;
const desc = about?.description?.trim() || DEFAULT_DESC;
const media = about?.images?.[0];
const mediaUrl = media?.media_url?.trim();
const isVideo =
media?.media_type?.startsWith("video") ||
/\.(mp4|webm)(\?|$)/i.test(mediaUrl ?? "") ||
(mediaUrl?.toLowerCase().includes("video") ?? false);
const messages = [ const messages = [
{ id: 1, text: "Dimana posisi Ayah saya sekarang?", type: "user" }, { id: 1, text: "Dimana posisi Ayah saya sekarang?", type: "user" },
@ -30,58 +43,65 @@ export default function AboutSection() {
return ( return (
<section className="relative bg-[#f7f0e3] py-24"> <section className="relative bg-[#f7f0e3] py-24">
<div className="container mx-auto grid grid-cols-1 items-center gap-16 px-6 md:grid-cols-2"> <div className="container mx-auto grid grid-cols-1 items-center gap-16 px-6 md:grid-cols-2">
{/* PHONE WRAPPER */}
<div className="flex justify-center"> <div className="flex justify-center">
<div className="relative w-[320px] h-[640px]"> <div className="relative h-[640px] w-[320px]">
{/* PHONE IMAGE */}
<Image <Image
src="/image/phone.png" src="/image/phone.png"
alt="App Preview" alt="App Preview"
fill fill
className="object-contain z-10 pointer-events-none" className="pointer-events-none z-10 object-contain"
/> />
{/* CHAT AREA */} <div className="absolute bottom-[120px] left-[25px] right-[25px] top-[120px] z-0 overflow-hidden rounded-[2rem] bg-black/5">
<div className="absolute top-[120px] left-[25px] right-[25px] bottom-[120px] overflow-hidden z-0"> {mediaUrl && isVideo ? (
<div className="flex flex-col gap-4"> // eslint-disable-next-line jsx-a11y/media-has-caption
<video
src={mediaUrl}
className="h-full w-full object-cover"
autoPlay
muted
loop
playsInline
/>
) : mediaUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={mediaUrl} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex flex-col gap-4 p-2">
{messages.map((msg, index) => ( {messages.map((msg, index) => (
<motion.div <motion.div
key={msg.id} key={msg.id}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 1.2 }} transition={{ delay: index * 1.2 }}
className={`max-w-[80%] px-4 py-3 rounded-2xl text-sm shadow ${ className={`max-w-[80%] rounded-2xl px-4 py-3 text-sm shadow ${
msg.type === "user" msg.type === "user"
? "bg-blue-600 text-white self-end rounded-br-sm" ? "self-end rounded-br-sm bg-blue-600 text-white"
: "bg-gray-200 text-gray-800 self-start rounded-bl-sm" : "self-start rounded-bl-sm bg-gray-200 text-gray-800"
}`} }`}
> >
{msg.text} {msg.text}
</motion.div> </motion.div>
))} ))}
</div> </div>
)}
</div> </div>
</div> </div>
</div> </div>
{/* TEXT CONTENT */}
<div className="max-w-xl"> <div className="max-w-xl">
<p className="mb-4 text-sm font-semibold uppercase tracking-widest text-gray-400"> <p className="mb-4 text-sm font-semibold uppercase tracking-widest text-gray-400">
About Us {kicker}
</p> </p>
<h2 className="mb-6 text-4xl font-extrabold leading-tight"> <h2 className="mb-6 text-4xl font-extrabold leading-tight whitespace-pre-line">
Helping you find the right{" "}
<span className="relative inline-block"> <span className="relative inline-block">
<span className="absolute bottom-1 left-0 h-3 w-full bg-[#966314]/40"></span> <span className="absolute bottom-1 left-0 h-3 w-full bg-[#966314]/40"></span>
<span className="relative">Solution</span> <span className="relative">{headline}</span>
</span> </span>
</h2> </h2>
<p className="text-sm leading-relaxed text-gray-600"> <p className="text-sm leading-relaxed text-gray-600 whitespace-pre-line">{desc}</p>
PT Qudo Buana Nawakara adalah perusahaan nasional Indonesia yang
berfokus pada pengembangan aplikasi...
</p>
</div> </div>
</div> </div>
</section> </section>

View File

@ -8,11 +8,25 @@ import { Input } from "../ui/input";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import { Textarea } from "../ui/textarea"; import { Textarea } from "../ui/textarea";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import type { CmsHeroContent } from "@/types/cms-landing";
export default function Header() { function isExternalUrl(url: string) {
return /^https?:\/\//i.test(url);
}
export default function Header({ hero }: { hero?: CmsHeroContent | null }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [contactOpen, setContactOpen] = useState(false); const [contactOpen, setContactOpen] = useState(false);
const title =
hero?.primary_title?.trim() ||
"Beyond Expectations to Build Reputation.";
const subtitle = hero?.secondary_title?.trim();
const lead = hero?.description?.trim();
const primaryCta = hero?.primary_cta?.trim() || "Contact Us";
const secondaryCta = hero?.secondary_cta_text?.trim();
const heroImg = hero?.images?.[0]?.image_url?.trim();
return ( return (
<> <>
<header className="relative w-full bg-white overflow-hidden"> <header className="relative w-full bg-white overflow-hidden">
@ -47,32 +61,58 @@ export default function Header() {
{/* HERO */} {/* HERO */}
<div className="container mx-auto flex min-h-[90vh] items-center px-6"> <div className="container mx-auto flex min-h-[90vh] items-center px-6">
<div className="flex-1 space-y-6"> <div className="flex-1 space-y-6">
<h1 className="text-4xl font-extrabold leading-tight md:text-6xl"> <h1 className="text-4xl font-extrabold leading-tight whitespace-pre-line md:text-6xl">
<span className="relative inline-block"> {title}
<span className="absolute bottom-1 left-0 h-3 w-full bg-[#966314]"></span>
<span className="relative">Beyond Expectations</span>
</span>
<br />
Build <span className="text-[#966314]">Reputation.</span>
</h1> </h1>
{subtitle ? (
<p className="text-lg text-gray-600 md:text-xl">{subtitle}</p>
) : null}
{lead ? (
<p className="max-w-xl text-sm leading-relaxed text-gray-600 md:text-base whitespace-pre-line">
{lead}
</p>
) : null}
<div className="flex flex-wrap gap-3">
<Button <Button
size="lg" size="lg"
onClick={() => setContactOpen(true)} onClick={() => setContactOpen(true)}
className="rounded-full bg-[#966314] px-8 py-6 text-base hover:bg-[#7c520f]" className="rounded-full bg-[#966314] px-8 py-6 text-base hover:bg-[#7c520f]"
> >
Contact Us {primaryCta}
</Button> </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>
</div> </div>
<div className="relative hidden flex-1 justify-end md:flex"> <div className="relative hidden max-h-[min(90vh,520px)] flex-1 justify-end md:flex">
{heroImg && isExternalUrl(heroImg) ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={heroImg}
alt=""
width={520}
height={520}
className="max-h-[520px] w-auto object-contain"
/>
) : (
<Image <Image
src="/image/img1.png" src={heroImg || "/image/img1.png"}
alt="Illustration" alt="Illustration"
width={520} width={520}
height={520} height={520}
className="object-contain" className="object-contain"
/> />
)}
</div> </div>
</div> </div>
</header> </header>

View File

@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import type { CmsPopupContent } from "@/types/cms-landing";
export default function PopupNewsBanner({
popups,
}: {
popups?: CmsPopupContent[] | null;
}) {
const list = popups?.filter((p) => p.primary_title?.trim()) ?? [];
const [open, setOpen] = useState(true);
const [idx, setIdx] = useState(0);
if (!open || list.length === 0) return null;
const popup = list[idx % list.length];
const img = popup.images?.[0]?.media_url?.trim();
return (
<div className="fixed bottom-6 left-4 right-4 z-[120] mx-auto max-w-lg md:left-auto md:right-6 md:mx-0">
<div className="relative overflow-hidden rounded-2xl border border-amber-200 bg-white shadow-2xl">
<button
type="button"
onClick={() => setOpen(false)}
className="absolute right-3 top-3 z-10 rounded-full bg-black/5 p-1 text-gray-600 hover:bg-black/10"
aria-label="Close"
>
<X size={18} />
</button>
{list.length > 1 ? (
<>
<button
type="button"
onClick={() => setIdx((i) => (i - 1 + list.length) % list.length)}
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/5 p-1.5 text-gray-700 hover:bg-black/10"
aria-label="Previous"
>
<ChevronLeft size={18} />
</button>
<button
type="button"
onClick={() => setIdx((i) => (i + 1) % list.length)}
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/5 p-1.5 text-gray-700 hover:bg-black/10"
aria-label="Next"
>
<ChevronRight size={18} />
</button>
</>
) : null}
{img ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={img} alt="" className="h-32 w-full object-cover md:h-40" />
) : null}
<div className="p-4 pr-10">
<p className="text-xs font-semibold uppercase tracking-wider text-amber-800">
{popup.secondary_title || "News"}
</p>
<h3 className="mt-1 text-lg font-bold text-gray-900">{popup.primary_title}</h3>
{popup.description ? (
<p className="mt-2 line-clamp-3 text-sm text-gray-600">{popup.description}</p>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
{popup.primary_cta ? (
<span className="inline-flex rounded-full bg-[#966314] px-4 py-2 text-xs font-semibold text-white">
{popup.primary_cta}
</span>
) : null}
{popup.secondary_cta_text ? (
<span className="inline-flex rounded-full border border-[#966314] px-4 py-2 text-xs font-semibold text-[#966314]">
{popup.secondary_cta_text}
</span>
) : null}
</div>
{list.length > 1 ? (
<div className="mt-3 flex justify-center gap-1.5">
{list.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setIdx(i)}
className={`h-1.5 w-1.5 rounded-full ${
i === idx % list.length ? "bg-[#966314]" : "bg-gray-300"
}`}
aria-label={`Go to item ${i + 1}`}
/>
))}
</div>
) : null}
</div>
</div>
</div>
);
}

View File

@ -1,184 +1,64 @@
import Image from "next/image"; import Image from "next/image";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import type { CmsProductContent } from "@/types/cms-landing";
export default function ProductSection() { const DEFAULT_TITLE =
const features = [ "The product we offer is designed to meet your business needs.";
"Content Creation: Producing creative and engaging content such as posts, images, videos, and stories that align with the brand and attract audience attention.", const DEFAULT_BODY =
"Social Media Account Management: Managing business social media accounts, including scheduling posts, monitoring interactions, and engaging with followers.", "Social media marketing services are provided by companies or individuals who specialize in marketing strategies through social media platforms.";
"Paid Advertising Campaigns: Designing, executing, and managing paid advertising campaigns on various social media platforms to reach a more specific target audience and improve ROI (Return on Investment).",
]; function cardImageUrl(p: CmsProductContent) {
return p.images?.[0]?.image_url?.trim() || "/image/p1.png";
}
export default function ProductSection({
products,
}: {
products?: CmsProductContent[] | null;
}) {
const list = products?.filter((p) => p.id) ?? [];
if (list.length === 0) {
return ( return (
<section className="bg-white py-32"> <section className="bg-white py-32">
<div className="container mx-auto px-6"> <div className="container mx-auto px-6">
{/* TITLE */}
<div className="mb-20 text-center"> <div className="mb-20 text-center">
<p className="mb-4 text-sm font-semibold uppercase tracking-widest text-gray-400"> <p className="mb-4 text-sm font-semibold uppercase tracking-widest text-gray-400">
Our Product Our Product
</p> </p>
<h2 className="mx-auto max-w-3xl text-4xl font-extrabold leading-tight md:text-5xl whitespace-pre-line">
<h2 className="mx-auto max-w-3xl text-4xl font-extrabold leading-tight md:text-5xl"> {DEFAULT_TITLE}
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> </h2>
</div> </div>
{/* CONTENT */}
<div className="grid grid-cols-1 items-center gap-16 md:grid-cols-2"> <div className="grid grid-cols-1 items-center gap-16 md:grid-cols-2">
{/* LEFT IMAGE */}
<div className="flex justify-center"> <div className="flex justify-center">
<Image <Image
src="/image/p1.png" src="/image/p1.png"
alt="Product Illustration" alt="Product illustration"
width={520} width={520}
height={420} height={420}
className="object-contain" className="object-contain"
/> />
</div> </div>
{/* RIGHT CONTENT */}
<div className="max-w-xl"> <div className="max-w-xl">
{/* ICON */}
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-xl bg-[#fdecc8]"> <div className="mb-6 flex h-12 w-12 items-center justify-center rounded-xl bg-[#fdecc8]">
<Image <Image
src="/image/product-icon.png" src="/image/product-icon.png"
alt="Product Icon" alt=""
width={22} width={22}
height={22} height={22}
/> />
</div> </div>
<h3 className="mb-4 text-2xl font-bold text-gray-900"> <h3 className="mb-4 text-2xl font-bold text-gray-900">
MediaHUB Content Aggregator MediaHUB Content Aggregator
</h3> </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"> {DEFAULT_BODY}
Social media marketing services are provided by companies or
individuals who specialize in marketing strategies through social
media platforms.
</p> </p>
<button
{/* FEATURES */} type="button"
<ul className="mb-6 space-y-4"> className="text-sm font-semibold text-[#966314] hover:underline"
{features.map((item) => ( >
<li key={item} className="flex gap-3 text-sm text-gray-600">
<span className="mt-1 flex h-8 w-16 items-center justify-center rounded-full bg-[#fdecc8]">
<Check size={12} className="text-[#966314]" />
</span>
{item}
</li>
))}
</ul>
{/* CTA */}
<button className="text-sm font-semibold text-[#966314] hover:underline">
Learn More
</button>
</div>
</div>
<div className="grid grid-cols-1 items-center gap-16 md:grid-cols-2 mt-10">
{/* LEFT IMAGE */}
{/* RIGHT CONTENT */}
<div className="max-w-xl ml-10">
{/* ICON */}
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-xl bg-[#fdecc8]">
<Image
src="/image/product-icon.png"
alt="Product Icon"
width={22}
height={22}
/>
</div>
<h3 className="mb-4 text-2xl font-bold text-gray-900">
Multipool Reputation Management
</h3>
<p className="mb-8 text-sm leading-relaxed text-gray-600">
Social media marketing services are provided by companies or
individuals who specialize in marketing strategies through social
media platforms.
</p>
{/* FEATURES */}
<ul className="mb-6 space-y-4">
{features.map((item) => (
<li key={item} className="flex gap-3 text-sm text-gray-600">
<span className="mt-1 flex h-8 w-16 items-center justify-center rounded-full bg-[#fdecc8]">
<Check size={12} className="text-[#966314]" />
</span>
{item}
</li>
))}
</ul>
{/* CTA */}
<button className="text-sm font-semibold text-[#966314] hover:underline">
Learn More
</button>
</div>
<div className="flex justify-center">
<Image
src="/image/p2.png"
alt="Product Illustration"
width={520}
height={420}
className="object-contain"
/>
</div>
</div>
<div className="grid grid-cols-1 items-center gap-16 md:grid-cols-2 mt-10">
{/* LEFT IMAGE */}
<div className="flex justify-center">
<Image
src="/image/p3.png"
alt="Product Illustration"
width={520}
height={420}
className="object-contain"
/>
</div>
{/* RIGHT CONTENT */}
<div className="max-w-xl">
{/* ICON */}
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-xl bg-[#fdecc8]">
<Image
src="/image/product-icon.png"
alt="Product Icon"
width={22}
height={22}
/>
</div>
<h3 className="mb-4 text-2xl font-bold text-gray-900">
PR Room Opinion Management
</h3>
<p className="mb-8 text-sm leading-relaxed text-gray-600">
Social media marketing services are provided by companies or
individuals who specialize in marketing strategies through social
media platforms.
</p>
{/* FEATURES */}
<ul className="mb-6 space-y-4">
{features.map((item) => (
<li key={item} className="flex gap-3 text-sm text-gray-600">
<span className="mt-1 flex h-8 w-16 items-center justify-center rounded-full bg-[#fdecc8]">
<Check size={12} className="text-[#966314]" />
</span>
{item}
</li>
))}
</ul>
{/* CTA */}
<button className="text-sm font-semibold text-[#966314] hover:underline">
Learn More Learn More
</button> </button>
</div> </div>
@ -186,4 +66,107 @@ export default function ProductSection() {
</div> </div>
</section> </section>
); );
}
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">
<div className="container mx-auto px-6">
<div className="mb-16 text-center">
<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>
{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) => {
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 ?? "";
return (
<div
key={p.id}
className="flex flex-col overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm"
>
<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>
</div>
);
})}
</div>
</div>
</section>
);
} }

View File

@ -1,189 +1,113 @@
import Image from "next/image"; import Image from "next/image";
import type { CmsServiceContent } from "@/types/cms-landing";
export default function ServiceSection() { 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) {
return s.images?.[0]?.image_url?.trim() || "/image/s1.png";
}
export default function ServiceSection({
services,
}: {
services?: CmsServiceContent[] | null;
}) {
const list = services?.filter((s) => s.id) ?? [];
if (list.length === 0) {
const imgSrc = "/image/s1.png";
return ( return (
<section className="py-20 bg-white"> <section className="bg-white py-20">
<div className="container mx-auto px-6"> <div className="container mx-auto px-6">
{/* Heading */} <div className="mb-16 text-center">
<div className="text-center mb-16"> <p className="text-sm uppercase tracking-widest text-gray-400">Our Services</p>
<p className="text-sm uppercase tracking-widest text-gray-400"> <h2 className="mt-2 text-3xl font-bold text-gray-900 md:text-4xl whitespace-pre-line">
Our Services {DEFAULT_HEADING}
</p>
<h2 className="mt-2 text-3xl md:text-4xl font-bold text-gray-900">
Innovative solutions for your{" "}
<span className="relative inline-block">
business growth
<span className="absolute left-0 -bottom-1 w-full h-2 bg-yellow-300/60 -z-10" />
</span>
.
</h2> </h2>
</div> </div>
<div className="grid items-center gap-12 md:grid-cols-2">
{/* Service 1 */} <div className="overflow-hidden rounded-2xl shadow-lg">
<div className="grid md:grid-cols-2 gap-12 items-center mb-20">
{/* Image */}
<div className="rounded-2xl overflow-hidden shadow-lg">
<Image <Image
src="/image/s1.png" src={imgSrc}
alt="Artifintel Soundworks" alt="Service"
width={600} width={600}
height={400} height={400}
className="w-full h-auto object-cover" className="h-auto w-full object-cover"
/> />
</div> </div>
{/* Content */}
<div> <div>
<h3 className="text-2xl font-semibold text-gray-900 mb-4"> <p className="leading-relaxed text-gray-600 whitespace-pre-line">{DEFAULT_BODY}</p>
Artifintel
</h3>
<p className="text-gray-600 mb-6 leading-relaxed">
Artifintel Soundworks adalah pionir musik AI yang menghadirkan
karya dan kolaborasi dari musisi AI penuh kreativitas dan emosi.
Sejak 2024, Artifintel telah merilis belasan hingga puluhan lagu
dengan melodi memukau dan ritme inovatif.
</p>
<ul className="space-y-3 text-gray-700">
<li> AI Music Composition & Songwriting</li>
<li> Vocal Synthesis & AI Musicians</li>
<li> Genre Exploration & Sound Innovation</li>
<li> AI Collaboration & Creative Experimentation</li>
<li> Music Release & Digital Distribution</li>
</ul>
</div>
</div>
{/* Service 2 */}
<div className="grid md:grid-cols-2 gap-12 items-center mb-20">
{/* Content */}
<div>
<h3 className="text-2xl font-semibold text-gray-900 mb-4">
Produksi Video Animasi
</h3>
<p className="text-gray-600 mb-6 leading-relaxed">
Professional animation production services that bring your ideas
to life. From explainer videos to brand storytelling, we create
engaging animated content that resonates with your audience.
</p>
<ul className="space-y-3 text-gray-700">
<li> 2D & 3D Animation Production</li>
<li> Motion Graphics & Visual Effects</li>
<li> Character Design & Storyboarding</li>
</ul>
</div>
{/* Image */}
<div className="rounded-2xl overflow-hidden shadow-lg">
<Image
src="/image/s2.png"
alt="Animasee"
width={600}
height={400}
className="w-full h-auto object-cover"
/>
</div>
</div>
<div className="grid md:grid-cols-2 gap-12 items-center mb-20 mt-10">
{/* Image */}
<div className="rounded-2xl overflow-hidden shadow-lg">
<Image
src="/image/s3.png"
alt="Artifintel Soundworks"
width={600}
height={400}
className="w-full h-auto object-cover"
/>
</div>
{/* Content */}
<div>
<h3 className="text-2xl font-semibold text-gray-900 mb-4">
Reelithic
</h3>
<p className="text-gray-600 mb-6 leading-relaxed">
Artifintel Soundworks adalah pionir musik AI yang menghadirkan
karya dan kolaborasi dari musisi AI penuh kreativitas dan emosi.
Sejak 2024, Artifintel telah merilis belasan hingga puluhan lagu
dengan melodi memukau dan ritme inovatif.
</p>
<ul className="space-y-3 text-gray-700">
<li> AI Music Composition & Songwriting</li>
<li> Vocal Synthesis & AI Musicians</li>
<li> Genre Exploration & Sound Innovation</li>
<li> AI Collaboration & Creative Experimentation</li>
<li> Music Release & Digital Distribution</li>
</ul>
</div>
</div>
{/* Service 3 */}
<div className="grid md:grid-cols-2 gap-12 items-center mb-20">
{/* Content */}
<div>
<h3 className="text-2xl font-semibold text-gray-900 mb-4">
Qudoin
</h3>
<p className="text-gray-600 mb-6 leading-relaxed">
Professional animation production services that bring your ideas
to life. From explainer videos to brand storytelling, we create
engaging animated content that resonates with your audience.
</p>
<ul className="space-y-3 text-gray-700">
<li> 2D & 3D Animation Production</li>
<li> Motion Graphics & Visual Effects</li>
<li> Character Design & Storyboarding</li>
</ul>
</div>
{/* Image */}
<div className="rounded-2xl overflow-hidden shadow-lg">
<Image
src="/image/s4.png"
alt="Animasee"
width={600}
height={400}
className="w-full h-auto object-cover"
/>
</div>
</div>
<div className="grid md:grid-cols-2 gap-12 items-center mb-20 mt-10">
{/* Image */}
<div className="rounded-2xl overflow-hidden shadow-lg">
<Image
src="/image/s5.png"
alt="Artifintel Soundworks"
width={600}
height={400}
className="w-full h-auto object-cover"
/>
</div>
{/* Content */}
<div>
<h3 className="text-2xl font-semibold text-gray-900 mb-4">
Talkshow AI
</h3>
<p className="text-gray-600 mb-6 leading-relaxed">
Artifintel Soundworks adalah pionir musik AI yang menghadirkan
karya dan kolaborasi dari musisi AI penuh kreativitas dan emosi.
Sejak 2024, Artifintel telah merilis belasan hingga puluhan lagu
dengan melodi memukau dan ritme inovatif.
</p>
<ul className="space-y-3 text-gray-700">
<li> AI Music Composition & Songwriting</li>
<li> Vocal Synthesis & AI Musicians</li>
<li> Genre Exploration & Sound Innovation</li>
<li> AI Collaboration & Creative Experimentation</li>
<li> Music Release & Digital Distribution</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
); );
}
return (
<section className="bg-white py-20">
<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}
</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;
return (
<div
key={s.id}
className="flex flex-col overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-md"
>
<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>
</div>
);
})}
</div>
</div>
</section>
);
} }

View File

@ -1,6 +1,6 @@
import Image from "next/image"; import type { CmsPartnerContent } from "@/types/cms-landing";
const technologies = [ const FALLBACK: { name: string; src: string }[] = [
{ name: "Tableau", src: "/image/tableu.png" }, { name: "Tableau", src: "/image/tableu.png" },
{ name: "TVU Networks", src: "/image/tvu.png" }, { name: "TVU Networks", src: "/image/tvu.png" },
{ name: "AWS", src: "/image/aws.png" }, { name: "AWS", src: "/image/aws.png" },
@ -9,31 +9,56 @@ const technologies = [
{ name: "Ui", src: "/image/uipath.png" }, { name: "Ui", src: "/image/uipath.png" },
]; ];
export default function Technology() { export default function Technology({
partners,
}: {
partners?: CmsPartnerContent[] | null;
}) {
const list =
partners && partners.length > 0
? partners.map((p) => ({
name: p.primary_title,
src: p.image_url || p.image_path || "",
}))
: FALLBACK;
const loop = [...list, ...list];
return ( return (
<section className="relative overflow-hidden bg-gradient-to-r from-[#faf6ee] to-[#f4efe6] py-14"> <section className="relative overflow-hidden bg-gradient-to-r from-[#faf6ee] to-[#f4efe6] py-14">
<div className="container mx-auto px-6"> <div className="container mx-auto px-6">
{/* Title */} <p className="mb-8 text-center text-lg font-semibold tracking-widest text-gray-500">
<p className="text-center text-lg font-semibold tracking-widest text-gray-500 mb-8">
TECHNOLOGY PARTNERS TECHNOLOGY PARTNERS
</p> </p>
{/* Slider */}
<div className="relative w-full overflow-hidden"> <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-14">
{/* duplicated for seamless loop */} {loop.map((tech, index) => (
{[...technologies, ...technologies].map((tech, index) => (
<div <div
key={index} key={`${tech.name}-${index}`}
className="flex items-center justify-center min-w-[140px] opacity-80 hover:opacity-100 transition" className="flex min-w-[140px] items-center justify-center opacity-80 transition hover:opacity-100"
> >
<Image {tech.src && /^https?:\/\//i.test(tech.src) ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={tech.src}
alt={tech.name}
width={120}
height={50}
className="max-h-[50px] object-contain"
/>
) : tech.src ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={tech.src} src={tech.src}
alt={tech.name} alt={tech.name}
width={120} width={120}
height={50} height={50}
className="object-contain" className="object-contain"
/> />
) : (
<span className="text-sm text-gray-500">{tech.name}</span>
)}
</div> </div>
))} ))}
</div> </div>

File diff suppressed because it is too large Load Diff

27
lib/public-api.ts Normal file
View File

@ -0,0 +1,27 @@
const DEFAULT_CLIENT_KEY = "9ca7f706-a8b0-4520-b467-5e8321df36fb";
function clientKey() {
return process.env.NEXT_PUBLIC_X_CLIENT_KEY ?? DEFAULT_CLIENT_KEY;
}
function apiBase() {
const base = process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "");
return base ?? "";
}
/** Server-side fetch for public landing data (requires `X-Client-Key`). */
export async function publicFetch<T>(path: string): Promise<T | null> {
const base = apiBase();
if (!base) return null;
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
const res = await fetch(url, {
headers: {
"Content-Type": "application/json",
"X-Client-Key": clientKey(),
},
next: { revalidate: 60 },
});
if (!res.ok) return null;
const json = (await res.json()) as { data?: T };
return json.data ?? null;
}

270
service/cms-landing.ts Normal file
View File

@ -0,0 +1,270 @@
import {
httpDeleteInterceptor,
httpGetInterceptor,
httpPostInterceptor,
httpPutInterceptor,
} from "./http-config/http-interceptor-services";
/** Axios wrapper returns `{ error, data }` where `data` is full API body `{ success, messages, data }`. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function apiPayload<T>(res: any): T | null {
if (!res || typeof res !== "object") return null;
const r = res as { error?: boolean; data?: { data?: T } | null };
if (r.error || r.data == null) return null;
return r.data.data ?? null;
}
export function apiRows<T>(res: any): T[] {
const rows = apiPayload<T[]>(res);
return Array.isArray(rows) ? rows : [];
}
/** Normalizes list endpoints that return either a single object or an array. */
export function apiDataList<T>(res: unknown): T[] {
const raw = apiPayload<T[] | T>(res);
if (Array.isArray(raw)) return raw;
if (raw != null && typeof raw === "object") return [raw as T];
return [];
}
export async function getHeroContent() {
return await httpGetInterceptor("/hero-contents");
}
export async function saveHeroContent(body: {
primary_title: string;
secondary_title?: string;
description?: string;
primary_cta?: string;
secondary_cta_text?: string;
}) {
return await httpPostInterceptor("/hero-contents", body);
}
export async function updateHeroContent(
id: string,
body: {
primary_title: string;
secondary_title?: string;
description?: string;
primary_cta?: string;
secondary_cta_text?: string;
},
) {
return await httpPutInterceptor(`/hero-contents/${id}`, body);
}
export async function getHeroImages(heroId: string) {
return await httpGetInterceptor(`/hero-content-images/${heroId}`);
}
export async function saveHeroImage(body: {
hero_content_id: string;
image_path?: string;
image_url?: string;
}) {
return await httpPostInterceptor("/hero-content-images", body);
}
export async function updateHeroImage(
id: string,
body: { image_path?: string; image_url?: string },
) {
return await httpPutInterceptor(`/hero-content-images/${id}`, body);
}
export async function getAboutContentsList() {
return await httpGetInterceptor("/about-us-contents");
}
export async function saveAboutContent(body: Record<string, string>) {
return await httpPostInterceptor("/about-us-contents", body);
}
export async function updateAboutContent(
id: number,
body: Record<string, string>,
) {
return await httpPutInterceptor(`/about-us-contents/${id}`, body);
}
export async function saveAboutUsMediaUrl(body: {
about_us_content_id: number;
media_url: string;
media_type?: string;
}) {
return await httpPostInterceptor("/about-us-content-images/url", body);
}
export async function deleteAboutUsContentImage(id: number) {
return await httpDeleteInterceptor(`/about-us-content-images/${id}`);
}
export async function getOurProductContent() {
return await httpGetInterceptor("/our-product-contents");
}
export async function saveOurProductContent(body: {
primary_title: string;
secondary_title?: string;
description?: string;
}) {
return await httpPostInterceptor("/our-product-contents", body);
}
export async function updateOurProductContent(
id: string,
body: {
primary_title: string;
secondary_title?: string;
description?: string;
},
) {
return await httpPutInterceptor(`/our-product-contents/${id}`, body);
}
export async function deleteOurProductContent(id: string) {
return await httpDeleteInterceptor(`/our-product-contents/${id}`);
}
export async function getOurProductImages(productContentId: string) {
return await httpGetInterceptor(
`/our-product-content-images/${productContentId}`,
);
}
export async function saveOurProductImage(body: {
our_product_content_id: string;
image_path?: string;
image_url?: string;
}) {
return await httpPostInterceptor("/our-product-content-images", body);
}
export async function updateOurProductImage(
id: string,
body: { image_path?: string; image_url?: string },
) {
return await httpPutInterceptor(`/our-product-content-images/${id}`, body);
}
export async function getOurServiceContent() {
return await httpGetInterceptor("/our-service-contents");
}
export async function saveOurServiceContent(body: {
primary_title: string;
secondary_title?: string;
description?: string;
}) {
return await httpPostInterceptor("/our-service-contents", body);
}
export async function updateOurServiceContent(
id: number,
body: {
primary_title: string;
secondary_title?: string;
description?: string;
},
) {
return await httpPutInterceptor(`/our-service-contents/${id}`, body);
}
export async function deleteOurServiceContent(id: number) {
return await httpDeleteInterceptor(`/our-service-contents/${id}`);
}
export async function getOurServiceImages(serviceContentId: number) {
return await httpGetInterceptor(
`/our-service-content-images/${serviceContentId}`,
);
}
export async function saveOurServiceImage(body: {
our_service_content_id: number;
image_path?: string;
image_url?: string;
}) {
return await httpPostInterceptor("/our-service-content-images", body);
}
export async function updateOurServiceImage(
id: string,
body: { image_path?: string; image_url?: string },
) {
return await httpPutInterceptor(`/our-service-content-images/${id}`, body);
}
export async function getPartnerContents() {
return await httpGetInterceptor("/partner-contents");
}
export async function savePartnerContent(body: {
primary_title: string;
image_path?: string;
image_url?: string;
}) {
return await httpPostInterceptor("/partner-contents", body);
}
export async function updatePartnerContent(
id: string,
body: {
primary_title: string;
image_path?: string;
image_url?: string;
},
) {
return await httpPutInterceptor(`/partner-contents/${id}`, body);
}
export async function deletePartnerContent(id: string) {
return await httpDeleteInterceptor(`/partner-contents/${id}`);
}
export async function getPopupNewsList(page = 1, limit = 10) {
return await httpGetInterceptor(
`/popup-news-contents?page=${page}&limit=${limit}`,
);
}
export async function getPopupNewsById(id: number) {
return await httpGetInterceptor(`/popup-news-contents/${id}`);
}
export async function savePopupNews(body: {
primary_title: string;
secondary_title?: string;
description?: string;
primary_cta?: string;
secondary_cta_text?: string;
}) {
return await httpPostInterceptor("/popup-news-contents", body);
}
export async function updatePopupNews(
id: number,
body: {
id: number;
primary_title: string;
secondary_title?: string;
description?: string;
primary_cta?: string;
secondary_cta_text?: string;
},
) {
return await httpPutInterceptor(`/popup-news-contents/${id}`, body);
}
export async function savePopupNewsImage(body: {
popup_news_content_id: number;
media_path?: string;
media_url?: string;
}) {
return await httpPostInterceptor("/popup-news-content-images", body);
}
export async function deletePopupNews(id: number) {
return await httpDeleteInterceptor(`/popup-news-contents/${id}`);
}

59
types/cms-landing.ts Normal file
View File

@ -0,0 +1,59 @@
export type CmsHeroContent = {
id: string;
primary_title: string;
secondary_title: string;
description: string;
primary_cta: string;
secondary_cta_text: string;
images?: CmsHeroImage[];
};
export type CmsHeroImage = {
id: string;
hero_content_id: string;
image_path: string;
image_url: string;
};
export type CmsAboutContent = {
id: number;
primary_title: string;
secondary_title: string;
description: string;
primary_cta: string;
secondary_cta_text: string;
images?: { id: number; media_url: string; media_type?: string }[];
};
export type CmsProductContent = {
id: string;
primary_title: string;
secondary_title: string;
description: string;
images?: { id: string; image_url: string; image_path: string }[];
};
export type CmsServiceContent = {
id: number;
primary_title: string;
secondary_title: string;
description: string;
images?: { id: string; image_url: string; image_path: string }[];
};
export type CmsPartnerContent = {
id: string;
primary_title: string;
image_path: string;
image_url: string;
};
export type CmsPopupContent = {
id: number;
primary_title: string;
secondary_title: string;
description: string;
primary_cta: string;
secondary_cta_text: string;
images?: { id: number; media_url: string; media_path: string }[];
};