559 lines
26 KiB
TypeScript
559 lines
26 KiB
TypeScript
import { Head } 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 { ProjectSector } from '@/data/projects';
|
|
import {
|
|
projectsBySector,
|
|
projectsInProgram,
|
|
yearRange,
|
|
} from '@/data/projects';
|
|
import { company, consultationUrl, officeAddress, offices } from '@/data/site';
|
|
import { useContent } from '@/hooks/use-locale';
|
|
import { cn } from '@/lib/utils';
|
|
import { contact } from '@/routes';
|
|
|
|
type Content = typeof clientsContent.id;
|
|
|
|
export default function Clients() {
|
|
const t = useContent(clientsContent);
|
|
|
|
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} />
|
|
|
|
<FeaturedPrograms t={t} />
|
|
|
|
<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">
|
|
{offices.map((office) => (
|
|
<a
|
|
key={office.name}
|
|
href={office.phone.href}
|
|
className="hover:text-primary flex items-center gap-2"
|
|
>
|
|
<Icon
|
|
name="call"
|
|
className="text-primary text-base"
|
|
/>
|
|
{office.phone.label}
|
|
</a>
|
|
))}
|
|
<a
|
|
href={`mailto:${company.email.busdev}`}
|
|
className="hover:text-primary flex items-center gap-2"
|
|
>
|
|
<Icon
|
|
name="mail"
|
|
className="text-primary text-base"
|
|
/>
|
|
{company.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">
|
|
{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 }: { t: Content }) {
|
|
const sectors: ProjectSector[] = ['government', 'private', 'soe'];
|
|
const [sector, setSector] = useState<ProjectSector>('government');
|
|
const [expanded, setExpanded] = useState(false);
|
|
const items = 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]} (
|
|
{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 }: { t: Content }) {
|
|
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 = 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>
|
|
);
|
|
}
|
|
|
|
/** Hidden until real client testimonials are added to the content file. */
|
|
function Testimonials({ t }: { t: Content }) {
|
|
if (t.testimonials.items.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">
|
|
{t.testimonials.items.map((testimonial) => (
|
|
<Card
|
|
key={testimonial.initials}
|
|
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">
|
|
{testimonial.initials}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|