Files
afriadirahman 8c6dad2a31
tests / ci (push) Canceled after 0s
Admin Panel
2026-09-29 14:57:37 +07:00

572 lines
27 KiB
TypeScript

import { Head, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { Button, ButtonLink } from '@/components/site/button';
import Card from '@/components/site/card';
import Eyebrow from '@/components/site/eyebrow';
import FeatureCard from '@/components/site/feature-card';
import Icon from '@/components/site/icon';
import IconTile from '@/components/site/icon-tile';
import ImageCard from '@/components/site/image-card';
import PageHero from '@/components/site/page-hero';
import RichText from '@/components/site/rich-text';
import Section from '@/components/site/section';
import SectionHeader from '@/components/site/section-header';
import CtaBanner from '@/components/site/sections/cta-banner';
import { clientsContent } from '@/content/clients';
import type { Portfolio } from '@/data/projects';
import { fillCounts, yearRange } from '@/data/projects';
import { company, consultationUrl, officeAddress } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { usePortfolio } from '@/hooks/use-portfolio';
import { useSiteInfo } from '@/hooks/use-site-info';
import { cn } from '@/lib/utils';
import { contact } from '@/routes';
import type { LocalizedTestimonial, ProjectSector } from '@/types';
type Content = typeof clientsContent.id;
export default function Clients() {
const stats = usePortfolio();
const t = fillCounts(useContent(clientsContent), stats);
const site = useSiteInfo();
return (
<>
<Head title={t.title} />
<PageHero
align="center"
badge={t.hero.badge}
title={<RichText text={t.hero.title} />}
description={t.hero.description}
>
<Card className="grid w-full max-w-5xl grid-cols-2 gap-4 md:grid-cols-4">
{t.hero.stats.map((stat) => (
<div
key={stat.caption}
className="bg-surface-container flex flex-col items-center justify-center rounded-lg p-4 text-center"
>
<span className="font-display text-headline-lg text-primary font-bold">
{stat.value}
</span>
<span className="text-on-surface-variant mt-1 text-xs font-semibold">
{stat.caption}
</span>
</div>
))}
</Card>
</PageHero>
<Section>
<div className="grid grid-cols-1 items-center gap-8 lg:grid-cols-12">
<div className="flex flex-col gap-6 lg:col-span-5">
<Eyebrow icon="verified_user">
{t.heritage.eyebrow}
</Eyebrow>
<h2 className="font-display text-on-surface lg:text-headline-xl text-3xl leading-tight font-extrabold">
{t.heritage.title}
</h2>
<p className="text-on-surface-variant text-base leading-relaxed">
{t.heritage.description}
</p>
<Card padding="sm" className="flex items-start gap-4">
<IconTile icon="hub" tone="solid" />
<div>
<h3 className="font-display text-on-surface text-lg font-semibold">
{t.heritage.scsrTitle}
</h3>
<p className="text-on-surface-variant mt-1 text-sm">
{t.heritage.scsrText}
</p>
</div>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:col-span-7">
{t.heritage.images.map((image, index) => (
<ImageCard
key={image.src}
{...image}
className={
index === 1 ? 'sm:translate-y-8' : ''
}
/>
))}
</div>
</div>
</Section>
<SectorPortfolio t={t} stats={stats} />
<FeaturedPrograms t={t} stats={stats} />
<Section tone="muted">
<div className="flex flex-col gap-12">
<SectionHeader
align="center"
eyebrow={t.reasons.eyebrow}
title={t.reasons.title}
description={t.reasons.description}
/>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{t.reasons.items.map((reason) => (
<FeatureCard
key={reason.icon}
iconTone="solid"
{...reason}
/>
))}
</div>
</div>
</Section>
<Testimonials t={t} />
<Section tone="muted">
<Card
padding="lg"
className="flex flex-col items-center justify-between gap-10 lg:flex-row lg:p-12"
>
<div className="max-w-xl">
<Eyebrow>{t.reliability.eyebrow}</Eyebrow>
<h2 className="font-display text-headline-lg text-on-surface mt-3 mb-4 font-bold">
{t.reliability.title}
</h2>
<p className="text-on-surface-variant mb-6 text-base leading-relaxed">
{t.reliability.description}
</p>
<div className="grid grid-cols-2 gap-4">
{t.reliability.metrics.map(([value, label]) => (
<div
key={label}
className="bg-surface-container rounded-lg p-4"
>
<span className="text-headline-md text-primary font-mono font-bold">
{value}
</span>
<span className="text-on-surface-variant mt-1 block text-xs font-semibold">
{label}
</span>
</div>
))}
</div>
</div>
<RetentionGauge t={t} />
</Card>
</Section>
<CtaBanner
badge={t.cta.badge}
title={t.cta.title}
description={t.cta.description}
actions={
<>
<ButtonLink
href={consultationUrl}
icon="event_available"
>
{t.cta.primary}
</ButtonLink>
<ButtonLink
href={`${contact.url()}#lokasi`}
variant="secondary"
icon="apartment"
>
{t.cta.secondary}
</ButtonLink>
</>
}
/>
<Section tone="muted">
<Card
padding="lg"
className="grid grid-cols-1 items-center gap-8 lg:grid-cols-12 lg:p-10"
>
<div className="flex flex-col gap-4 lg:col-span-4">
<img
src={company.logo}
alt={company.name}
className="h-12 w-auto self-start object-contain"
/>
<h2 className="font-display text-on-surface text-lg font-bold">
{t.office.title}
</h2>
<p className="text-on-surface-variant text-sm">
{t.office.description}
</p>
<div className="text-on-surface mt-2 flex flex-col gap-2 font-mono text-sm">
{site.offices.map((office) => (
<a
key={office.name}
href={office.phoneHref}
className="hover:text-primary flex items-center gap-2"
>
<Icon
name="call"
className="text-primary text-base"
/>
{office.phone}
</a>
))}
<a
href={`mailto:${site.email.busdev}`}
className="hover:text-primary flex items-center gap-2"
>
<Icon
name="mail"
className="text-primary text-base"
/>
{site.email.busdev}
</a>
</div>
</div>
<div className="relative flex min-h-56 items-center overflow-hidden rounded-xl bg-neutral-900 p-4 lg:col-span-8">
<img
src="/images/probindo/clients-hero-bg.jpg"
alt=""
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="relative z-10 grid w-full grid-cols-1 gap-3 sm:grid-cols-2">
{site.offices.map((office) => (
<div
key={office.name}
className="bg-surface-container-lowest/90 rounded-xl p-4 shadow-lg backdrop-blur-md"
>
<span className="text-primary mb-1 block font-mono text-[11px] font-bold uppercase">
{office.name}
</span>
<p className="text-on-surface text-sm font-medium">
{officeAddress(office)}
</p>
</div>
))}
</div>
</div>
</Card>
</Section>
</>
);
}
const PREVIEW_COUNT = 8;
function SectorPortfolio({ t, stats }: { t: Content; stats: Portfolio }) {
const sectors: ProjectSector[] = ['government', 'private', 'soe'];
const [sector, setSector] = useState<ProjectSector>('government');
const [expanded, setExpanded] = useState(false);
const items = stats.projectsBySector(sector);
const visible = expanded ? items : items.slice(0, PREVIEW_COUNT);
const selectSector = (next: ProjectSector) => {
setSector(next);
setExpanded(false);
};
return (
<Section tone="muted">
<div className="flex flex-col gap-12">
<SectionHeader
align="split"
eyebrow={t.portfolio.eyebrow}
title={t.portfolio.title}
aside={
<div
role="group"
aria-label={t.portfolio.filterLabel}
className="border-outline-variant/40 bg-surface-container-low flex flex-wrap items-center gap-2 rounded-xl border p-1.5"
>
{sectors.map((option) => (
<button
key={option}
type="button"
aria-pressed={sector === option}
onClick={() => selectSector(option)}
className={cn(
'cursor-pointer rounded-lg px-4 py-2 text-xs font-semibold transition-all',
sector === option
? 'bg-primary-container text-white shadow-md'
: 'bg-surface-container text-on-surface-variant hover:text-on-surface',
)}
>
{t.portfolio.sectors[option]} (
{stats.projectsBySector(option).length})
</button>
))}
</div>
}
/>
<p className="text-on-surface-variant -mt-6 max-w-2xl text-base">
{t.portfolio.description}
</p>
<Card padding="none" className="overflow-hidden">
<ul className="divide-outline-variant/30 divide-y">
{visible.map((project) => (
<li
key={`${project.title}-${project.client}-${project.year}`}
className="flex flex-col gap-2 p-5 sm:flex-row sm:items-center sm:gap-6 sm:px-6"
>
<span className="bg-surface-container text-primary w-fit shrink-0 rounded-lg px-3 py-1 font-mono text-sm font-bold">
{project.year}
</span>
<div className="flex flex-1 flex-col gap-1">
<span className="text-on-surface font-semibold">
{project.title}
</span>
{project.scope && (
<span className="text-on-surface-variant text-sm">
{project.scope}
</span>
)}
<span className="text-on-surface-variant flex items-center gap-1.5 text-sm">
<Icon
name="apartment"
className="text-primary text-base"
/>
{project.client}
</span>
</div>
{project.program === 'erp' && (
<span className="border-primary/40 text-primary w-fit shrink-0 rounded-full border px-3 py-1 font-mono text-[11px] font-semibold">
{t.portfolio.erpBadge}
</span>
)}
</li>
))}
</ul>
</Card>
{items.length > PREVIEW_COUNT && (
<Button
variant="secondary"
size="sm"
icon={expanded ? 'expand_less' : 'expand_more'}
onClick={() => setExpanded((open) => !open)}
className="self-center"
>
{expanded
? t.portfolio.showLess
: t.portfolio.showAll.replace(
'{count}',
String(items.length),
)}
</Button>
)}
</div>
</Section>
);
}
const RELATED_PREVIEW = 4;
function FeaturedPrograms({ t, stats }: { t: Content; stats: Portfolio }) {
return (
<Section>
<div className="flex flex-col gap-12">
<SectionHeader
eyebrow={t.cases.eyebrow}
title={t.cases.title}
description={t.cases.description}
/>
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{t.cases.items.map((item, index) => {
const related = stats.projectsInProgram(item.program);
const remaining = related.length - RELATED_PREVIEW;
return (
<Card
key={item.program}
padding="none"
className="flex flex-col overflow-hidden"
>
<div className="bg-surface-container flex flex-col gap-3 p-6">
<div className="flex items-center justify-between">
<span className="text-primary font-mono text-xs font-bold">
{t.cases.label} 0{index + 1}
</span>
<Icon
name={item.icon}
className="text-on-surface-variant text-xl"
/>
</div>
<h3 className="font-display text-on-surface text-lg leading-snug font-bold">
{item.title}
</h3>
<span className="text-highlight text-xs font-semibold">
{item.client}
</span>
</div>
<div className="flex flex-1 flex-col gap-5 p-6">
<p className="text-on-surface-variant text-sm leading-relaxed">
{item.summary}
</p>
<div className="grid grid-cols-2 gap-3">
<div className="bg-surface-container-high rounded-lg p-3">
<span className="font-display text-primary text-2xl font-bold">
{related.length}
</span>
<span className="text-on-surface-variant block text-xs font-semibold">
{t.cases.projects}
</span>
</div>
<div className="bg-surface-container-high flex items-center rounded-lg p-3">
<span className="font-display text-on-surface text-lg font-bold">
{yearRange(related)}
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-primary font-mono text-[11px] font-bold uppercase">
{t.cases.related}
</span>
<ul className="flex flex-col gap-2">
{related
.slice(0, RELATED_PREVIEW)
.map((project) => (
<li
key={`${project.title}-${project.year}`}
className="text-on-surface-variant flex gap-2 text-sm"
>
<span className="text-on-surface shrink-0 font-mono font-semibold">
{project.year}
</span>
<span>
{project.title}
</span>
</li>
))}
</ul>
{remaining > 0 && (
<span className="text-on-surface-variant text-xs italic">
{t.cases.more.replace(
'{count}',
String(remaining),
)}
</span>
)}
</div>
</div>
</Card>
);
})}
</div>
</div>
</Section>
);
}
/** Shown only once published testimonials exist (admin → Testimoni). */
function Testimonials({ t }: { t: Content }) {
const { testimonials } = usePage<{
testimonials: LocalizedTestimonial[];
}>().props;
if (testimonials.length === 0) {
return null;
}
return (
<Section>
<div className="flex flex-col gap-12">
<SectionHeader
align="split"
eyebrow={t.testimonials.eyebrow}
title={t.testimonials.title}
description={t.testimonials.description}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{testimonials.map((testimonial) => (
<Card
key={testimonial.id}
className="flex flex-col justify-between gap-6"
>
<div className="space-y-4">
<div
className="text-primary flex"
aria-label={t.testimonials.rating}
>
{Array.from({ length: 5 }, (_, i) => (
<Icon key={i} name="star" filled />
))}
</div>
<blockquote className="text-on-surface text-base italic">
“{testimonial.quote}”
</blockquote>
</div>
<div className="flex items-center gap-3">
<div className="bg-surface-container text-primary flex h-10 w-10 items-center justify-center rounded-full font-mono font-bold">
{initials(testimonial.role)}
</div>
<div>
<div className="text-on-surface text-sm font-bold">
{testimonial.role}
</div>
<span className="text-on-surface-variant text-sm">
{testimonial.organization}
</span>
</div>
</div>
</Card>
))}
</div>
</div>
</Section>
);
}
const initials = (text: string) =>
text
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0].toUpperCase())
.join('');
function RetentionGauge({ t }: { t: Content }) {
return (
<div className="bg-surface-container flex w-full flex-col items-center justify-center rounded-xl p-6 lg:w-96">
<span className="text-on-surface-variant mb-4 font-mono text-[11px] font-semibold uppercase">
{t.reliability.gaugeLabel}
</span>
<div className="relative flex h-48 w-48 items-center justify-center">
<svg
aria-hidden="true"
className="absolute inset-0 -rotate-90"
viewBox="0 0 100 100"
>
<circle
className="stroke-surface-container-highest fill-none"
cx="50"
cy="50"
r="42"
strokeWidth="8"
/>
<circle
className="stroke-primary-container fill-none"
cx="50"
cy="50"
r="42"
strokeDasharray="264"
strokeDashoffset="1"
strokeLinecap="round"
strokeWidth="8"
/>
</svg>
<div className="flex flex-col items-center">
<span className="font-display text-headline-xl text-on-surface font-bold">
99.8%
</span>
<span className="text-highlight text-xs font-semibold">
{t.reliability.gaugeCaption}
</span>
</div>
</div>
<p className="text-on-surface-variant mt-4 text-center text-sm">
{t.reliability.gaugeNote}
</p>
</div>
);
}