Admin Panel
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-09-29 14:57:37 +07:00
parent 04fbddba13
commit 8c6dad2a31
94 changed files with 4864 additions and 1151 deletions
@@ -1,30 +1,32 @@
import { Form } from '@inertiajs/react';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Button } from '@/components/site/button';
import {
Field,
FieldError,
FormSuccess,
Honeypot,
SelectInput,
TextInput,
} from '@/components/site/form-field';
import { common, serviceOptions } from '@/content/common';
import { useContent } from '@/hooks/use-locale';
import { store } from '@/routes/consultations';
/**
* Compact consultation request form.
*
* TODO: submissions are not sent anywhere yet; wire this to a Laravel
* endpoint before going live.
*/
export default function ConsultationForm({ idPrefix }: { idPrefix: string }) {
type ConsultationFormProps = {
idPrefix: string;
/** Which page the request came from, shown in the admin inbox. */
source: 'home' | 'services';
};
/** Compact consultation request form, saved to the admin inbox. */
export default function ConsultationForm({
idPrefix,
source,
}: ConsultationFormProps) {
const t = useContent(common);
const [submitted, setSubmitted] = useState(false);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitted(true);
};
return (
<div className="border-outline-variant/60 bg-surface-container-high flex flex-col gap-4 rounded-2xl border p-6 shadow-xl lg:p-7">
<div className="flex flex-col">
@@ -42,60 +44,82 @@ export default function ConsultationForm({ idPrefix }: { idPrefix: string }) {
message={t.form.successMessage}
/>
) : (
<form className="flex flex-col gap-3" onSubmit={handleSubmit}>
<Field id={`${idPrefix}-name`} label={t.form.name} required>
<TextInput
id={`${idPrefix}-name`}
name="name"
placeholder={t.form.namePlaceholder}
required
/>
</Field>
<Field
id={`${idPrefix}-email`}
label={t.form.email}
required
>
<TextInput
id={`${idPrefix}-email`}
name="email"
type="email"
placeholder={t.form.emailPlaceholder}
required
/>
</Field>
<Field
id={`${idPrefix}-phone`}
label={t.form.phone}
required
>
<TextInput
id={`${idPrefix}-phone`}
name="phone"
type="tel"
placeholder="+62 811 xxxx xxxx"
required
/>
</Field>
<Field id={`${idPrefix}-service`} label={t.form.service}>
<SelectInput
id={`${idPrefix}-service`}
name="service"
options={serviceOptions(t)}
/>
</Field>
<Button
type="submit"
size="block"
icon="send"
className="mt-2"
>
{t.form.submit}
</Button>
<p className="text-on-surface-variant text-center text-[11px]">
{t.form.nda}
</p>
</form>
<Form
{...store.form()}
options={{ preserveScroll: true }}
onSuccess={() => setSubmitted(true)}
className="relative flex flex-col gap-3"
>
{({ processing, errors }) => (
<>
<input type="hidden" name="source" value={source} />
<Honeypot />
<Field
id={`${idPrefix}-name`}
label={t.form.name}
required
>
<TextInput
id={`${idPrefix}-name`}
name="name"
placeholder={t.form.namePlaceholder}
required
/>
<FieldError message={errors.name} />
</Field>
<Field
id={`${idPrefix}-email`}
label={t.form.email}
required
>
<TextInput
id={`${idPrefix}-email`}
name="email"
type="email"
placeholder={t.form.emailPlaceholder}
required
/>
<FieldError message={errors.email} />
</Field>
<Field
id={`${idPrefix}-phone`}
label={t.form.phone}
required
>
<TextInput
id={`${idPrefix}-phone`}
name="phone"
type="tel"
placeholder="+62 811 xxxx xxxx"
required
/>
<FieldError message={errors.phone} />
</Field>
<Field
id={`${idPrefix}-service`}
label={t.form.service}
>
<SelectInput
id={`${idPrefix}-service`}
name="service"
options={serviceOptions(t)}
/>
</Field>
<Button
type="submit"
size="block"
icon={processing ? 'progress_activity' : 'send'}
disabled={processing}
className="mt-2"
>
{t.form.submit}
</Button>
<p className="text-on-surface-variant text-center text-[11px]">
{t.form.nda}
</p>
</>
)}
</Form>
)}
</div>
);
@@ -39,6 +39,32 @@ export function Field({
);
}
/** Validation message under a field. */
export function FieldError({ message }: { message?: string }) {
if (!message) {
return null;
}
return <p className="text-primary text-xs font-medium">{message}</p>;
}
/**
* Hidden field only bots fill in; the server rejects any submission that
* has a value here.
*/
export function Honeypot() {
return (
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="absolute -left-[9999px] h-0 w-0 opacity-0"
/>
);
}
export function TextInput({
className,
...props
@@ -4,11 +4,12 @@ import Icon from '@/components/site/icon';
import Pill from '@/components/site/pill';
import Section from '@/components/site/section';
import { common } from '@/content/common';
import { company, offices } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { useSiteInfo } from '@/hooks/use-site-info';
type ConsultationSectionProps = {
id: string;
source: 'home' | 'services';
title: string;
description: string;
};
@@ -16,18 +17,20 @@ type ConsultationSectionProps = {
/** Closing call-to-action with contact details and the quick form. */
export default function ConsultationSection({
id,
source,
title,
description,
}: ConsultationSectionProps) {
const site = useSiteInfo();
const t = useContent(common);
const contacts = offices.map((office) => ({
const contacts = site.offices.map((office) => ({
icon: office.icon,
title: office.name,
lines: [
office.building,
`${office.street}, ${office.area}`,
office.city,
`${t.consultation.tel}: ${office.phone.label}`,
`${t.consultation.tel}: ${office.phone}`,
],
}));
@@ -74,18 +77,18 @@ export default function ConsultationSection({
))}
</div>
<a
href={`mailto:${company.email.busdev}`}
href={`mailto:${site.email.busdev}`}
className="text-on-surface-variant hover:text-primary flex items-center gap-2 text-xs"
>
<Icon
name="mail"
className="text-primary text-base"
/>
{company.email.busdev}
{site.email.busdev}
</a>
</div>
<div className="w-full lg:col-span-5">
<ConsultationForm idPrefix={id} />
<ConsultationForm idPrefix={id} source={source} />
</div>
</div>
</div>
@@ -3,7 +3,7 @@ import Glow from '@/components/site/glow';
import Icon from '@/components/site/icon';
import Pill from '@/components/site/pill';
import Section from '@/components/site/section';
import { company } from '@/data/site';
import { useSiteInfo } from '@/hooks/use-site-info';
type CtaBannerProps = {
badge: string;
@@ -22,9 +22,10 @@ export default function CtaBanner({
actions,
showContacts = true,
}: CtaBannerProps) {
const site = useSiteInfo();
const contacts = [
{ icon: 'call', label: company.phone.label },
{ icon: 'mail', label: company.email.busdev },
{ icon: 'call', label: site.phone.label },
{ icon: 'mail', label: site.email.busdev },
{ icon: 'location_on', label: 'Bintaro & Plaza Simatupang, Jakarta' },
];
+10 -7
View File
@@ -2,9 +2,10 @@ import { Link } from '@inertiajs/react';
import Container from '@/components/site/container';
import Icon from '@/components/site/icon';
import { common } from '@/content/common';
import { company, consultationUrl, officeAddress, offices } from '@/data/site';
import { company, consultationUrl, officeAddress } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { about, clients, contact, services } from '@/routes';
import { useSiteInfo } from '@/hooks/use-site-info';
import { about, clients, contact, login, services } from '@/routes';
function FooterHeading({ children }: { children: string }) {
return (
@@ -15,6 +16,7 @@ function FooterHeading({ children }: { children: string }) {
}
export default function SiteFooter() {
const site = useSiteInfo();
const t = useContent(common);
const companyLinks = [
@@ -27,6 +29,7 @@ export default function SiteFooter() {
{ label: t.footer.links.clients, href: clients.url() },
{ label: t.footer.links.audit, href: consultationUrl },
{ label: t.footer.links.offices, href: `${contact.url()}#lokasi` },
{ label: t.footer.links.admin, href: login.url() },
];
return (
@@ -50,7 +53,7 @@ export default function SiteFooter() {
{t.footer.tagline}
</p>
<div className="text-on-surface-variant mt-2 flex flex-col gap-4 text-xs">
{offices.map((office) => (
{site.offices.map((office) => (
<div
key={office.name}
className="flex items-start gap-2"
@@ -65,23 +68,23 @@ export default function SiteFooter() {
</span>
<span>{officeAddress(office)}</span>
<a
href={office.phone.href}
href={office.phoneHref}
className="hover:text-primary font-mono transition-colors"
>
{t.footer.tel} {office.phone.label}
{t.footer.tel} {office.phone}
</a>
</div>
</div>
))}
<a
href={`mailto:${company.email.busdev}`}
href={`mailto:${site.email.busdev}`}
className="hover:text-primary flex items-center gap-2 font-mono transition-colors"
>
<Icon
name="mail"
className="text-primary text-base"
/>
{company.email.busdev}
{site.email.busdev}
</a>
</div>
</div>
+7 -5
View File
@@ -7,12 +7,14 @@ 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, consultationUrl, navigation, offices } from '@/data/site';
import { consultationUrl, navigation } from '@/data/site';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { useContent } from '@/hooks/use-locale';
import { useSiteInfo } from '@/hooks/use-site-info';
import { cn } from '@/lib/utils';
export default function SiteHeader() {
const site = useSiteInfo();
const t = useContent(common);
const { isCurrentUrl } = useCurrentUrl();
const [menuOpen, setMenuOpen] = useState(false);
@@ -46,20 +48,20 @@ export default function SiteHeader() {
<div className="flex items-center gap-2 sm:gap-3">
<div className="text-on-surface-variant hidden flex-col text-right font-mono text-[11px] 2xl:flex">
<a
href={offices[0].phone.href}
href={site.phone.href}
className="text-on-surface hover:text-primary flex items-center justify-end gap-1 font-semibold"
>
<Icon
name="call"
className="text-primary text-xs"
/>
{offices[0].phone.label}
{site.phone.label}
</a>
<a
href={`mailto:${company.email.busdev}`}
href={`mailto:${site.email.busdev}`}
className="hover:text-primary"
>
{company.email.busdev}
{site.email.busdev}
</a>
</div>
<LanguageToggle />