@@ -14,13 +14,15 @@ import SectionHeader from '@/components/site/section-header';
|
||||
import CtaBanner from '@/components/site/sections/cta-banner';
|
||||
import StatCard from '@/components/site/stat-card';
|
||||
import { aboutContent } from '@/content/about';
|
||||
import { clientsInSector, projectsBySector } from '@/data/projects';
|
||||
import { fillCounts } from '@/data/projects';
|
||||
import { consultationUrl } from '@/data/site';
|
||||
import { useContent } from '@/hooks/use-locale';
|
||||
import { usePortfolio } from '@/hooks/use-portfolio';
|
||||
import { clients, services } from '@/routes';
|
||||
|
||||
export default function About() {
|
||||
const t = useContent(aboutContent);
|
||||
const stats = usePortfolio();
|
||||
const t = fillCounts(useContent(aboutContent), stats);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -178,7 +180,9 @@ export default function About() {
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||
{t.industries.items.map((item) => {
|
||||
const clientNames = clientsInSector(item.sector);
|
||||
const clientNames = stats.clientsInSector(
|
||||
item.sector,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -196,8 +200,9 @@ export default function About() {
|
||||
{clientNames.length}{' '}
|
||||
{t.industries.clients} ·{' '}
|
||||
{
|
||||
projectsBySector(item.sector)
|
||||
.length
|
||||
stats.projectsBySector(
|
||||
item.sector,
|
||||
).length
|
||||
}{' '}
|
||||
{t.industries.projects}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { Head, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { Button, ButtonLink } from '@/components/site/button';
|
||||
import Card from '@/components/site/card';
|
||||
@@ -13,21 +13,22 @@ 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 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 t = useContent(clientsContent);
|
||||
const stats = usePortfolio();
|
||||
const t = fillCounts(useContent(clientsContent), stats);
|
||||
const site = useSiteInfo();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -94,9 +95,9 @@ export default function Clients() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<SectorPortfolio t={t} />
|
||||
<SectorPortfolio t={t} stats={stats} />
|
||||
|
||||
<FeaturedPrograms t={t} />
|
||||
<FeaturedPrograms t={t} stats={stats} />
|
||||
|
||||
<Section tone="muted">
|
||||
<div className="flex flex-col gap-12">
|
||||
@@ -194,28 +195,28 @@ export default function Clients() {
|
||||
{t.office.description}
|
||||
</p>
|
||||
<div className="text-on-surface mt-2 flex flex-col gap-2 font-mono text-sm">
|
||||
{offices.map((office) => (
|
||||
{site.offices.map((office) => (
|
||||
<a
|
||||
key={office.name}
|
||||
href={office.phone.href}
|
||||
href={office.phoneHref}
|
||||
className="hover:text-primary flex items-center gap-2"
|
||||
>
|
||||
<Icon
|
||||
name="call"
|
||||
className="text-primary text-base"
|
||||
/>
|
||||
{office.phone.label}
|
||||
{office.phone}
|
||||
</a>
|
||||
))}
|
||||
<a
|
||||
href={`mailto:${company.email.busdev}`}
|
||||
href={`mailto:${site.email.busdev}`}
|
||||
className="hover:text-primary flex items-center gap-2"
|
||||
>
|
||||
<Icon
|
||||
name="mail"
|
||||
className="text-primary text-base"
|
||||
/>
|
||||
{company.email.busdev}
|
||||
{site.email.busdev}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,7 +228,7 @@ export default function Clients() {
|
||||
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) => (
|
||||
{site.offices.map((office) => (
|
||||
<div
|
||||
key={office.name}
|
||||
className="bg-surface-container-lowest/90 rounded-xl p-4 shadow-lg backdrop-blur-md"
|
||||
@@ -250,11 +251,11 @@ export default function Clients() {
|
||||
|
||||
const PREVIEW_COUNT = 8;
|
||||
|
||||
function SectorPortfolio({ t }: { t: Content }) {
|
||||
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 = projectsBySector(sector);
|
||||
const items = stats.projectsBySector(sector);
|
||||
const visible = expanded ? items : items.slice(0, PREVIEW_COUNT);
|
||||
|
||||
const selectSector = (next: ProjectSector) => {
|
||||
@@ -289,7 +290,7 @@ function SectorPortfolio({ t }: { t: Content }) {
|
||||
)}
|
||||
>
|
||||
{t.portfolio.sectors[option]} (
|
||||
{projectsBySector(option).length})
|
||||
{stats.projectsBySector(option).length})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -360,7 +361,7 @@ function SectorPortfolio({ t }: { t: Content }) {
|
||||
|
||||
const RELATED_PREVIEW = 4;
|
||||
|
||||
function FeaturedPrograms({ t }: { t: Content }) {
|
||||
function FeaturedPrograms({ t, stats }: { t: Content; stats: Portfolio }) {
|
||||
return (
|
||||
<Section>
|
||||
<div className="flex flex-col gap-12">
|
||||
@@ -371,7 +372,7 @@ function FeaturedPrograms({ t }: { t: Content }) {
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
{t.cases.items.map((item, index) => {
|
||||
const related = projectsInProgram(item.program);
|
||||
const related = stats.projectsInProgram(item.program);
|
||||
const remaining = related.length - RELATED_PREVIEW;
|
||||
|
||||
return (
|
||||
@@ -456,9 +457,13 @@ function FeaturedPrograms({ t }: { t: Content }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Hidden until real client testimonials are added to the content file. */
|
||||
/** Shown only once published testimonials exist (admin → Testimoni). */
|
||||
function Testimonials({ t }: { t: Content }) {
|
||||
if (t.testimonials.items.length === 0) {
|
||||
const { testimonials } = usePage<{
|
||||
testimonials: LocalizedTestimonial[];
|
||||
}>().props;
|
||||
|
||||
if (testimonials.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -472,9 +477,9 @@ function Testimonials({ t }: { t: Content }) {
|
||||
description={t.testimonials.description}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{t.testimonials.items.map((testimonial) => (
|
||||
{testimonials.map((testimonial) => (
|
||||
<Card
|
||||
key={testimonial.initials}
|
||||
key={testimonial.id}
|
||||
className="flex flex-col justify-between gap-6"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -492,7 +497,7 @@ function Testimonials({ t }: { t: Content }) {
|
||||
</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}
|
||||
{initials(testimonial.role)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-on-surface text-sm font-bold">
|
||||
@@ -511,6 +516,14 @@ function Testimonials({ t }: { t: Content }) {
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
|
||||
+237
-182
@@ -1,11 +1,11 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Button, ButtonLink } from '@/components/site/button';
|
||||
import Card from '@/components/site/card';
|
||||
import Eyebrow from '@/components/site/eyebrow';
|
||||
import {
|
||||
Field,
|
||||
Honeypot,
|
||||
SelectInput,
|
||||
TextArea,
|
||||
TextInput,
|
||||
@@ -19,13 +19,16 @@ import SectionHeader from '@/components/site/section-header';
|
||||
import StepCard from '@/components/site/step-card';
|
||||
import { common, serviceOptions } from '@/content/common';
|
||||
import { contactContent } from '@/content/contact';
|
||||
import { company, officeAddress, offices } from '@/data/site';
|
||||
import { officeAddress } from '@/data/site';
|
||||
import { useContent } from '@/hooks/use-locale';
|
||||
import { useSiteInfo } from '@/hooks/use-site-info';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { store } from '@/routes/consultations';
|
||||
|
||||
type Content = typeof contactContent.id;
|
||||
|
||||
export default function Contact() {
|
||||
const site = useSiteInfo();
|
||||
const t = useContent(contactContent);
|
||||
|
||||
return (
|
||||
@@ -186,19 +189,19 @@ export default function Contact() {
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 pt-2">
|
||||
<ButtonLink
|
||||
href={company.whatsapp.href}
|
||||
href={site.whatsapp.href}
|
||||
size="sm"
|
||||
icon="chat"
|
||||
>
|
||||
{t.faq.whatsapp} ({company.whatsapp.label})
|
||||
{t.faq.whatsapp} ({site.whatsapp.label})
|
||||
</ButtonLink>
|
||||
<ButtonLink
|
||||
href={company.phone.href}
|
||||
href={site.phone.href}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="call"
|
||||
>
|
||||
{t.faq.phone} ({company.phone.label})
|
||||
{t.faq.phone} ({site.phone.label})
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,6 +220,7 @@ function CardLabel({ children }: { children: string }) {
|
||||
}
|
||||
|
||||
function OfficeCard({ t }: { t: Content }) {
|
||||
const site = useSiteInfo();
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<div className="pb-4">
|
||||
@@ -226,7 +230,7 @@ function OfficeCard({ t }: { t: Content }) {
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{offices.map((office) => (
|
||||
{site.offices.map((office) => (
|
||||
<div
|
||||
key={office.name}
|
||||
className="bg-surface-container flex items-start gap-3 rounded-lg p-3.5"
|
||||
@@ -240,10 +244,10 @@ function OfficeCard({ t }: { t: Content }) {
|
||||
{officeAddress(office)}
|
||||
</span>
|
||||
<a
|
||||
href={office.phone.href}
|
||||
href={office.phoneHref}
|
||||
className="text-on-surface hover:text-primary font-mono font-semibold"
|
||||
>
|
||||
{t.offices.tel} {office.phone.label}
|
||||
{t.offices.tel} {office.phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,31 +279,32 @@ function OfficeCard({ t }: { t: Content }) {
|
||||
}
|
||||
|
||||
function ChannelsCard({ t }: { t: Content }) {
|
||||
const site = useSiteInfo();
|
||||
const channels = [
|
||||
{
|
||||
icon: 'call',
|
||||
label: t.channels.phone,
|
||||
value: company.phone.label,
|
||||
href: company.phone.href,
|
||||
value: site.phone.label,
|
||||
href: site.phone.href,
|
||||
},
|
||||
{
|
||||
icon: 'chat',
|
||||
label: t.channels.whatsapp,
|
||||
value: company.whatsapp.label,
|
||||
href: `${company.whatsapp.href}?text=${encodeURIComponent(t.channels.whatsappMessage)}`,
|
||||
value: site.whatsapp.label,
|
||||
href: `${site.whatsapp.href}?text=${encodeURIComponent(t.channels.whatsappMessage)}`,
|
||||
badge: t.channels.whatsappBadge,
|
||||
},
|
||||
{
|
||||
icon: 'mail',
|
||||
label: t.channels.email,
|
||||
value: company.email.busdev,
|
||||
href: `mailto:${company.email.busdev}`,
|
||||
value: site.email.busdev,
|
||||
href: `mailto:${site.email.busdev}`,
|
||||
},
|
||||
{
|
||||
icon: 'headset_mic',
|
||||
label: t.channels.support,
|
||||
value: company.email.support,
|
||||
href: `mailto:${company.email.support}`,
|
||||
value: site.email.support,
|
||||
href: `mailto:${site.email.support}`,
|
||||
badge: t.channels.supportBadge,
|
||||
},
|
||||
];
|
||||
@@ -398,12 +403,6 @@ function ConsultationRequestForm({ t }: { t: Content }) {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const form = t.form;
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSubmitted(true);
|
||||
event.currentTarget.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="lg" className="shadow-xl lg:p-10">
|
||||
<div className="border-outline-variant/30 border-b pb-6">
|
||||
@@ -416,162 +415,216 @@ function ConsultationRequestForm({ t }: { t: Content }) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-6 pt-6" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field id="full-name" label={form.name} required>
|
||||
<TextInput
|
||||
id="full-name"
|
||||
name="name"
|
||||
placeholder={form.namePlaceholder}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field id="company-name" label={form.company} required>
|
||||
<TextInput
|
||||
id="company-name"
|
||||
name="company"
|
||||
placeholder={form.companyPlaceholder}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field id="job-title" label={form.jobTitle} required>
|
||||
<TextInput
|
||||
id="job-title"
|
||||
name="job_title"
|
||||
placeholder={form.jobTitlePlaceholder}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field id="work-email" label={form.email} required>
|
||||
<TextInput
|
||||
id="work-email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={form.emailPlaceholder}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field id="phone-number" label={form.phone} required>
|
||||
<TextInput
|
||||
id="phone-number"
|
||||
name="phone"
|
||||
type="tel"
|
||||
placeholder="+62 812 XXXX XXXX"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field id="industry-sector" label={form.industry} required>
|
||||
<SelectInput
|
||||
id="industry-sector"
|
||||
name="industry"
|
||||
placeholder={form.industryPlaceholder}
|
||||
options={form.industries}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field id="service-focus" label={form.service} required>
|
||||
<SelectInput
|
||||
id="service-focus"
|
||||
name="service"
|
||||
placeholder={form.servicePlaceholder}
|
||||
options={serviceOptions(shared)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-on-surface pb-2 font-mono text-xs font-medium">
|
||||
{form.timeline}
|
||||
</legend>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{form.timelines.map((timeline, index) => (
|
||||
<label
|
||||
key={timeline.value}
|
||||
className={cn(
|
||||
'border-outline-variant/60 bg-surface-container-lowest text-on-surface hover:bg-surface-container-high flex cursor-pointer flex-col rounded-lg border p-3 transition-colors',
|
||||
'has-checked:border-primary-container has-checked:bg-primary-container has-checked:text-white',
|
||||
'has-focus-visible:ring-primary has-focus-visible:ring-2',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="timeline"
|
||||
value={timeline.value}
|
||||
defaultChecked={index === 0}
|
||||
className="sr-only"
|
||||
<Form
|
||||
{...store.form()}
|
||||
options={{ preserveScroll: true }}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setSubmitted(true)}
|
||||
className="relative flex flex-col gap-6 pt-6"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<input type="hidden" name="source" value="contact" />
|
||||
<Honeypot />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field id="full-name" label={form.name} required>
|
||||
<TextInput
|
||||
id="full-name"
|
||||
name="name"
|
||||
placeholder={form.namePlaceholder}
|
||||
required
|
||||
/>
|
||||
<span className="text-xs font-bold">
|
||||
{timeline.label}
|
||||
</span>
|
||||
<span className="text-sm opacity-80">
|
||||
{timeline.note}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<Field id="project-details" label={form.details}>
|
||||
<TextArea
|
||||
id="project-details"
|
||||
name="details"
|
||||
rows={4}
|
||||
placeholder={form.detailsPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="bg-surface-container-lowest flex items-start gap-3 rounded-lg p-3.5">
|
||||
<input
|
||||
id="nda-consent"
|
||||
name="nda"
|
||||
type="checkbox"
|
||||
required
|
||||
className="mt-0.5 h-4 w-4 shrink-0"
|
||||
/>
|
||||
<label
|
||||
htmlFor="nda-consent"
|
||||
className="text-on-surface-variant cursor-pointer text-sm"
|
||||
>
|
||||
<RichText text={form.nda} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="block" trailingIcon="arrow_forward">
|
||||
{form.submit}
|
||||
</Button>
|
||||
|
||||
{submitted && (
|
||||
<div
|
||||
role="status"
|
||||
className="border-primary/40 bg-surface-container-high text-on-surface flex items-center gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<Icon
|
||||
name="check_circle"
|
||||
className="text-primary text-2xl"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-bold">
|
||||
{form.successTitle}
|
||||
</span>
|
||||
<p className="text-on-surface-variant text-sm">
|
||||
{form.successMessage}
|
||||
</p>
|
||||
</Field>
|
||||
<Field
|
||||
id="company-name"
|
||||
label={form.company}
|
||||
required
|
||||
>
|
||||
<TextInput
|
||||
id="company-name"
|
||||
name="company"
|
||||
placeholder={form.companyPlaceholder}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
id="job-title"
|
||||
label={form.jobTitle}
|
||||
required
|
||||
>
|
||||
<TextInput
|
||||
id="job-title"
|
||||
name="job_title"
|
||||
placeholder={form.jobTitlePlaceholder}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field id="work-email" label={form.email} required>
|
||||
<TextInput
|
||||
id="work-email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={form.emailPlaceholder}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
id="phone-number"
|
||||
label={form.phone}
|
||||
required
|
||||
>
|
||||
<TextInput
|
||||
id="phone-number"
|
||||
name="phone"
|
||||
type="tel"
|
||||
placeholder="+62 812 XXXX XXXX"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
id="industry-sector"
|
||||
label={form.industry}
|
||||
required
|
||||
>
|
||||
<SelectInput
|
||||
id="industry-sector"
|
||||
name="industry"
|
||||
placeholder={form.industryPlaceholder}
|
||||
options={form.industries.map(
|
||||
({ label }) => ({
|
||||
value: label,
|
||||
label,
|
||||
}),
|
||||
)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-on-surface-variant flex items-center justify-center gap-2 pt-2 text-center font-mono text-[11px]">
|
||||
<Icon name="lock" className="text-primary text-sm" />
|
||||
{form.privacy}
|
||||
</div>
|
||||
</form>
|
||||
<Field id="service-focus" label={form.service} required>
|
||||
<SelectInput
|
||||
id="service-focus"
|
||||
name="service"
|
||||
placeholder={form.servicePlaceholder}
|
||||
options={serviceOptions(shared)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-on-surface pb-2 font-mono text-xs font-medium">
|
||||
{form.timeline}
|
||||
</legend>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{form.timelines.map((timeline, index) => (
|
||||
<label
|
||||
key={timeline.value}
|
||||
className={cn(
|
||||
'border-outline-variant/60 bg-surface-container-lowest text-on-surface hover:bg-surface-container-high flex cursor-pointer flex-col rounded-lg border p-3 transition-colors',
|
||||
'has-checked:border-primary-container has-checked:bg-primary-container has-checked:text-white',
|
||||
'has-focus-visible:ring-primary has-focus-visible:ring-2',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="timeline"
|
||||
value={timeline.value}
|
||||
defaultChecked={index === 0}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-xs font-bold">
|
||||
{timeline.label}
|
||||
</span>
|
||||
<span className="text-sm opacity-80">
|
||||
{timeline.note}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<Field id="project-details" label={form.details}>
|
||||
<TextArea
|
||||
id="project-details"
|
||||
name="details"
|
||||
rows={4}
|
||||
placeholder={form.detailsPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="bg-surface-container-lowest flex items-start gap-3 rounded-lg p-3.5">
|
||||
<input
|
||||
id="nda-consent"
|
||||
name="nda_agreed"
|
||||
value="1"
|
||||
type="checkbox"
|
||||
required
|
||||
className="mt-0.5 h-4 w-4 shrink-0"
|
||||
/>
|
||||
<label
|
||||
htmlFor="nda-consent"
|
||||
className="text-on-surface-variant cursor-pointer text-sm"
|
||||
>
|
||||
<RichText text={form.nda} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<ul
|
||||
role="alert"
|
||||
className="border-primary/40 text-primary flex flex-col gap-1 rounded-lg border p-3 text-sm"
|
||||
>
|
||||
{Object.values(errors).map((message) => (
|
||||
<li key={message}>{message}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="block"
|
||||
trailingIcon="arrow_forward"
|
||||
disabled={processing}
|
||||
>
|
||||
{form.submit}
|
||||
</Button>
|
||||
|
||||
{submitted && (
|
||||
<div
|
||||
role="status"
|
||||
className="border-primary/40 bg-surface-container-high text-on-surface flex items-center gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<Icon
|
||||
name="check_circle"
|
||||
className="text-primary text-2xl"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-bold">
|
||||
{form.successTitle}
|
||||
</span>
|
||||
<p className="text-on-surface-variant text-sm">
|
||||
{form.successMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-on-surface-variant flex items-center justify-center gap-2 pt-2 text-center font-mono text-[11px]">
|
||||
<Icon
|
||||
name="lock"
|
||||
className="text-primary text-sm"
|
||||
/>
|
||||
{form.privacy}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function OfficeMap({ t }: { t: Content }) {
|
||||
const site = useSiteInfo();
|
||||
return (
|
||||
<div className="bg-surface-container relative flex min-h-96 w-full flex-col justify-end overflow-hidden rounded-2xl p-4 shadow-xl sm:p-6 lg:min-h-[480px]">
|
||||
<img
|
||||
@@ -582,7 +635,7 @@ function OfficeMap({ t }: { t: Content }) {
|
||||
/>
|
||||
<div className="from-surface via-surface/40 absolute inset-0 bg-gradient-to-t to-transparent" />
|
||||
<div className="relative mt-40 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{offices.map((office) => (
|
||||
{site.offices.map((office) => (
|
||||
<div
|
||||
key={office.name}
|
||||
className="border-outline-variant/40 bg-surface-container/95 flex flex-col gap-3 rounded-xl border p-5 shadow-2xl backdrop-blur-md"
|
||||
@@ -601,15 +654,17 @@ function OfficeMap({ t }: { t: Content }) {
|
||||
<p className="text-on-surface-variant text-sm leading-relaxed">
|
||||
{officeAddress(office)}
|
||||
</p>
|
||||
<ButtonLink
|
||||
href={office.mapsUrl}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="directions"
|
||||
className="self-start"
|
||||
>
|
||||
{t.location.directions}
|
||||
</ButtonLink>
|
||||
{office.maps_url && (
|
||||
<ButtonLink
|
||||
href={office.maps_url}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="directions"
|
||||
className="self-start"
|
||||
>
|
||||
{t.location.directions}
|
||||
</ButtonLink>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -14,14 +14,16 @@ import ConsultationSection from '@/components/site/sections/consultation-section
|
||||
import ServiceGrid from '@/components/site/sections/service-grid';
|
||||
import StatCard from '@/components/site/stat-card';
|
||||
import { homeContent } from '@/content/home';
|
||||
import { topClients } from '@/data/projects';
|
||||
import { fillCounts } from '@/data/projects';
|
||||
import { useContent } from '@/hooks/use-locale';
|
||||
import { usePortfolio } from '@/hooks/use-portfolio';
|
||||
import { about, clients, services } from '@/routes';
|
||||
|
||||
type Content = typeof homeContent.id;
|
||||
|
||||
export default function Home() {
|
||||
const t = useContent(homeContent);
|
||||
const stats = usePortfolio();
|
||||
const t = fillCounts(useContent(homeContent), stats);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -48,7 +50,7 @@ export default function Home() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{topClients(10).map((client) => (
|
||||
{stats.topClients(10).map((client) => (
|
||||
<div
|
||||
key={client}
|
||||
className="border-outline-variant/40 bg-surface-container text-on-surface hover:border-primary/50 hover:bg-surface-container-high flex min-h-16 items-center justify-center rounded-xl border px-4 py-3 text-center font-mono text-xs font-medium transition-all"
|
||||
@@ -140,6 +142,7 @@ export default function Home() {
|
||||
|
||||
<ConsultationSection
|
||||
id="konsultasi"
|
||||
source="home"
|
||||
title={t.consultation.title}
|
||||
description={t.consultation.description}
|
||||
/>
|
||||
|
||||
@@ -185,6 +185,7 @@ export default function Services() {
|
||||
|
||||
<ConsultationSection
|
||||
id="konsultasi"
|
||||
source="services"
|
||||
title={t.consultation.title}
|
||||
description={t.consultation.description}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user