feat: add ui form for content website
continuous-integration/drone/push Build is failing
Details
continuous-integration/drone/push Build is failing
Details
This commit is contained in:
parent
1a17ce15aa
commit
2eaa34d052
49
app/page.tsx
49
app/page.tsx
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -187,3 +67,106 @@ export default function ProductSection() {
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
|
@ -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 }[];
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue