@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user