@@ -0,0 +1,23 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
|
||||
/** Light/dark switch for the admin panel header. */
|
||||
export function AdminThemeToggle() {
|
||||
const { resolvedAppearance, updateAppearance } = useAppearance();
|
||||
const isDark = resolvedAppearance === 'dark';
|
||||
const label = isDark ? 'Ganti ke mode terang' : 'Ganti ke mode gelap';
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={() => updateAppearance(isDark ? 'light' : 'dark')}
|
||||
className="size-9 cursor-pointer"
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Form } from '@inertiajs/react';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { RouteFormDefinition } from '@/wayfinder';
|
||||
|
||||
const controlClasses =
|
||||
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs outline-none focus-visible:ring-[3px] disabled:opacity-50 md:text-sm';
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="font-display text-xl font-bold tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
id,
|
||||
label,
|
||||
error,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
error?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('grid gap-2', className)}>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{children}
|
||||
<InputError message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NativeSelect({
|
||||
options,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'select'> & {
|
||||
options: ReadonlyArray<{ value: string; label: string }>;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
className={cn(controlClasses, 'h-9 cursor-pointer py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea({ className, ...props }: ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<textarea
|
||||
rows={4}
|
||||
className={cn(controlClasses, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type Errors = Partial<Record<string, string>>;
|
||||
|
||||
/**
|
||||
* A dialog holding a create/edit form; it closes itself once the server
|
||||
* accepts the submission.
|
||||
*/
|
||||
export function FormDialog({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
form,
|
||||
submitLabel = 'Simpan',
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
form: RouteFormDefinition<'get' | 'post'>;
|
||||
submitLabel?: string;
|
||||
className?: string;
|
||||
children: (errors: Errors) => ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'max-h-[90vh] overflow-y-auto sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && (
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<Form
|
||||
{...form}
|
||||
options={{ preserveScroll: true }}
|
||||
onSuccess={() => setOpen(false)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
{children(errors)}
|
||||
<DialogFooter className="gap-2">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
Batal
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={processing}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Asks for confirmation before sending a delete request. */
|
||||
export function ConfirmDelete({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
form,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
form: RouteFormDefinition<'get' | 'post'>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form
|
||||
{...form}
|
||||
options={{ preserveScroll: true }}
|
||||
onSuccess={() => setOpen(false)}
|
||||
>
|
||||
{({ processing }) => (
|
||||
<DialogFooter className="gap-2">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
Batal
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={processing}
|
||||
>
|
||||
Hapus
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
import { usePage } from '@inertiajs/react';
|
||||
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { company } from '@/data/site';
|
||||
|
||||
/** PAJ logo mark with the admin panel name, used in the sidebar header. */
|
||||
export default function AppLogo() {
|
||||
const { name } = usePage().props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md">
|
||||
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" />
|
||||
<div className="border-sidebar-border flex aspect-square size-8 items-center justify-center rounded-md border bg-white p-1">
|
||||
<img
|
||||
src={company.logo}
|
||||
alt={company.name}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-1 grid flex-1 text-left text-sm">
|
||||
<span className="mb-0.5 truncate leading-tight font-semibold">
|
||||
{name}
|
||||
<span className="font-display truncate leading-tight font-bold tracking-wide uppercase">
|
||||
{company.brand}
|
||||
</span>
|
||||
<span className="text-sidebar-foreground/60 truncate font-mono text-[11px]">
|
||||
Admin Panel
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AdminThemeToggle } from '@/components/admin-theme-toggle';
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
@@ -13,6 +14,9 @@ export function AppSidebarHeader({
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<AdminThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { BookOpen, FolderGit2, LayoutGrid } from 'lucide-react';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
Building2,
|
||||
FolderKanban,
|
||||
Globe,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
MessageSquareQuote,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import AppLogo from '@/components/app-logo';
|
||||
import { NavFooter } from '@/components/nav-footer';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
@@ -13,7 +21,12 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { dashboard } from '@/routes';
|
||||
import { dashboard, home } from '@/routes';
|
||||
import { edit as companyEdit } from '@/routes/admin/company';
|
||||
import { index as consultationsIndex } from '@/routes/admin/consultations';
|
||||
import { index as projectsIndex } from '@/routes/admin/projects';
|
||||
import { index as testimonialsIndex } from '@/routes/admin/testimonials';
|
||||
import { index as usersIndex } from '@/routes/admin/users';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
@@ -22,22 +35,50 @@ const mainNavItems: NavItem[] = [
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Inbox Konsultasi',
|
||||
href: consultationsIndex(),
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
title: 'Portofolio',
|
||||
href: projectsIndex(),
|
||||
icon: FolderKanban,
|
||||
},
|
||||
{
|
||||
title: 'Info Perusahaan',
|
||||
href: companyEdit(),
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
title: 'Testimoni',
|
||||
href: testimonialsIndex(),
|
||||
icon: MessageSquareQuote,
|
||||
},
|
||||
];
|
||||
|
||||
const adminNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Pengguna',
|
||||
href: usersIndex(),
|
||||
icon: Users,
|
||||
},
|
||||
];
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/react-starter-kit',
|
||||
icon: FolderGit2,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#react',
|
||||
icon: BookOpen,
|
||||
title: 'Lihat Website',
|
||||
href: home(),
|
||||
icon: Globe,
|
||||
},
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
const { can } = usePage().props;
|
||||
const navItems = can.manageUsers
|
||||
? [...mainNavItems, ...adminNavItems]
|
||||
: mainNavItems;
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<SidebarHeader>
|
||||
@@ -53,7 +94,7 @@ export function AppSidebar() {
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain items={mainNavItems} />
|
||||
<NavMain items={navItems} />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -12,8 +12,8 @@ export default function Heading({
|
||||
<h2
|
||||
className={
|
||||
variant === 'small'
|
||||
? 'mb-0.5 text-base font-medium'
|
||||
: 'text-xl font-semibold tracking-tight'
|
||||
? 'font-display mb-0.5 text-base font-semibold'
|
||||
: 'font-display text-xl font-bold tracking-tight'
|
||||
}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function NavFooter({
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-neutral-100"
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground"
|
||||
>
|
||||
<a
|
||||
href={toUrl(item.href)}
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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,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 />
|
||||
|
||||
Reference in New Issue
Block a user