first commit
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-09-28 13:52:25 +07:00
commit 566275348c
262 changed files with 37008 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
import { Link } from '@inertiajs/react';
import { company } from '@/data/site';
import { home } from '@/routes';
/** Logo mark with company name, linking back to the home page. */
export default function Brand() {
return (
<Link
href={home()}
title="Kembali ke Beranda"
className="group flex items-center gap-3.5"
>
<div className="border-outline-variant/50 group-hover:border-primary/60 dark:bg-surface-container-low flex items-center justify-center rounded-xl border bg-white p-1.5 transition-all duration-300 group-hover:shadow-[0_0_20px_rgba(163,29,36,0.35)]">
<img
src={company.logo}
alt={company.name}
className="h-8 w-auto object-contain transition-transform duration-300 group-hover:scale-105"
/>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span className="font-display text-on-surface text-base font-bold tracking-wider uppercase">
{company.brand}
</span>
<span className="bg-primary h-1.5 w-1.5 animate-pulse rounded-full" />
</div>
<span className="text-on-surface-variant font-mono text-[11px] font-medium">
{company.name}
</span>
</div>
</Link>
);
}
+131
View File
@@ -0,0 +1,131 @@
import type { InertiaLinkProps } from '@inertiajs/react';
import { Link } from '@inertiajs/react';
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import Icon from '@/components/site/icon';
import { cn, toUrl } from '@/lib/utils';
export const buttonVariants = cva(
'font-display inline-flex cursor-pointer items-center justify-center gap-2 font-bold transition-all active:scale-[0.98] disabled:pointer-events-none disabled:opacity-60',
{
variants: {
variant: {
primary:
'from-paj-crimson to-primary bg-gradient-to-r text-white shadow-[0_0_24px_rgba(163,29,36,0.35)] hover:shadow-[0_0_32px_rgba(255,77,88,0.5)] hover:brightness-110',
secondary:
'border-outline-variant/60 bg-surface-container-high text-on-surface hover:border-primary/50 hover:bg-surface-bright border',
ghost: 'text-primary hover:text-on-surface',
},
size: {
sm: 'rounded-full px-5 py-2.5 text-xs tracking-wide uppercase',
md: 'rounded-full px-6 py-3.5 text-sm',
block: 'w-full rounded-xl px-6 py-3.5 text-xs tracking-wider uppercase',
inline: 'p-0 text-sm',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
},
);
type ButtonStyleProps = VariantProps<typeof buttonVariants> & {
icon?: string;
/** Icon rendered after the label, e.g. `arrow_forward`. */
trailingIcon?: string;
className?: string;
children: ReactNode;
};
function Content({
icon,
trailingIcon,
children,
}: Pick<ButtonStyleProps, 'icon' | 'trailingIcon' | 'children'>) {
return (
<>
{icon && <Icon name={icon} className="text-lg" />}
<span>{children}</span>
{trailingIcon && <Icon name={trailingIcon} className="text-lg" />}
</>
);
}
type ButtonLinkProps = ButtonStyleProps & {
href: NonNullable<InertiaLinkProps['href']>;
onClick?: () => void;
};
/**
* Renders an Inertia <Link> for app routes, and a plain <a> for in-page
* anchors, tel:/mailto: links and external URLs (opened in a new tab).
*/
export function ButtonLink({
href,
variant,
size,
icon,
trailingIcon,
className,
children,
onClick,
}: ButtonLinkProps) {
const classes = cn(buttonVariants({ variant, size }), className);
const content = (
<Content icon={icon} trailingIcon={trailingIcon}>
{children}
</Content>
);
const url = toUrl(href);
if (/^(#|tel:|mailto:)/.test(url)) {
return (
<a href={url} className={classes} onClick={onClick}>
{content}
</a>
);
}
if (/^https?:\/\//.test(url)) {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={classes}
onClick={onClick}
>
{content}
</a>
);
}
return (
<Link href={href} className={classes} onClick={onClick}>
{content}
</Link>
);
}
export function Button({
variant,
size,
icon,
trailingIcon,
className,
children,
type = 'button',
...props
}: ButtonStyleProps &
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'className'>) {
return (
<button
type={type}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
>
<Content icon={icon} trailingIcon={trailingIcon}>
{children}
</Content>
</button>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
const paddings = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-6 lg:p-8',
} as const;
type CardProps = {
padding?: keyof typeof paddings;
/** Adds the crimson hover border used on clickable-looking cards. */
interactive?: boolean;
className?: string;
children: ReactNode;
};
export default function Card({
padding = 'md',
interactive = false,
className,
children,
}: CardProps) {
return (
<div
className={cn(
'border-outline-variant/50 bg-surface-container-low relative rounded-2xl border shadow-sm dark:shadow-lg',
interactive &&
'group hover:border-primary/60 transition-all duration-300 hover:shadow-md',
paddings[padding],
className,
)}
>
{children}
</div>
);
}
@@ -0,0 +1,109 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Button } from '@/components/site/button';
import {
Field,
FormSuccess,
SelectInput,
TextInput,
} from '@/components/site/form-field';
import { serviceOptions } from '@/data/site';
/**
* 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 }) {
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">
<span className="font-display text-on-surface text-base font-bold">
Jadwalkan Konsultasi IT
</span>
<span className="text-on-surface-variant text-xs">
Konfirmasi langsung oleh Lead Architect PAJ dalam 2 jam
kerja.
</span>
</div>
{submitted ? (
<FormSuccess
title="Permintaan Terkirim!"
message="Tim Lead Consultant PT Probindo Artika Jaya akan segera menghubungi kontak Anda untuk konfirmasi jadwal sesi konsultasi."
/>
) : (
<form className="flex flex-col gap-3" onSubmit={handleSubmit}>
<Field
id={`${idPrefix}-name`}
label="Nama Lengkap & Perusahaan"
required
>
<TextInput
id={`${idPrefix}-name`}
name="name"
placeholder="Nama Anda (PT ...)"
required
/>
</Field>
<Field
id={`${idPrefix}-email`}
label="Email Perusahaan / Kantor"
required
>
<TextInput
id={`${idPrefix}-email`}
name="email"
type="email"
placeholder="nama@perusahaan.co.id"
required
/>
</Field>
<Field
id={`${idPrefix}-phone`}
label="No. WhatsApp / Telepon"
required
>
<TextInput
id={`${idPrefix}-phone`}
name="phone"
type="tel"
placeholder="+62 811 xxxx xxxx"
required
/>
</Field>
<Field
id={`${idPrefix}-service`}
label="Layanan Yang Dibutuhkan"
>
<SelectInput
id={`${idPrefix}-service`}
name="service"
options={serviceOptions}
/>
</Field>
<Button
type="submit"
size="block"
icon="send"
className="mt-2"
>
Kirim Permintaan Konsultasi
</Button>
<p className="text-on-surface-variant text-center text-[11px]">
Non-Disclosure Agreement (NDA) dapat ditandatangani
sebelum audit teknis.
</p>
</form>
)}
</div>
);
}
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
const widths = {
default: 'max-w-7xl',
narrow: 'max-w-5xl',
} as const;
type ContainerProps = {
size?: keyof typeof widths;
className?: string;
children: ReactNode;
};
export default function Container({
size = 'default',
className,
children,
}: ContainerProps) {
return (
<div
className={cn(
'relative mx-auto w-full px-5 sm:px-6 lg:px-8',
widths[size],
className,
)}
>
{children}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
import Icon from '@/components/site/icon';
import { cn } from '@/lib/utils';
type EyebrowProps = {
icon?: string;
className?: string;
children: ReactNode;
};
/** Small monospace label that sits above a heading. */
export default function Eyebrow({ icon, className, children }: EyebrowProps) {
return (
<div
className={cn(
'text-primary flex items-center gap-2 font-mono text-xs font-semibold tracking-wider uppercase',
className,
)}
>
{icon ? (
<Icon name={icon} className="text-base" />
) : (
<span className="bg-primary-container h-1 w-6 rounded-full" />
)}
<span>{children}</span>
</div>
);
}
@@ -0,0 +1,57 @@
import type { ReactNode } from 'react';
import Card from '@/components/site/card';
import Icon from '@/components/site/icon';
import IconTile from '@/components/site/icon-tile';
import { cn } from '@/lib/utils';
type FeatureCardProps = {
icon: string;
title: ReactNode;
description: ReactNode;
/** Small label shown opposite the icon, e.g. "01 / JASA RESMI". */
tag?: string;
/** Outcome line separated by a divider at the bottom of the card. */
footer?: string;
iconTone?: 'soft' | 'solid' | 'neutral';
className?: string;
};
export default function FeatureCard({
icon,
title,
description,
tag,
footer,
iconTone = 'soft',
className,
}: FeatureCardProps) {
return (
<Card
interactive
className={cn('flex flex-col justify-between', className)}
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<IconTile icon={icon} tone={iconTone} groupHover />
{tag && (
<span className="text-primary font-mono text-[11px] font-bold">
{tag}
</span>
)}
</div>
<h3 className="font-display text-on-surface text-lg font-bold">
{title}
</h3>
<p className="text-on-surface-variant text-sm leading-relaxed">
{description}
</p>
</div>
{footer && (
<div className="border-outline-variant/30 text-highlight mt-4 flex items-center gap-1.5 border-t pt-4 font-mono text-[11px]">
<Icon name="done" className="text-sm" />
<span>{footer}</span>
</div>
)}
</Card>
);
}
+104
View File
@@ -0,0 +1,104 @@
import type {
InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
TextareaHTMLAttributes,
} from 'react';
import Icon from '@/components/site/icon';
import { cn } from '@/lib/utils';
const controlClasses =
'w-full rounded-xl border border-outline-variant/60 bg-surface-container-lowest px-4 py-2.5 text-sm text-on-surface transition-colors placeholder:text-on-surface-variant/50 focus:border-primary focus:outline-hidden';
type FieldProps = {
id: string;
label: string;
required?: boolean;
className?: string;
children: ReactNode;
};
export function Field({
id,
label,
required,
className,
children,
}: FieldProps) {
return (
<div className={cn('flex flex-col gap-1.5', className)}>
<label
htmlFor={id}
className="text-on-surface font-mono text-xs font-medium"
>
{label}
{required && ' *'}
</label>
{children}
</div>
);
}
export function TextInput({
className,
...props
}: InputHTMLAttributes<HTMLInputElement>) {
return <input className={cn(controlClasses, className)} {...props} />;
}
export function SelectInput({
options,
placeholder,
className,
...props
}: SelectHTMLAttributes<HTMLSelectElement> & {
options: ReadonlyArray<{ value: string; label: string }>;
placeholder?: string;
}) {
return (
<select
className={cn(controlClasses, 'cursor-pointer', className)}
defaultValue={placeholder ? '' : undefined}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
export function TextArea({
className,
...props
}: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea className={cn(controlClasses, className)} {...props} />;
}
export function FormSuccess({
title,
message,
}: {
title: string;
message: string;
}) {
return (
<div
role="status"
className="border-primary/40 bg-surface-container-lowest flex flex-col items-center gap-2 rounded-xl border p-6 text-center"
>
<Icon name="check_circle" className="text-primary text-4xl" />
<span className="font-display text-on-surface text-base font-bold">
{title}
</span>
<p className="text-on-surface-variant text-xs">{message}</p>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { cn } from '@/lib/utils';
/**
* Blurred crimson light used as section atmosphere. Position and size come
* from `className`; it is toned down in light mode so text stays crisp.
*/
export default function Glow({ className }: { className?: string }) {
return (
<div
aria-hidden="true"
className={cn(
'bg-paj-crimson/20 pointer-events-none absolute rounded-full opacity-40 blur-[130px] dark:opacity-100',
className,
)}
/>
);
}
/** Faint engineering grid pattern for hero backgrounds. */
export function GridPattern({ className }: { className?: string }) {
return (
<div
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-0 [background-image:linear-gradient(to_right,var(--site-on-surface)_1px,transparent_1px),linear-gradient(to_bottom,var(--site-on-surface)_1px,transparent_1px)] [background-size:40px_40px] opacity-[0.04]',
className,
)}
/>
);
}
@@ -0,0 +1,47 @@
import Icon from '@/components/site/icon';
import { cn } from '@/lib/utils';
const sizes = {
sm: 'h-10 w-10 rounded-xl text-xl',
md: 'h-12 w-12 rounded-xl text-2xl',
lg: 'h-14 w-14 rounded-2xl text-3xl',
} as const;
const tones = {
soft: 'border border-primary/30 bg-primary-container/10 text-primary dark:bg-primary-container/20',
solid: 'bg-primary-container text-white',
neutral:
'border border-outline-variant/50 bg-surface-container-high text-primary',
} as const;
type IconTileProps = {
icon: string;
size?: keyof typeof sizes;
tone?: keyof typeof tones;
/** Fill with crimson when the parent `group` is hovered. */
groupHover?: boolean;
className?: string;
};
export default function IconTile({
icon,
size = 'sm',
tone = 'soft',
groupHover = false,
className,
}: IconTileProps) {
return (
<div
className={cn(
'flex shrink-0 items-center justify-center transition-all',
sizes[size],
tones[tone],
groupHover &&
'group-hover:bg-primary-container group-hover:text-white',
className,
)}
>
<Icon name={icon} />
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { cn } from '@/lib/utils';
type IconProps = {
/** Material Symbols ligature name, e.g. `call` or `arrow_forward`. */
name: string;
filled?: boolean;
/** Size and color utilities, e.g. `text-xl text-primary`. */
className?: string;
};
/**
* Google's Material Symbols stylesheet is unlayered and fixes the glyph at
* 24px, which beats Tailwind's layered utilities. The glyph therefore
* inherits its size from this wrapper, where utilities apply normally.
*/
export default function Icon({ name, filled = false, className }: IconProps) {
return (
<span
aria-hidden="true"
className={cn(
'inline-flex shrink-0 leading-none select-none',
className,
)}
>
<span
className="material-symbols-outlined"
style={{
fontSize: 'inherit',
fontVariationSettings: filled ? "'FILL' 1" : undefined,
}}
>
{name}
</span>
</span>
);
}
@@ -0,0 +1,53 @@
import { cn } from '@/lib/utils';
type ImageCardProps = {
src: string;
alt: string;
eyebrow: string;
title: string;
description?: string;
className?: string;
};
/**
* Photo card with a caption over a dark scrim. The scrim stays dark in both
* themes because the photography itself is dark.
*/
export default function ImageCard({
src,
alt,
eyebrow,
title,
description,
className,
}: ImageCardProps) {
return (
<figure
className={cn(
'group relative flex min-h-72 flex-col justify-end overflow-hidden rounded-2xl bg-neutral-900 p-6 shadow-md',
className,
)}
>
<img
src={src}
alt={alt}
loading="lazy"
className="absolute inset-0 h-full w-full object-cover brightness-75 transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent" />
<figcaption className="relative flex flex-col gap-1">
<span className="font-mono text-[11px] font-bold tracking-wider text-rose-300 uppercase">
{eyebrow}
</span>
<span className="font-display text-lg font-bold text-white">
{title}
</span>
{description && (
<span className="text-sm text-neutral-300">
{description}
</span>
)}
</figcaption>
</figure>
);
}
@@ -0,0 +1,80 @@
import type { ReactNode } from 'react';
import Glow, { GridPattern } from '@/components/site/glow';
import Pill from '@/components/site/pill';
import Section from '@/components/site/section';
import { cn } from '@/lib/utils';
type PageHeroProps = {
badge: string;
title: ReactNode;
description: ReactNode;
align?: 'left' | 'center';
/** Extra content below the lead paragraph (actions, stats, media). */
children?: ReactNode;
className?: string;
};
export default function PageHero({
badge,
title,
description,
align = 'left',
children,
className,
}: PageHeroProps) {
return (
<Section
tone="muted"
className={className}
background={
<>
<GridPattern />
<Glow className="-top-40 -left-20 h-[500px] w-[640px]" />
<Glow className="bg-primary/15 top-1/4 -right-20 h-[480px] w-[520px]" />
</>
}
>
<div
className={cn(
'flex flex-col gap-10',
align === 'center' && 'items-center text-center',
)}
>
<div
className={cn(
'flex max-w-4xl flex-col gap-5',
align === 'center' && 'items-center',
)}
>
<Pill
pulse
className={cn(align === 'center' && 'self-center')}
>
{badge}
</Pill>
<h1 className="font-display text-on-surface lg:text-display-lg text-4xl leading-[1.12] font-extrabold tracking-tight sm:text-5xl">
{title}
</h1>
<p className="text-on-surface-variant max-w-3xl text-base leading-relaxed lg:text-lg">
{description}
</p>
</div>
{children}
</div>
</Section>
);
}
/** Crimson gradient emphasis for a phrase inside a hero title. */
export function Highlight({ children }: { children: ReactNode }) {
return (
<span className="from-primary-container via-primary dark:from-primary bg-gradient-to-r to-rose-500 bg-clip-text text-transparent dark:via-rose-300 dark:to-rose-400">
{children}
</span>
);
}
/** Inline emphasis inside body copy. */
export function Strong({ children }: { children: ReactNode }) {
return <span className="text-on-surface font-semibold">{children}</span>;
}
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react';
import Icon from '@/components/site/icon';
import { cn } from '@/lib/utils';
const tones = {
neutral:
'border-outline-variant/60 bg-surface-container-high text-on-surface-variant',
brand: 'border-primary/40 bg-primary-container/10 text-primary dark:bg-primary-container/25 dark:text-highlight',
} as const;
type PillProps = {
tone?: keyof typeof tones;
icon?: string;
/** Show a pulsing status dot instead of an icon. */
pulse?: boolean;
className?: string;
children: ReactNode;
};
export default function Pill({
tone = 'neutral',
icon,
pulse = false,
className,
children,
}: PillProps) {
return (
<div
className={cn(
'inline-flex items-center gap-2 self-start rounded-full border px-3.5 py-1.5 font-mono text-xs font-semibold',
tones[tone],
className,
)}
>
{pulse && (
<span className="relative flex h-2.5 w-2.5">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
</span>
)}
{icon && <Icon name={icon} className="text-primary text-base" />}
<span>{children}</span>
</div>
);
}
@@ -0,0 +1,77 @@
import type { ReactNode } from 'react';
import Eyebrow from '@/components/site/eyebrow';
import { cn } from '@/lib/utils';
type SectionHeaderProps = {
eyebrow?: ReactNode;
eyebrowIcon?: string;
title: ReactNode;
description?: ReactNode;
/**
* `left`: stacked, `center`: centered, `split`: title on the left and the
* description (or `aside`) aligned to the bottom right.
*/
align?: 'left' | 'center' | 'split';
aside?: ReactNode;
className?: string;
};
export default function SectionHeader({
eyebrow,
eyebrowIcon,
title,
description,
align = 'left',
aside,
className,
}: SectionHeaderProps) {
const heading = (
<div
className={cn(
'flex flex-col gap-3',
align === 'center' && 'items-center text-center',
align === 'split' ? 'max-w-2xl' : 'max-w-3xl',
)}
>
{eyebrow && <Eyebrow icon={eyebrowIcon}>{eyebrow}</Eyebrow>}
<h2 className="font-display text-on-surface lg:text-headline-xl text-3xl font-extrabold tracking-tight">
{title}
</h2>
{description && align !== 'split' && (
<p className="text-on-surface-variant text-base leading-relaxed">
{description}
</p>
)}
</div>
);
if (align === 'split') {
return (
<div
className={cn(
'flex flex-col justify-between gap-6 md:flex-row md:items-end',
className,
)}
>
{heading}
{aside ??
(description && (
<p className="text-on-surface-variant max-w-md text-sm leading-relaxed">
{description}
</p>
))}
</div>
);
}
return (
<div
className={cn(
align === 'center' && 'mx-auto flex justify-center',
className,
)}
>
{heading}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
import Container from '@/components/site/container';
import { cn } from '@/lib/utils';
const tones = {
base: 'bg-surface',
muted: 'bg-surface-container-lowest',
raised: 'bg-surface-container-high/50',
} as const;
type SectionProps = {
id?: string;
/** Alternate tones between consecutive sections to separate them. */
tone?: keyof typeof tones;
size?: 'default' | 'narrow';
/** Decorative layers (e.g. <Glow />) rendered behind the content. */
background?: ReactNode;
className?: string;
containerClassName?: string;
children: ReactNode;
};
export default function Section({
id,
tone = 'base',
size,
background,
className,
containerClassName,
children,
}: SectionProps) {
return (
<section
id={id}
className={cn(
'border-outline-variant/30 relative w-full overflow-hidden border-b py-16 lg:py-24',
tones[tone],
className,
)}
>
{background}
<Container size={size} className={containerClassName}>
{children}
</Container>
</section>
);
}
@@ -0,0 +1,91 @@
import ConsultationForm from '@/components/site/consultation-form';
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, offices } from '@/data/site';
type ConsultationSectionProps = {
id: string;
title: string;
description: string;
};
/** Closing call-to-action with contact details and the quick form. */
export default function ConsultationSection({
id,
title,
description,
}: ConsultationSectionProps) {
const contacts = offices.map((office) => ({
icon: office.icon,
title: office.name,
lines: [
office.building,
`${office.street}, ${office.area}`,
office.city,
`Telp: ${office.phone.label}`,
],
}));
return (
<Section
id={id}
background={
<Glow className="top-1/2 left-1/2 h-[520px] w-[850px] -translate-x-1/2 -translate-y-1/2" />
}
>
<div className="border-outline-variant/60 bg-surface-container rounded-3xl border p-6 shadow-2xl sm:p-8 lg:p-12">
<div className="grid grid-cols-1 items-center gap-10 lg:grid-cols-12">
<div className="flex flex-col gap-6 lg:col-span-7">
<Pill tone="brand" icon="verified_user">
KONSULTASI TEKNIS &amp; AUDIT INFRASTRUKTUR
</Pill>
<div className="flex flex-col gap-3">
<h2 className="font-display text-on-surface lg:text-headline-xl text-3xl leading-tight font-extrabold tracking-tight">
{title}
</h2>
<p className="text-on-surface-variant text-sm leading-relaxed lg:text-base">
{description}
</p>
</div>
<div className="text-on-surface-variant grid grid-cols-1 gap-4 pt-2 text-xs sm:grid-cols-2">
{contacts.map((contact) => (
<div
key={contact.title}
className="border-outline-variant/40 bg-surface-container-high/60 flex items-start gap-2.5 rounded-xl border p-3"
>
<Icon
name={contact.icon}
className="text-primary mt-0.5 text-xl"
/>
<div className="flex flex-col gap-0.5">
<span className="text-on-surface font-bold">
{contact.title}
</span>
{contact.lines.map((line) => (
<span key={line}>{line}</span>
))}
</div>
</div>
))}
</div>
<a
href={`mailto:${company.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}
</a>
</div>
<div className="w-full lg:col-span-5">
<ConsultationForm idPrefix={id} />
</div>
</div>
</div>
</Section>
);
}
@@ -0,0 +1,72 @@
import type { ReactNode } from 'react';
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';
type CtaBannerProps = {
badge: string;
title: string;
description: string;
/** Usually one primary and one secondary <ButtonLink />. */
actions: ReactNode;
showContacts?: boolean;
};
/** Centered call-to-action card used to close informational pages. */
export default function CtaBanner({
badge,
title,
description,
actions,
showContacts = true,
}: CtaBannerProps) {
const contacts = [
{ icon: 'call', label: company.phone.label },
{ icon: 'mail', label: company.email.busdev },
{ icon: 'location_on', label: 'Bintaro & Plaza Simatupang, Jakarta' },
];
return (
<Section
size="narrow"
background={
<Glow className="-top-32 left-1/2 h-64 w-[48rem] -translate-x-1/2" />
}
>
<div className="border-outline-variant/50 from-surface-container to-surface-container-low flex flex-col items-center gap-6 rounded-3xl border bg-gradient-to-b p-8 text-center shadow-xl md:p-14">
<Pill tone="brand" icon="verified_user" className="self-center">
{badge}
</Pill>
<div className="flex max-w-2xl flex-col gap-3">
<h2 className="font-display text-on-surface lg:text-headline-xl text-3xl font-extrabold tracking-tight">
{title}
</h2>
<p className="text-on-surface-variant text-base leading-relaxed">
{description}
</p>
</div>
<div className="flex w-full flex-col items-center gap-4 sm:w-auto sm:flex-row">
{actions}
</div>
{showContacts && (
<div className="text-on-surface-variant flex flex-wrap items-center justify-center gap-x-6 gap-y-2 pt-2 text-sm">
{contacts.map((contact) => (
<span
key={contact.icon}
className="flex items-center gap-1.5"
>
<Icon
name={contact.icon}
className="text-primary text-base"
/>
{contact.label}
</span>
))}
</div>
)}
</div>
</Section>
);
}
@@ -0,0 +1,38 @@
import FeatureCard from '@/components/site/feature-card';
import { officialServices } from '@/data/site';
type ServiceGridProps = {
/** `summary` for overviews, `description` for the official wording. */
detail?: 'summary' | 'description';
};
/** The eight official PAJ services as a 4-column card grid. */
export default function ServiceGrid({ detail = 'summary' }: ServiceGridProps) {
return (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-4">
{officialServices.map((service, index) => {
const number = String(index + 1).padStart(2, '0');
return (
<FeatureCard
key={service.title}
icon={service.icon}
tag={`${number} / JASA RESMI`}
title={`${index + 1}. ${service.title}`}
description={
<>
{service.expansion && (
<span className="text-on-surface font-semibold">
{service.expansion}:{' '}
</span>
)}
{service[detail]}
</>
}
footer={service.outcome}
/>
);
})}
</div>
);
}
@@ -0,0 +1,137 @@
import { Link } from '@inertiajs/react';
import Container from '@/components/site/container';
import Icon from '@/components/site/icon';
import {
company,
consultationUrl,
officeAddress,
offices,
officialServices,
} from '@/data/site';
import { about, clients, contact, services } from '@/routes';
const companyLinks = [
{ label: 'Tentang Kami', href: about.url() },
{ label: 'Visi & Misi Resmi', href: `${about.url()}#visi-misi` },
{ label: 'Solusi Odoo ERP', href: `${services.url()}#odoo-erp` },
{ label: 'Daftar Klien Industri', href: clients.url() },
{ label: 'Permintaan Audit Sistem', href: consultationUrl },
{ label: 'Lokasi Kantor PAJ', href: `${contact.url()}#lokasi` },
];
function FooterHeading({ children }: { children: string }) {
return (
<span className="text-on-surface font-mono text-xs font-bold tracking-wider uppercase">
{children}
</span>
);
}
export default function SiteFooter() {
return (
<footer className="border-outline-variant/40 bg-surface-container-lowest w-full border-t py-16">
<Container>
<div className="border-outline-variant/30 grid grid-cols-1 gap-10 border-b pb-12 md:grid-cols-2 lg:grid-cols-12">
<div className="flex flex-col gap-4 lg:col-span-5">
<div className="flex items-center gap-3">
<div className="border-outline-variant/50 dark:bg-surface-container-low rounded-lg border bg-white p-1">
<img
src={company.logo}
alt={company.name}
className="h-7 w-auto object-contain"
/>
</div>
<span className="font-display text-on-surface text-base font-bold tracking-wide uppercase">
{company.name}
</span>
</div>
<p className="text-on-surface-variant max-w-md text-xs leading-relaxed">
{company.tagline}
</p>
<div className="text-on-surface-variant mt-2 flex flex-col gap-4 text-xs">
{offices.map((office) => (
<div
key={office.name}
className="flex items-start gap-2"
>
<Icon
name={office.icon}
className="text-primary mt-0.5 text-base"
/>
<div className="flex flex-col gap-0.5">
<span className="text-on-surface font-semibold">
{office.name}
</span>
<span>{officeAddress(office)}</span>
<a
href={office.phone.href}
className="hover:text-primary font-mono transition-colors"
>
Tel. {office.phone.label}
</a>
</div>
</div>
))}
<a
href={`mailto:${company.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}
</a>
</div>
</div>
<div className="flex flex-col gap-3 lg:col-span-4">
<FooterHeading>8 Jasa Resmi PAJ</FooterHeading>
<ul className="text-on-surface-variant grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
{officialServices.map((service, index) => (
<li key={service.title}>
<Link
href={`${services.url()}#jasa-resmi`}
className="hover:text-primary transition-colors"
>
{index + 1}. {service.title}
</Link>
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-3 lg:col-span-3">
<FooterHeading>Informasi &amp; Tautan</FooterHeading>
<ul className="text-on-surface-variant flex flex-col gap-2 text-xs">
{companyLinks.map((link) => (
<li key={link.label}>
<Link
href={link.href}
className="hover:text-primary transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
</div>
<div className="text-on-surface-variant flex flex-col items-center justify-between gap-4 pt-8 text-xs md:flex-row">
<p>
© {new Date().getFullYear()} {company.name}. Hak Cipta
Dilindungi Undang-Undang.
</p>
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-1 font-mono text-[11px]">
<span>
{company.notary} ({company.founded})
</span>
<span className="text-primary">•</span>
<span>Jakarta Selatan, Indonesia</span>
</div>
</div>
</Container>
</footer>
);
}
@@ -0,0 +1,120 @@
import { Link } from '@inertiajs/react';
import { useState } from 'react';
import Brand from '@/components/site/brand';
import { ButtonLink } from '@/components/site/button';
import Container from '@/components/site/container';
import Icon from '@/components/site/icon';
import ThemeToggle from '@/components/site/theme-toggle';
import { company, consultationUrl, navigation, offices } from '@/data/site';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { cn } from '@/lib/utils';
export default function SiteHeader() {
const { isCurrentUrl } = useCurrentUrl();
const [menuOpen, setMenuOpen] = useState(false);
const closeMenu = () => setMenuOpen(false);
return (
<header className="border-outline-variant/40 bg-surface/85 fixed inset-x-0 top-0 z-50 border-b backdrop-blur-md">
<Container className="flex h-20 items-center justify-between gap-4">
<Brand />
<nav
aria-label="Navigasi utama"
className="hidden items-center gap-8 lg:flex"
>
{navigation.map((item) => (
<Link
key={item.label}
href={item.href}
className={cn(
'hover:border-primary hover:text-on-surface border-b-2 pb-1 text-sm font-medium transition-all',
isCurrentUrl(item.href)
? 'border-primary text-on-surface font-semibold'
: 'text-on-surface-variant border-transparent',
)}
>
{item.label}
</Link>
))}
</nav>
<div className="flex items-center 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}
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}
</a>
<a
href={`mailto:${company.email.busdev}`}
className="hover:text-primary"
>
{company.email.busdev}
</a>
</div>
<ThemeToggle />
<ButtonLink
href={consultationUrl}
size="sm"
icon="support_agent"
className="hidden sm:inline-flex"
>
Konsultasi IT
</ButtonLink>
<button
type="button"
aria-label={menuOpen ? 'Tutup menu' : 'Buka menu'}
aria-expanded={menuOpen}
onClick={() => setMenuOpen((open) => !open)}
className="border-outline-variant/50 bg-surface-container-low text-on-surface inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-xl border lg:hidden"
>
<Icon
name={menuOpen ? 'close' : 'menu'}
className="text-2xl"
/>
</button>
</div>
</Container>
{menuOpen && (
<nav
aria-label="Navigasi utama"
className="border-outline-variant/40 bg-surface border-t lg:hidden"
>
<Container className="flex flex-col gap-1 py-4">
{navigation.map((item) => (
<Link
key={item.label}
href={item.href}
onClick={closeMenu}
className={cn(
'rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
isCurrentUrl(item.href)
? 'bg-surface-container-high text-on-surface'
: 'text-on-surface-variant hover:bg-surface-container hover:text-on-surface',
)}
>
{item.label}
</Link>
))}
<ButtonLink
href={consultationUrl}
size="sm"
icon="support_agent"
onClick={closeMenu}
className="mt-2 sm:hidden"
>
Konsultasi IT
</ButtonLink>
</Container>
</nav>
)}
</header>
);
}
@@ -0,0 +1,51 @@
import Card from '@/components/site/card';
import { cn } from '@/lib/utils';
type StatCardProps = {
label?: string;
value: string;
unit?: string;
description?: string;
align?: 'left' | 'center';
className?: string;
};
export default function StatCard({
label,
value,
unit,
description,
align = 'left',
className,
}: StatCardProps) {
return (
<Card
className={cn(
'flex flex-col justify-between gap-3',
align === 'center' && 'items-center text-center',
className,
)}
>
{label && (
<span className="text-primary font-mono text-xs font-semibold tracking-wider uppercase">
{label}
</span>
)}
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="font-display text-on-surface text-4xl font-extrabold">
{value}
</span>
{unit && (
<span className="font-display text-primary text-xl font-bold">
{unit}
</span>
)}
</div>
{description && (
<p className="text-on-surface-variant text-xs leading-relaxed">
{description}
</p>
)}
</Card>
);
}
@@ -0,0 +1,56 @@
import Card from '@/components/site/card';
import Icon from '@/components/site/icon';
type StepCardProps = {
step: string;
title: string;
description: string;
icon?: string;
/** Duration or phase label shown next to the number. */
badge?: string;
footer?: { icon: string; label: string };
};
export default function StepCard({
step,
title,
description,
icon,
badge,
footer,
}: StepCardProps) {
return (
<Card interactive className="flex flex-col justify-between">
<div>
<div className="flex items-center justify-between pb-5">
<span className="font-display text-headline-xl text-outline-variant group-hover:text-primary font-extrabold transition-colors">
{step}
</span>
{badge && (
<span className="bg-surface-container text-primary rounded px-2.5 py-1 font-mono text-[11px] font-semibold">
{badge}
</span>
)}
{icon && !badge && (
<Icon
name={icon}
className="text-on-surface-variant text-2xl"
/>
)}
</div>
<h3 className="font-display text-on-surface pb-2 text-lg font-bold">
{title}
</h3>
<p className="text-on-surface-variant text-sm leading-relaxed">
{description}
</p>
</div>
{footer && (
<div className="border-outline-variant/30 text-on-surface-variant mt-6 flex items-center gap-2 border-t pt-5 font-mono text-[11px] uppercase">
<Icon name={footer.icon} className="text-primary text-sm" />
{footer.label}
</div>
)}
</Card>
);
}
@@ -0,0 +1,28 @@
import Icon from '@/components/site/icon';
import { useAppearance } from '@/hooks/use-appearance';
import { cn } from '@/lib/utils';
/** Switches the whole app between light and dark mode. */
export default function ThemeToggle({ className }: { className?: string }) {
const { resolvedAppearance, updateAppearance } = useAppearance();
const isDark = resolvedAppearance === 'dark';
const label = isDark ? 'Ganti ke mode terang' : 'Ganti ke mode gelap';
return (
<button
type="button"
onClick={() => updateAppearance(isDark ? 'light' : 'dark')}
aria-label={label}
title={label}
className={cn(
'border-outline-variant/50 bg-surface-container-low text-on-surface hover:border-primary/60 hover:text-primary inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-xl border transition-colors',
className,
)}
>
<Icon
name={isDark ? 'light_mode' : 'dark_mode'}
className="text-xl"
/>
</button>
);
}