@@ -0,0 +1,267 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { ExternalLink, MapPin, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
ConfirmDelete,
|
||||
FormDialog,
|
||||
FormField,
|
||||
PageHeader,
|
||||
} from '@/components/admin/form-controls';
|
||||
import Heading from '@/components/heading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { officeAddress } from '@/data/site';
|
||||
import { edit } from '@/routes/admin/company';
|
||||
import { update as updateContact } from '@/routes/admin/company/contact';
|
||||
import { destroy, store, update } from '@/routes/admin/offices';
|
||||
import type { Office, SiteInfo } from '@/types';
|
||||
|
||||
export default function CompanyEdit({ contact, offices }: SiteInfo) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Info Perusahaan" />
|
||||
<div className="flex flex-col gap-8 p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Info Perusahaan"
|
||||
description="Kontak dan alamat kantor yang tampil di header, footer, dan halaman Kontak."
|
||||
/>
|
||||
|
||||
<section className="bg-card flex flex-col gap-6 rounded-xl border p-5">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Kontak"
|
||||
description="Email busdev juga menjadi tujuan notifikasi konsultasi baru."
|
||||
/>
|
||||
<Form
|
||||
{...updateContact.form()}
|
||||
options={{ preserveScroll: true }}
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<FormField
|
||||
id="phone"
|
||||
label="Telepon kantor"
|
||||
error={errors.phone}
|
||||
>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
defaultValue={contact.phone}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="whatsapp"
|
||||
label="WhatsApp konsultasi"
|
||||
error={errors.whatsapp}
|
||||
>
|
||||
<Input
|
||||
id="whatsapp"
|
||||
name="whatsapp"
|
||||
defaultValue={contact.whatsapp}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="email_busdev"
|
||||
label="Email business development"
|
||||
error={errors.email_busdev}
|
||||
>
|
||||
<Input
|
||||
id="email_busdev"
|
||||
name="email_busdev"
|
||||
type="email"
|
||||
defaultValue={contact.email_busdev}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="email_support"
|
||||
label="Email support"
|
||||
error={errors.email_support}
|
||||
>
|
||||
<Input
|
||||
id="email_support"
|
||||
name="email_support"
|
||||
type="email"
|
||||
defaultValue={contact.email_support}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end sm:col-span-2">
|
||||
<Button type="submit" disabled={processing}>
|
||||
Simpan Kontak
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Kantor"
|
||||
description="Diurutkan berdasarkan nomor urut."
|
||||
/>
|
||||
<OfficeDialog
|
||||
nextOrder={offices.length + 1}
|
||||
trigger={
|
||||
<Button>
|
||||
<Plus /> Tambah Kantor
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{offices.map((office) => (
|
||||
<div
|
||||
key={office.id}
|
||||
className="bg-card flex flex-col gap-3 rounded-xl border p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-display font-bold">
|
||||
{office.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{office.district} · {office.phone}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<OfficeDialog
|
||||
office={office}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Ubah"
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ConfirmDelete
|
||||
title="Hapus kantor ini?"
|
||||
description={`${office.name} tidak akan tampil lagi di website.`}
|
||||
form={destroy.form(office.id)}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Hapus"
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex gap-2 text-sm">
|
||||
<MapPin className="text-primary mt-0.5 size-4 shrink-0" />
|
||||
{officeAddress(office)}
|
||||
</p>
|
||||
{office.maps_url && (
|
||||
<a
|
||||
href={office.maps_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary flex items-center gap-1 text-xs font-semibold hover:underline"
|
||||
>
|
||||
Buka di Google Maps
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OfficeDialog({
|
||||
office,
|
||||
nextOrder = 1,
|
||||
trigger,
|
||||
}: {
|
||||
office?: Office;
|
||||
nextOrder?: number;
|
||||
trigger: ReactNode;
|
||||
}) {
|
||||
const id = (field: string) => `office-${office?.id ?? 'new'}-${field}`;
|
||||
const text = (
|
||||
field: keyof Office,
|
||||
label: string,
|
||||
errors: Partial<Record<string, string>>,
|
||||
extra: { placeholder?: string; required?: boolean } = {
|
||||
required: true,
|
||||
},
|
||||
) => (
|
||||
<FormField id={id(field)} label={label} error={errors[field]}>
|
||||
<Input
|
||||
id={id(field)}
|
||||
name={field}
|
||||
defaultValue={office?.[field] ?? ''}
|
||||
{...extra}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
trigger={trigger}
|
||||
title={office ? 'Ubah Kantor' : 'Tambah Kantor'}
|
||||
form={office ? update.form(office.id) : store.form()}
|
||||
>
|
||||
{(errors) => (
|
||||
<>
|
||||
{text('name', 'Nama kantor', errors)}
|
||||
{text('building', 'Gedung / nomor', errors)}
|
||||
{text('street', 'Jalan', errors)}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{text('area', 'Kelurahan / kecamatan', errors)}
|
||||
{text('city', 'Kota & kode pos', errors)}
|
||||
{text('district', 'Label wilayah', errors, {
|
||||
placeholder: 'Mis. Bintaro',
|
||||
required: true,
|
||||
})}
|
||||
{text('phone', 'Telepon', errors)}
|
||||
</div>
|
||||
{text('maps_url', 'Link Google Maps (opsional)', errors, {
|
||||
placeholder: 'https://maps.app.goo.gl/…',
|
||||
})}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{text('icon', 'Ikon (Material Symbols)', errors, {
|
||||
placeholder: 'apartment',
|
||||
required: true,
|
||||
})}
|
||||
<FormField
|
||||
id={id('sort_order')}
|
||||
label="Nomor urut"
|
||||
error={errors.sort_order}
|
||||
>
|
||||
<Input
|
||||
id={id('sort_order')}
|
||||
name="sort_order"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={office?.sort_order ?? nextOrder}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
CompanyEdit.layout = {
|
||||
breadcrumbs: [{ title: 'Info Perusahaan', href: edit() }],
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
import { Form, Head, Link } from '@inertiajs/react';
|
||||
import { Mail, MessageCircle, Phone, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ConfirmDelete,
|
||||
FormField,
|
||||
NativeSelect,
|
||||
PageHeader,
|
||||
Textarea,
|
||||
} from '@/components/admin/form-controls';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { telHref, whatsappHref } from '@/data/site';
|
||||
import {
|
||||
formatDate,
|
||||
optionsOf,
|
||||
sourceLabels,
|
||||
statusBadgeClasses,
|
||||
statusLabels,
|
||||
} from '@/lib/admin-labels';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { destroy, index, update } from '@/routes/admin/consultations';
|
||||
import type { Consultation, ConsultationStatus, Paginated } from '@/types';
|
||||
|
||||
type Props = {
|
||||
consultations: Paginated<Consultation>;
|
||||
status: ConsultationStatus | null;
|
||||
counts: Partial<Record<ConsultationStatus, number>>;
|
||||
};
|
||||
|
||||
const timelineLabels: Record<string, string> = {
|
||||
immediate: 'Segera (< 1 bulan)',
|
||||
quarter: '1–3 bulan',
|
||||
midterm: '3–6 bulan',
|
||||
feasibility: 'Masih studi kelayakan',
|
||||
};
|
||||
|
||||
export default function ConsultationsIndex({
|
||||
consultations,
|
||||
status,
|
||||
counts,
|
||||
}: Props) {
|
||||
const [selectedId, setSelectedId] = useState<number | null>(
|
||||
consultations.data[0]?.id ?? null,
|
||||
);
|
||||
const selected =
|
||||
consultations.data.find((item) => item.id === selectedId) ??
|
||||
consultations.data[0];
|
||||
const total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
|
||||
|
||||
const filters: {
|
||||
value: ConsultationStatus | null;
|
||||
label: string;
|
||||
count: number;
|
||||
}[] = [
|
||||
{ value: null, label: 'Semua', count: total },
|
||||
...optionsOf(statusLabels).map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
count: counts[value] ?? 0,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Inbox Konsultasi" />
|
||||
<div className="flex flex-col gap-6 p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Inbox Konsultasi"
|
||||
description="Permintaan dari form konsultasi di website. Notifikasi juga dikirim ke email busdev."
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filters.map((filter) => (
|
||||
<Link
|
||||
key={filter.label}
|
||||
href={index({
|
||||
query: filter.value
|
||||
? { status: filter.value }
|
||||
: {},
|
||||
})}
|
||||
preserveScroll
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
status === filter.value
|
||||
? 'bg-primary text-primary-foreground border-transparent'
|
||||
: 'hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
{filter.label}
|
||||
<span className="font-mono text-xs opacity-75">
|
||||
{filter.count}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{consultations.data.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-xl border border-dashed p-12 text-center text-sm">
|
||||
Belum ada permintaan konsultasi di sini.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
<div className="bg-card flex flex-col rounded-xl border lg:col-span-2">
|
||||
<ul className="divide-border divide-y">
|
||||
{consultations.data.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedId(item.id)
|
||||
}
|
||||
className={cn(
|
||||
'flex w-full flex-col gap-1 px-4 py-3 text-left transition-colors',
|
||||
selected?.id === item.id
|
||||
? 'bg-accent'
|
||||
: 'hover:bg-accent/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{item.name}
|
||||
</span>
|
||||
<Badge
|
||||
className={
|
||||
statusBadgeClasses[
|
||||
item.status
|
||||
]
|
||||
}
|
||||
>
|
||||
{statusLabels[item.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{item.company ?? item.email}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatDate(
|
||||
item.created_at,
|
||||
true,
|
||||
)}
|
||||
{' · '}
|
||||
{sourceLabels[item.source]}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{consultations.last_page > 1 && (
|
||||
<div className="mt-auto flex items-center justify-between border-t px-4 py-3 text-sm">
|
||||
<PageLink
|
||||
href={consultations.prev_page_url}
|
||||
label="Sebelumnya"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Halaman {consultations.current_page} /{' '}
|
||||
{consultations.last_page}
|
||||
</span>
|
||||
<PageLink
|
||||
href={consultations.next_page_url}
|
||||
label="Berikutnya"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<ConsultationDetail
|
||||
key={selected.id}
|
||||
consultation={selected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PageLink({ href, label }: { href: string | null; label: string }) {
|
||||
if (!href) {
|
||||
return <span className="text-muted-foreground/50">{label}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} preserveScroll className="text-primary font-medium">
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsultationDetail({ consultation }: { consultation: Consultation }) {
|
||||
const details: [string, string | null][] = [
|
||||
['Perusahaan', consultation.company],
|
||||
['Jabatan', consultation.job_title],
|
||||
['Industri', consultation.industry],
|
||||
['Layanan', consultation.service],
|
||||
[
|
||||
'Target waktu',
|
||||
consultation.timeline
|
||||
? (timelineLabels[consultation.timeline] ??
|
||||
consultation.timeline)
|
||||
: null,
|
||||
],
|
||||
['Halaman asal', sourceLabels[consultation.source]],
|
||||
['Bahasa', consultation.locale === 'en' ? 'English' : 'Indonesia'],
|
||||
['Setuju NDA', consultation.nda_agreed ? 'Ya' : 'Tidak'],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-card flex flex-col gap-6 rounded-xl border p-5 lg:col-span-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Masuk {formatDate(consultation.created_at, true)}
|
||||
</span>
|
||||
<ConfirmDelete
|
||||
title="Hapus permintaan ini?"
|
||||
description={`Data konsultasi dari ${consultation.name} akan dihapus permanen.`}
|
||||
form={destroy.form(consultation.id)}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 /> Hapus
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="font-display text-lg font-bold">
|
||||
{consultation.name}
|
||||
</h2>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={`mailto:${consultation.email}`}>
|
||||
<Mail /> {consultation.email}
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={telHref(consultation.phone)}>
|
||||
<Phone /> {consultation.phone}
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a
|
||||
href={whatsappHref(consultation.phone)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<MessageCircle /> WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||
{details
|
||||
.filter(([, value]) => value)
|
||||
.map(([label, value]) => (
|
||||
<div key={label} className="flex flex-col">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="text-sm font-medium">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{consultation.details && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Kebutuhan
|
||||
</span>
|
||||
<p className="bg-muted/50 rounded-lg p-3 text-sm whitespace-pre-line">
|
||||
{consultation.details}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form
|
||||
{...update.form(consultation.id)}
|
||||
options={{ preserveScroll: true }}
|
||||
className="flex flex-col gap-4 border-t pt-5"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<FormField
|
||||
id="status"
|
||||
label="Status tindak lanjut"
|
||||
error={errors.status}
|
||||
>
|
||||
<NativeSelect
|
||||
id="status"
|
||||
name="status"
|
||||
defaultValue={consultation.status}
|
||||
options={optionsOf(statusLabels)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="admin_notes"
|
||||
label="Catatan internal"
|
||||
error={errors.admin_notes}
|
||||
>
|
||||
<Textarea
|
||||
id="admin_notes"
|
||||
name="admin_notes"
|
||||
defaultValue={consultation.admin_notes ?? ''}
|
||||
placeholder="Mis. sudah ditelepon, jadwal meeting 3 Okt."
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={processing}>
|
||||
Simpan
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ConsultationsIndex.layout = {
|
||||
breadcrumbs: [{ title: 'Inbox Konsultasi', href: index() }],
|
||||
};
|
||||
@@ -0,0 +1,290 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { Pencil, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
ConfirmDelete,
|
||||
FormDialog,
|
||||
FormField,
|
||||
NativeSelect,
|
||||
PageHeader,
|
||||
Textarea,
|
||||
} from '@/components/admin/form-controls';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { portfolio } from '@/data/projects';
|
||||
import { optionsOf, programLabels, sectorLabels } from '@/lib/admin-labels';
|
||||
import { destroy, index, store, update } from '@/routes/admin/projects';
|
||||
import type { Project, ProjectSector } from '@/types';
|
||||
|
||||
export default function ProjectsIndex({ projects }: { projects: Project[] }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [sector, setSector] = useState<ProjectSector | ''>('');
|
||||
const stats = portfolio(projects);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
|
||||
return projects.filter(
|
||||
(project) =>
|
||||
(!sector || project.sector === sector) &&
|
||||
(!needle ||
|
||||
`${project.title} ${project.client} ${project.year}`
|
||||
.toLowerCase()
|
||||
.includes(needle)),
|
||||
);
|
||||
}, [projects, query, sector]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Portofolio" />
|
||||
<div className="flex flex-col gap-6 p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Portofolio Proyek"
|
||||
description={`${stats.projectCount} proyek dari ${stats.clientCount} klien. Angka di website dihitung otomatis dari daftar ini.`}
|
||||
action={
|
||||
<ProjectDialog
|
||||
trigger={
|
||||
<Button>
|
||||
<Plus /> Tambah Proyek
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Cari judul, klien, atau tahun…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<NativeSelect
|
||||
value={sector}
|
||||
onChange={(event) =>
|
||||
setSector(event.target.value as ProjectSector | '')
|
||||
}
|
||||
options={[
|
||||
{ value: '', label: 'Semua sektor' },
|
||||
...optionsOf(sectorLabels),
|
||||
]}
|
||||
className="sm:w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-card overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-muted-foreground border-b text-left text-xs">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">Tahun</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
Proyek
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
Sektor
|
||||
</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{visible.map((project) => (
|
||||
<tr key={project.id} className="align-top">
|
||||
<td className="px-4 py-3 font-mono text-xs font-bold">
|
||||
{project.year}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-semibold">
|
||||
{project.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{project.client}
|
||||
</span>
|
||||
{project.scope && (
|
||||
<span className="text-muted-foreground text-xs italic">
|
||||
{project.scope}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline">
|
||||
{sectorLabels[project.sector]}
|
||||
</Badge>
|
||||
{project.program && (
|
||||
<Badge variant="secondary">
|
||||
{
|
||||
programLabels[
|
||||
project.program
|
||||
]
|
||||
}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-1">
|
||||
<ProjectDialog
|
||||
project={project}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Ubah"
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ConfirmDelete
|
||||
title="Hapus proyek ini?"
|
||||
description={`"${project.title}" akan dihapus dari portofolio website.`}
|
||||
form={destroy.form(project.id)}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Hapus"
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{visible.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="text-muted-foreground px-4 py-12 text-center"
|
||||
>
|
||||
Tidak ada proyek yang cocok.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectDialog({
|
||||
project,
|
||||
trigger,
|
||||
}: {
|
||||
project?: Project;
|
||||
trigger: ReactNode;
|
||||
}) {
|
||||
const id = (field: string) => `project-${project?.id ?? 'new'}-${field}`;
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
trigger={trigger}
|
||||
title={project ? 'Ubah Proyek' : 'Tambah Proyek'}
|
||||
form={project ? update.form(project.id) : store.form()}
|
||||
>
|
||||
{(errors) => (
|
||||
<>
|
||||
<FormField
|
||||
id={id('title')}
|
||||
label="Judul proyek"
|
||||
error={errors.title}
|
||||
>
|
||||
<Textarea
|
||||
id={id('title')}
|
||||
name="title"
|
||||
rows={2}
|
||||
defaultValue={project?.title}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id={id('client')}
|
||||
label="Klien"
|
||||
error={errors.client}
|
||||
>
|
||||
<Input
|
||||
id={id('client')}
|
||||
name="client"
|
||||
defaultValue={project?.client}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
id={id('year')}
|
||||
label="Tahun"
|
||||
error={errors.year}
|
||||
>
|
||||
<Input
|
||||
id={id('year')}
|
||||
name="year"
|
||||
type="number"
|
||||
min={1985}
|
||||
max={new Date().getFullYear() + 1}
|
||||
defaultValue={
|
||||
project?.year ?? new Date().getFullYear()
|
||||
}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id={id('sector')}
|
||||
label="Sektor"
|
||||
error={errors.sector}
|
||||
>
|
||||
<NativeSelect
|
||||
id={id('sector')}
|
||||
name="sector"
|
||||
defaultValue={project?.sector ?? 'government'}
|
||||
options={optionsOf(sectorLabels)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
id={id('program')}
|
||||
label="Program unggulan (opsional)"
|
||||
error={errors.program}
|
||||
>
|
||||
<NativeSelect
|
||||
id={id('program')}
|
||||
name="program"
|
||||
defaultValue={project?.program ?? ''}
|
||||
options={[
|
||||
{ value: '', label: '— Tidak ada —' },
|
||||
...optionsOf(programLabels),
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id={id('scope')}
|
||||
label="Cakupan (opsional)"
|
||||
error={errors.scope}
|
||||
>
|
||||
<Input
|
||||
id={id('scope')}
|
||||
name="scope"
|
||||
defaultValue={project?.scope ?? ''}
|
||||
placeholder="Mis. Odoo Enterprise → POS, Penjualan → Custom Development"
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
ProjectsIndex.layout = {
|
||||
breadcrumbs: [{ title: 'Portofolio', href: index() }],
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { Pencil, Plus, Quote, Trash2 } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
ConfirmDelete,
|
||||
FormDialog,
|
||||
FormField,
|
||||
PageHeader,
|
||||
Textarea,
|
||||
} from '@/components/admin/form-controls';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { destroy, index, store, update } from '@/routes/admin/testimonials';
|
||||
import type { Testimonial } from '@/types';
|
||||
|
||||
export default function TestimonialsIndex({
|
||||
testimonials,
|
||||
}: {
|
||||
testimonials: Testimonial[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Testimoni" />
|
||||
<div className="flex flex-col gap-6 p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Testimoni"
|
||||
description="Bagian testimoni di halaman Klien hanya muncul jika ada testimoni yang dipublikasikan. Gunakan hanya testimoni asli yang sudah disetujui klien."
|
||||
action={
|
||||
<TestimonialDialog
|
||||
nextOrder={testimonials.length + 1}
|
||||
trigger={
|
||||
<Button>
|
||||
<Plus /> Tambah Testimoni
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{testimonials.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-xl border border-dashed p-12 text-center text-sm">
|
||||
Belum ada testimoni. Bagian testimoni sedang
|
||||
disembunyikan di website.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{testimonials.map((testimonial) => (
|
||||
<div
|
||||
key={testimonial.id}
|
||||
className="bg-card flex flex-col gap-3 rounded-xl border p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Quote className="text-primary size-5" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge
|
||||
variant={
|
||||
testimonial.is_published
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{testimonial.is_published
|
||||
? 'Tayang'
|
||||
: 'Draf'}
|
||||
</Badge>
|
||||
<TestimonialDialog
|
||||
testimonial={testimonial}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Ubah"
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ConfirmDelete
|
||||
title="Hapus testimoni ini?"
|
||||
description={`Testimoni dari ${testimonial.organization_id} akan dihapus permanen.`}
|
||||
form={destroy.form(testimonial.id)}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Hapus"
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm italic">
|
||||
“{testimonial.quote_id}”
|
||||
</p>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{testimonial.role_id} ·{' '}
|
||||
{testimonial.organization_id}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TestimonialDialog({
|
||||
testimonial,
|
||||
nextOrder = 1,
|
||||
trigger,
|
||||
}: {
|
||||
testimonial?: Testimonial;
|
||||
nextOrder?: number;
|
||||
trigger: ReactNode;
|
||||
}) {
|
||||
const id = (field: string) =>
|
||||
`testimonial-${testimonial?.id ?? 'new'}-${field}`;
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
trigger={trigger}
|
||||
title={testimonial ? 'Ubah Testimoni' : 'Tambah Testimoni'}
|
||||
description="Isi versi Bahasa Indonesia dan English; website menampilkan sesuai bahasa pengunjung."
|
||||
form={testimonial ? update.form(testimonial.id) : store.form()}
|
||||
className="sm:max-w-2xl"
|
||||
>
|
||||
{(errors) => (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
id={id('quote_id')}
|
||||
label="Kutipan (ID)"
|
||||
error={errors.quote_id}
|
||||
>
|
||||
<Textarea
|
||||
id={id('quote_id')}
|
||||
name="quote_id"
|
||||
defaultValue={testimonial?.quote_id}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id={id('quote_en')}
|
||||
label="Quote (EN)"
|
||||
error={errors.quote_en}
|
||||
>
|
||||
<Textarea
|
||||
id={id('quote_en')}
|
||||
name="quote_en"
|
||||
defaultValue={testimonial?.quote_en}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id={id('role_id')}
|
||||
label="Jabatan (ID)"
|
||||
error={errors.role_id}
|
||||
>
|
||||
<Input
|
||||
id={id('role_id')}
|
||||
name="role_id"
|
||||
defaultValue={testimonial?.role_id}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id={id('role_en')}
|
||||
label="Role (EN)"
|
||||
error={errors.role_en}
|
||||
>
|
||||
<Input
|
||||
id={id('role_en')}
|
||||
name="role_en"
|
||||
defaultValue={testimonial?.role_en}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id={id('organization_id')}
|
||||
label="Organisasi (ID)"
|
||||
error={errors.organization_id}
|
||||
>
|
||||
<Input
|
||||
id={id('organization_id')}
|
||||
name="organization_id"
|
||||
defaultValue={testimonial?.organization_id}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id={id('organization_en')}
|
||||
label="Organization (EN)"
|
||||
error={errors.organization_en}
|
||||
>
|
||||
<Input
|
||||
id={id('organization_en')}
|
||||
name="organization_en"
|
||||
defaultValue={testimonial?.organization_en}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id={id('sort_order')}
|
||||
label="Nomor urut"
|
||||
error={errors.sort_order}
|
||||
>
|
||||
<Input
|
||||
id={id('sort_order')}
|
||||
name="sort_order"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={
|
||||
testimonial?.sort_order ?? nextOrder
|
||||
}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<label className="flex items-center gap-2 self-end pb-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_published"
|
||||
value="1"
|
||||
defaultChecked={
|
||||
testimonial?.is_published ?? true
|
||||
}
|
||||
className="accent-primary size-4"
|
||||
/>
|
||||
Tampilkan di website
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
TestimonialsIndex.layout = {
|
||||
breadcrumbs: [{ title: 'Testimoni', href: index() }],
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
import { Head, usePage } from '@inertiajs/react';
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
ConfirmDelete,
|
||||
FormDialog,
|
||||
FormField,
|
||||
NativeSelect,
|
||||
PageHeader,
|
||||
} from '@/components/admin/form-controls';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { formatDate, optionsOf, roleLabels } from '@/lib/admin-labels';
|
||||
import { destroy, index, store, update } from '@/routes/admin/users';
|
||||
import type { UserRole } from '@/types';
|
||||
|
||||
type PanelUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const roleDescriptions: Record<UserRole, string> = {
|
||||
admin: 'Semua menu, termasuk kelola pengguna.',
|
||||
editor: 'Konten website dan inbox konsultasi.',
|
||||
};
|
||||
|
||||
export default function UsersIndex({ users }: { users: PanelUser[] }) {
|
||||
const { auth } = usePage().props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Pengguna" />
|
||||
<div className="flex flex-col gap-6 p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Pengguna"
|
||||
description="Akun yang dapat masuk ke admin panel. Registrasi publik dinonaktifkan."
|
||||
action={
|
||||
<UserDialog
|
||||
trigger={
|
||||
<Button>
|
||||
<Plus /> Tambah Pengguna
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-card overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-muted-foreground border-b text-left text-xs">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">Nama</th>
|
||||
<th className="px-4 py-3 font-medium">Peran</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
Dibuat
|
||||
</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{users.map((user) => {
|
||||
const isSelf = user.id === auth.user?.id;
|
||||
|
||||
return (
|
||||
<tr key={user.id}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold">
|
||||
{user.name}
|
||||
{isSelf && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{' '}
|
||||
(Anda)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant={
|
||||
user.role === 'admin'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{roleLabels[user.role]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-muted-foreground px-4 py-3 text-xs">
|
||||
{formatDate(user.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-1">
|
||||
<UserDialog
|
||||
user={user}
|
||||
isSelf={isSelf}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Ubah"
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{!isSelf && (
|
||||
<ConfirmDelete
|
||||
title="Hapus pengguna ini?"
|
||||
description={`${user.name} tidak akan bisa masuk ke admin panel lagi.`}
|
||||
form={destroy.form(
|
||||
user.id,
|
||||
)}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Hapus"
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{optionsOf(roleLabels).map(({ value, label }) => (
|
||||
<div
|
||||
key={value}
|
||||
className="text-muted-foreground rounded-lg border border-dashed p-4 text-sm"
|
||||
>
|
||||
<span className="text-foreground font-semibold">
|
||||
{label}:
|
||||
</span>{' '}
|
||||
{roleDescriptions[value]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDialog({
|
||||
user,
|
||||
isSelf = false,
|
||||
trigger,
|
||||
}: {
|
||||
user?: PanelUser;
|
||||
isSelf?: boolean;
|
||||
trigger: ReactNode;
|
||||
}) {
|
||||
const id = (field: string) => `user-${user?.id ?? 'new'}-${field}`;
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
trigger={trigger}
|
||||
title={user ? 'Ubah Pengguna' : 'Tambah Pengguna'}
|
||||
form={user ? update.form(user.id) : store.form()}
|
||||
>
|
||||
{(errors) => (
|
||||
<>
|
||||
<FormField id={id('name')} label="Nama" error={errors.name}>
|
||||
<Input
|
||||
id={id('name')}
|
||||
name="name"
|
||||
defaultValue={user?.name}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id={id('email')}
|
||||
label="Email"
|
||||
error={errors.email}
|
||||
>
|
||||
<Input
|
||||
id={id('email')}
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={user?.email}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id={id('role')}
|
||||
label="Peran"
|
||||
error={errors.role}
|
||||
>
|
||||
<NativeSelect
|
||||
id={id('role')}
|
||||
name="role"
|
||||
defaultValue={user?.role ?? 'editor'}
|
||||
options={optionsOf(roleLabels)}
|
||||
disabled={isSelf}
|
||||
/>
|
||||
{isSelf && (
|
||||
<>
|
||||
<input
|
||||
type="hidden"
|
||||
name="role"
|
||||
value={user?.role}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Anda tidak dapat mengubah peran akun
|
||||
sendiri.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</FormField>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
id={id('password')}
|
||||
label={user ? 'Password baru' : 'Password'}
|
||||
error={errors.password}
|
||||
>
|
||||
<Input
|
||||
id={id('password')}
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={
|
||||
user ? 'Kosongkan jika tidak diubah' : ''
|
||||
}
|
||||
required={!user}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id={id('password_confirmation')}
|
||||
label="Ulangi password"
|
||||
error={errors.password_confirmation}
|
||||
>
|
||||
<Input
|
||||
id={id('password_confirmation')}
|
||||
name="password_confirmation"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required={!user}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
UsersIndex.layout = {
|
||||
breadcrumbs: [{ title: 'Pengguna', href: index() }],
|
||||
};
|
||||
@@ -1,117 +1,169 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { Form, Head, Link } from '@inertiajs/react';
|
||||
import InputError from '@/components/input-error';
|
||||
import PasswordInput from '@/components/password-input';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { register } from '@/routes';
|
||||
import { Button } from '@/components/site/button';
|
||||
import Card from '@/components/site/card';
|
||||
import { TextInput } from '@/components/site/form-field';
|
||||
import Glow, { GridPattern } from '@/components/site/glow';
|
||||
import Icon from '@/components/site/icon';
|
||||
import LanguageToggle from '@/components/site/language-toggle';
|
||||
import ThemeToggle from '@/components/site/theme-toggle';
|
||||
import { common } from '@/content/common';
|
||||
import { company } from '@/data/site';
|
||||
import { useContent } from '@/hooks/use-locale';
|
||||
import { home } from '@/routes';
|
||||
import { store } from '@/routes/login';
|
||||
import { request } from '@/routes/password';
|
||||
import PasskeyVerify from '@/components/passkey-verify';
|
||||
|
||||
type Props = {
|
||||
status?: string;
|
||||
canResetPassword: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Admin panel sign-in. Public registration is disabled; admin accounts are
|
||||
* created with `php artisan admin:create`.
|
||||
*/
|
||||
export default function Login({ status, canResetPassword }: Props) {
|
||||
const t = useContent(common).login;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Log in" />
|
||||
<div className="probindo-site bg-surface-container-lowest text-on-surface relative flex min-h-svh flex-col items-center justify-center overflow-hidden p-6 font-sans antialiased">
|
||||
<Head title={t.pageTitle} />
|
||||
<GridPattern />
|
||||
<Glow className="-top-40 -left-20 h-[500px] w-[640px]" />
|
||||
<Glow className="bg-primary/15 -right-20 bottom-0 h-[420px] w-[520px]" />
|
||||
|
||||
<PasskeyVerify />
|
||||
<div className="absolute top-5 right-5 flex items-center gap-2">
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<Form
|
||||
{...store.form()}
|
||||
resetOnSuccess={['password']}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="email"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
<div className="relative flex w-full max-w-md flex-col gap-6">
|
||||
<Link
|
||||
href={home()}
|
||||
className="flex flex-col items-center gap-3 self-center"
|
||||
>
|
||||
<div className="border-outline-variant/50 dark:bg-surface-container-low rounded-2xl border bg-white p-3">
|
||||
<img
|
||||
src={company.logo}
|
||||
alt={company.name}
|
||||
className="h-12 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-on-surface-variant font-mono text-xs font-medium">
|
||||
{company.name}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
{canResetPassword && (
|
||||
<TextLink
|
||||
href={request()}
|
||||
className="ml-auto text-sm"
|
||||
tabIndex={5}
|
||||
>
|
||||
Forgot your password?
|
||||
</TextLink>
|
||||
)}
|
||||
<Card padding="lg" className="shadow-xl">
|
||||
<div className="mb-6 flex flex-col gap-1 text-center">
|
||||
<h1 className="font-display text-on-surface text-2xl font-extrabold">
|
||||
{t.title}
|
||||
</h1>
|
||||
<p className="text-on-surface-variant text-sm">
|
||||
{t.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div
|
||||
role="status"
|
||||
className="border-primary/40 text-on-surface mb-4 rounded-lg border p-3 text-center text-sm"
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form
|
||||
{...store.form()}
|
||||
resetOnSuccess={['password']}
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-on-surface font-mono text-xs font-medium"
|
||||
>
|
||||
{t.email}
|
||||
</label>
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="email"
|
||||
placeholder={t.emailPlaceholder}
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
tabIndex={2}
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
name="remember"
|
||||
tabIndex={3}
|
||||
/>
|
||||
<Label htmlFor="remember">Remember me</Label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-on-surface font-mono text-xs font-medium"
|
||||
>
|
||||
{t.password}
|
||||
</label>
|
||||
{canResetPassword && (
|
||||
<Link
|
||||
href={request()}
|
||||
className="text-primary text-xs font-semibold hover:underline"
|
||||
>
|
||||
{t.forgot}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
placeholder={t.passwordPlaceholder}
|
||||
className="border-outline-variant/60 bg-surface-container-lowest text-on-surface focus-visible:border-primary h-auto rounded-xl px-4 py-2.5 text-sm"
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4 w-full"
|
||||
tabIndex={4}
|
||||
disabled={processing}
|
||||
data-test="login-button"
|
||||
>
|
||||
{processing && <Spinner />}
|
||||
Log in
|
||||
</Button>
|
||||
</div>
|
||||
<label className="text-on-surface-variant flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remember"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{t.remember}
|
||||
</label>
|
||||
|
||||
<div className="text-muted-foreground text-center text-sm">
|
||||
Don't have an account?{' '}
|
||||
<TextLink href={register()} tabIndex={5}>
|
||||
Sign up
|
||||
</TextLink>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
<Button
|
||||
type="submit"
|
||||
size="block"
|
||||
icon={
|
||||
processing
|
||||
? 'progress_activity'
|
||||
: 'login'
|
||||
}
|
||||
disabled={processing}
|
||||
data-test="login-button"
|
||||
>
|
||||
{t.submit}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{status && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<Link
|
||||
href={home()}
|
||||
className="text-on-surface-variant hover:text-primary flex items-center gap-1.5 self-center text-sm"
|
||||
>
|
||||
<Icon name="arrow_back" className="text-base" />
|
||||
{t.back}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Login.layout = {
|
||||
title: 'Log in to your account',
|
||||
description: 'Enter your email and password below to log in',
|
||||
};
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import InputError from '@/components/input-error';
|
||||
import PasswordInput from '@/components/password-input';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { login } from '@/routes';
|
||||
import { store } from '@/routes/register';
|
||||
|
||||
type Props = {
|
||||
passwordRules: string;
|
||||
};
|
||||
|
||||
export default function Register({ passwordRules }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Register" />
|
||||
<Form
|
||||
{...store.form()}
|
||||
resetOnSuccess={['password', 'password_confirmation']}
|
||||
disableWhileProcessing
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="name"
|
||||
name="name"
|
||||
placeholder="Full name"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
tabIndex={2}
|
||||
autoComplete="email"
|
||||
name="email"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
tabIndex={3}
|
||||
autoComplete="new-password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">
|
||||
Confirm password
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
required
|
||||
tabIndex={4}
|
||||
autoComplete="new-password"
|
||||
name="password_confirmation"
|
||||
placeholder="Confirm password"
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.password_confirmation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-2 w-full"
|
||||
tabIndex={5}
|
||||
data-test="register-user-button"
|
||||
>
|
||||
{processing && <Spinner />}
|
||||
Create account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-center text-sm">
|
||||
Already have an account?{' '}
|
||||
<TextLink href={login()} tabIndex={6}>
|
||||
Log in
|
||||
</TextLink>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Register.layout = {
|
||||
title: 'Create an account',
|
||||
description: 'Enter your details below to create your account',
|
||||
};
|
||||
@@ -1,25 +1,294 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
|
||||
import { dashboard } from '@/routes';
|
||||
import { Head, Link, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Building2,
|
||||
FolderKanban,
|
||||
Inbox,
|
||||
Layers,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { portfolio, yearRange } from '@/data/projects';
|
||||
import {
|
||||
formatDate,
|
||||
sectorLabels,
|
||||
statusBadgeClasses,
|
||||
statusLabels,
|
||||
} from '@/lib/admin-labels';
|
||||
import { about, clients, contact, dashboard, home, services } from '@/routes';
|
||||
import { index as consultationsIndex } from '@/routes/admin/consultations';
|
||||
import { index as projectsIndex } from '@/routes/admin/projects';
|
||||
import type { Consultation, Project, ProjectSector } from '@/types';
|
||||
|
||||
type DashboardProps = {
|
||||
projects: Project[];
|
||||
newConsultations: number;
|
||||
latestConsultations: Pick<
|
||||
Consultation,
|
||||
'id' | 'name' | 'company' | 'service' | 'status' | 'created_at'
|
||||
>[];
|
||||
};
|
||||
|
||||
const sitePages = [
|
||||
{ title: 'Beranda', href: home() },
|
||||
{ title: 'Tentang Kami', href: about() },
|
||||
{ title: 'Jasa', href: services() },
|
||||
{ title: 'Klien & Portofolio', href: clients() },
|
||||
{ title: 'Kontak', href: contact() },
|
||||
];
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string | number;
|
||||
note: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card border-sidebar-border/70 flex flex-col gap-3 rounded-xl border p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground font-mono text-xs font-semibold tracking-wider uppercase">
|
||||
{label}
|
||||
</span>
|
||||
<span className="bg-accent text-accent-foreground flex size-9 items-center justify-center rounded-lg">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-display text-3xl font-extrabold">
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">{note}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard({
|
||||
projects,
|
||||
newConsultations,
|
||||
latestConsultations,
|
||||
}: DashboardProps) {
|
||||
const { auth } = usePage().props;
|
||||
const {
|
||||
clientCount,
|
||||
clientsInSector,
|
||||
projectsBySector,
|
||||
projectsInProgram,
|
||||
} = portfolio(projects);
|
||||
const sectors: ProjectSector[] = ['government', 'private', 'soe'];
|
||||
const erpProjects = projectsInProgram('erp');
|
||||
const latest = projects.slice(0, 5);
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<>
|
||||
<Head title="Dashboard" />
|
||||
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
<div className="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
<div className="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="from-primary relative overflow-hidden rounded-xl bg-gradient-to-r to-[#c81e2a] p-6 text-white md:p-8">
|
||||
<div className="pointer-events-none absolute -top-16 -right-16 size-56 rounded-full bg-white/10 blur-2xl" />
|
||||
<div className="relative flex flex-col gap-2">
|
||||
<span className="font-mono text-xs font-semibold tracking-wider text-white/80 uppercase">
|
||||
Admin Panel · PT Probindo Artika Jaya
|
||||
</span>
|
||||
<h1 className="font-display text-2xl font-extrabold md:text-3xl">
|
||||
Selamat datang, {auth.user?.name}
|
||||
</h1>
|
||||
<p className="max-w-2xl text-sm text-white/85">
|
||||
Ringkasan permintaan konsultasi dan rekam jejak
|
||||
proyek yang ditampilkan di website.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border md:min-h-min">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
icon={FolderKanban}
|
||||
label="Total Proyek"
|
||||
value={projects.length}
|
||||
note={`Periode ${yearRange(projects)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Users}
|
||||
label="Total Klien"
|
||||
value={clientCount}
|
||||
note="Pemerintahan, BUMN & swasta"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Layers}
|
||||
label="Pengalaman ERP"
|
||||
value={erpProjects.length}
|
||||
note={`Periode ${yearRange(erpProjects)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Inbox}
|
||||
label="Konsultasi Baru"
|
||||
value={newConsultations}
|
||||
note="Belum ditindaklanjuti"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-4 lg:col-span-2">
|
||||
<div className="bg-card border-sidebar-border/70 rounded-xl border p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-display text-base font-bold">
|
||||
Konsultasi Terbaru
|
||||
</h2>
|
||||
<Link
|
||||
href={consultationsIndex()}
|
||||
className="text-primary flex items-center gap-1 text-xs font-semibold hover:underline"
|
||||
>
|
||||
Buka inbox
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
{latestConsultations.length === 0 ? (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
Belum ada permintaan konsultasi.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-border divide-y">
|
||||
{latestConsultations.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex items-center gap-4 py-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{item.name}
|
||||
{item.company &&
|
||||
` · ${item.company}`}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{formatDate(
|
||||
item.created_at,
|
||||
)}
|
||||
{item.service &&
|
||||
` · ${item.service}`}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
className={
|
||||
statusBadgeClasses[
|
||||
item.status
|
||||
]
|
||||
}
|
||||
>
|
||||
{statusLabels[item.status]}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-card border-sidebar-border/70 rounded-xl border p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-display text-base font-bold">
|
||||
Proyek Terbaru
|
||||
</h2>
|
||||
<Link
|
||||
href={projectsIndex()}
|
||||
className="text-primary flex items-center gap-1 text-xs font-semibold hover:underline"
|
||||
>
|
||||
Kelola portofolio
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<ul className="divide-border divide-y">
|
||||
{latest.map((project) => (
|
||||
<li
|
||||
key={project.id}
|
||||
className="flex items-start gap-4 py-3"
|
||||
>
|
||||
<span className="bg-accent text-accent-foreground shrink-0 rounded-md px-2 py-0.5 font-mono text-xs font-bold">
|
||||
{project.year}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{project.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{project.client} ·{' '}
|
||||
{sectorLabels[project.sector]}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-card border-sidebar-border/70 rounded-xl border p-5">
|
||||
<h2 className="font-display mb-4 text-base font-bold">
|
||||
Proyek per Sektor
|
||||
</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{sectors.map((sector) => {
|
||||
const count =
|
||||
projectsBySector(sector).length;
|
||||
const percent = projects.length
|
||||
? Math.round(
|
||||
(count / projects.length) * 100,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sector}
|
||||
className="flex flex-col gap-1.5"
|
||||
>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<Building2 className="text-primary size-4" />
|
||||
{sectorLabels[sector]}
|
||||
</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{count} proyek ·{' '}
|
||||
{
|
||||
clientsInSector(sector)
|
||||
.length
|
||||
}{' '}
|
||||
klien
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted h-2 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full"
|
||||
style={{
|
||||
width: `${percent}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border-sidebar-border/70 rounded-xl border p-5">
|
||||
<h2 className="font-display mb-3 text-base font-bold">
|
||||
Halaman Website
|
||||
</h2>
|
||||
<div className="flex flex-col">
|
||||
{sitePages.map((page) => (
|
||||
<a
|
||||
key={page.title}
|
||||
href={page.href.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:bg-accent hover:text-accent-foreground flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors"
|
||||
>
|
||||
{page.title}
|
||||
<ArrowUpRight className="size-4 opacity-60" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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