feat: unify tenant admin ui and add photo moderation
This commit is contained in:
@@ -1,15 +1,6 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
ADMIN_HOME_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
ADMIN_ENGAGEMENT_PATH,
|
||||
} from '../constants';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
CalendarDays,
|
||||
@@ -17,6 +8,15 @@ import {
|
||||
CreditCard,
|
||||
Settings as SettingsIcon,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ADMIN_HOME_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
ADMIN_ENGAGEMENT_PATH,
|
||||
} from '../constants';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { registerApiErrorListener } from '../lib/apiError';
|
||||
import { getDashboardSummary, getEvents, getTenantPackagesOverview } from '../api';
|
||||
@@ -92,27 +92,29 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-svh overflow-hidden bg-slate-950 text-white">
|
||||
<div className="relative min-h-svh overflow-hidden bg-gradient-to-br from-rose-50 via-white to-slate-50 text-slate-900 dark:bg-slate-950 dark:text-white">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.28),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.25),_transparent_65%)]"
|
||||
className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_10%_10%,rgba(255,137,170,0.25),transparent_55%),radial-gradient(circle_at_80%_0%,rgba(96,165,250,0.22),transparent_60%)] opacity-60 dark:opacity-30"
|
||||
/>
|
||||
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900/60 to-[#1d1130]" />
|
||||
<div aria-hidden className="absolute inset-0 bg-gradient-to-b from-white/80 via-white/60 to-transparent dark:from-slate-950 dark:via-slate-950/90 dark:to-slate-950/80" />
|
||||
<div className="relative z-10 flex min-h-svh flex-col">
|
||||
<header className="sticky top-0 z-30 px-4 pt-6 sm:px-6">
|
||||
<div className="mx-auto w-full max-w-6xl rounded-3xl border border-white/15 bg-white/85 text-slate-900 shadow-2xl shadow-rose-400/10 backdrop-blur-xl">
|
||||
<div className="flex flex-col gap-6 px-6 py-6 md:flex-row md:items-center md:justify-between">
|
||||
<header className="sticky top-0 z-40 border-b border-slate-200/70 bg-white/90 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/80">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.4em] text-rose-500 dark:text-rose-200">{t('app.brand')}</p>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-rose-400">{t('app.brand')}</p>
|
||||
<h1 className="font-display text-3xl font-semibold text-slate-900">{title}</h1>
|
||||
{subtitle ? <p className="mt-1 text-sm text-slate-600">{subtitle}</p> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
{actions}
|
||||
<h1 className="text-xl font-semibold text-slate-900 dark:text-white sm:text-2xl">{title}</h1>
|
||||
{subtitle ? <p className="text-xs text-slate-600 dark:text-slate-300 sm:text-sm">{subtitle}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="hidden items-center gap-2 border-t border-slate-200/70 px-6 py-4 md:flex">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{actions}
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
<nav className="hidden border-t border-slate-200/60 dark:border-white/5 sm:block">
|
||||
<div className="mx-auto flex w-full max-w-6xl items-center gap-1 px-4 py-2 sm:px-6">
|
||||
{navItems.map(({ to, labelKey, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
@@ -123,10 +125,10 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
onTouchStart={() => triggerPrefetch(to)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950',
|
||||
'flex items-center gap-2 rounded-2xl px-3 py-2 text-xs font-semibold uppercase tracking-wide transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60',
|
||||
isActive
|
||||
? 'bg-rose-600 text-white shadow-md shadow-rose-400/30'
|
||||
: 'border border-slate-200/80 bg-white text-slate-700 hover:bg-rose-50/80 hover:text-rose-700'
|
||||
? 'bg-rose-600 text-white shadow shadow-rose-300/40'
|
||||
: 'text-slate-500 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white'
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -134,28 +136,34 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
{t(labelKey)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="relative z-10 mx-auto w-full max-w-6xl flex-1 px-4 pb-[calc(env(safe-area-inset-bottom,0)+6.5rem)] pt-6 sm:px-6 md:pb-16">
|
||||
<div className="grid gap-6">{children}</div>
|
||||
<main className="relative z-10 mx-auto w-full max-w-5xl flex-1 px-4 pb-[calc(env(safe-area-inset-bottom,0)+5.5rem)] pt-5 sm:px-6 md:pb-16">
|
||||
<div className="space-y-5">{children}</div>
|
||||
</main>
|
||||
|
||||
<TenantMobileNav items={navItems} />
|
||||
<TenantMobileNav items={navItems} onPrefetch={triggerPrefetch} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TenantMobileNav({ items }: { items: typeof navItems }) {
|
||||
function TenantMobileNav({
|
||||
items,
|
||||
onPrefetch,
|
||||
}: {
|
||||
items: typeof navItems;
|
||||
onPrefetch: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
return (
|
||||
<nav className="md:hidden" aria-label={t('navigation.mobile', { defaultValue: 'Tenant Navigation' })}>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-x-0 bottom-0 z-30 h-16 bg-gradient-to-t from-slate-950/35 via-transparent to-transparent dark:from-black/60"
|
||||
className="pointer-events-none fixed inset-x-0 bottom-0 z-30 h-12 bg-gradient-to-t from-white via-white/40 to-transparent dark:from-slate-950 dark:via-slate-950/60 dark:to-transparent"
|
||||
/>
|
||||
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200/80 bg-white/95 px-4 pb-[calc(env(safe-area-inset-bottom,0)+0.75rem)] pt-3 shadow-2xl shadow-rose-300/15 backdrop-blur supports-[backdrop-filter]:bg-white/90 dark:border-slate-800/70 dark:bg-slate-950/90">
|
||||
<div className="mx-auto flex max-w-xl items-center justify-around gap-1">
|
||||
@@ -164,9 +172,9 @@ function TenantMobileNav({ items }: { items: typeof navItems }) {
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
onPointerEnter={() => triggerPrefetch(to)}
|
||||
onFocus={() => triggerPrefetch(to)}
|
||||
onTouchStart={() => triggerPrefetch(to)}
|
||||
onPointerEnter={() => onPrefetch(to)}
|
||||
onFocus={() => onPrefetch(to)}
|
||||
onTouchStart={() => onPrefetch(to)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex flex-col items-center gap-1 rounded-xl px-3 py-2 text-xs font-semibold text-slate-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:focus-visible:ring-offset-slate-950',
|
||||
|
||||
38
resources/js/admin/components/tenant/action-grid.tsx
Normal file
38
resources/js/admin/components/tenant/action-grid.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ActionItem {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ActionGridProps {
|
||||
items: ActionItem[];
|
||||
columns?: 1 | 2;
|
||||
}
|
||||
|
||||
export function ActionGrid({ items, columns = 2 }: ActionGridProps) {
|
||||
return (
|
||||
<div className={cn('grid gap-3', columns === 2 ? 'sm:grid-cols-2' : '')}>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
className="group flex flex-col gap-1.5 rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm transition duration-200 hover:-translate-y-0.5 hover:border-rose-200 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-rose-300 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{item.icon ? <span className="text-rose-500 dark:text-rose-200">{item.icon}</span> : null}
|
||||
{item.label}
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">{item.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Card } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const frostedCardClass = cn(
|
||||
'border border-white/15 bg-white/92 text-slate-900 shadow-lg shadow-rose-400/10 backdrop-blur-lg',
|
||||
'border border-slate-200 bg-white text-slate-900 shadow-lg shadow-rose-100/30 backdrop-blur',
|
||||
'dark:border-slate-800/70 dark:bg-slate-950/85 dark:text-slate-100'
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ export function FrostedSurface({ className, ...props }: FrostedSurfaceProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border border-white/15 bg-white/88 text-slate-900 shadow-lg shadow-rose-300/10 backdrop-blur-lg',
|
||||
'rounded-2xl border border-slate-200 bg-white text-slate-900 shadow-lg shadow-rose-100/30 backdrop-blur',
|
||||
'dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -29,31 +29,27 @@ export function TenantHeroCard({
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden border border-slate-200/80 bg-white text-slate-900 shadow-xl shadow-rose-200/30 backdrop-blur',
|
||||
'dark:border-white/10 dark:bg-slate-950/90 dark:text-slate-100',
|
||||
'relative overflow-hidden border border-slate-200 bg-white text-slate-900 shadow-lg shadow-rose-100/50 backdrop-blur dark:border-white/10 dark:bg-slate-900/80 dark:text-slate-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.18),_transparent_55%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.16),_transparent_65%)] motion-safe:animate-[aurora_18s_ease-in-out_infinite] dark:hidden"
|
||||
/>
|
||||
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-rose-50/70 via-white to-sky-50/70 dark:hidden" />
|
||||
<div aria-hidden className="absolute inset-0 hidden bg-gradient-to-br from-slate-950/80 via-slate-900/20 to-transparent mix-blend-overlay dark:block" />
|
||||
|
||||
<CardContent className="relative z-10 flex flex-col gap-8 px-6 py-8 lg:flex-row lg:items-start lg:justify-between lg:px-10 lg:py-12">
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<CardContent className="relative z-10 flex flex-col gap-6 px-5 py-6 sm:px-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-4">
|
||||
{badge ? (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-rose-100 bg-rose-50/80 px-4 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-rose-600 dark:border-white/30 dark:bg-white/15 dark:text-white">
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-rose-100/80 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.25em] text-rose-600 dark:bg-white/15 dark:text-white">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3 text-slate-700 dark:text-slate-100">
|
||||
<h1 className="font-display text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">{title}</h1>
|
||||
{description ? <p className="text-sm text-slate-600 dark:text-white/75 sm:text-base">{description}</p> : null}
|
||||
<div className="space-y-2 text-slate-700 dark:text-slate-100">
|
||||
<h1 className="font-display text-2xl font-semibold leading-tight text-slate-900 dark:text-white sm:text-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="text-sm text-slate-600 dark:text-white/80">{description}</p>
|
||||
) : null}
|
||||
{supporting?.map((paragraph) => (
|
||||
<p key={paragraph} className="text-sm text-slate-600 dark:text-white/75 sm:text-base">
|
||||
<p key={paragraph} className="text-sm text-slate-600 dark:text-white/70">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
@@ -61,14 +57,18 @@ export function TenantHeroCard({
|
||||
</div>
|
||||
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{primaryAction}
|
||||
{secondaryAction}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{aside ? <div className="w-full max-w-sm">{aside}</div> : null}
|
||||
{aside ? (
|
||||
<div className="rounded-2xl border border-slate-200/70 bg-white/90 p-4 text-sm dark:border-white/10 dark:bg-white/5">
|
||||
{aside}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -3,3 +3,6 @@ export { FrostedCard, FrostedSurface, frostedCardClass } from './frosted-surface
|
||||
export { ChecklistRow } from './checklist-row';
|
||||
export type { ChecklistStep } from './onboarding-checklist-card';
|
||||
export { TenantOnboardingChecklistCard } from './onboarding-checklist-card';
|
||||
export { SectionCard, SectionHeader } from './section-card';
|
||||
export { StatCarousel } from './stat-carousel';
|
||||
export { ActionGrid } from './action-grid';
|
||||
|
||||
41
resources/js/admin/components/tenant/section-card.tsx
Normal file
41
resources/js/admin/components/tenant/section-card.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SectionCardProps extends React.HTMLAttributes<HTMLElement> {
|
||||
as?: 'section' | 'div';
|
||||
}
|
||||
|
||||
export function SectionCard({ className, as: Tag = 'section', ...props }: SectionCardProps) {
|
||||
return (
|
||||
<Tag
|
||||
className={cn(
|
||||
'rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5 dark:shadow-inner',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
endSlot?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({ eyebrow, title, description, endSlot, className }: SectionHeaderProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between', className)}>
|
||||
<div>
|
||||
{eyebrow ? (
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">{eyebrow}</p>
|
||||
) : null}
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">{title}</h2>
|
||||
{description ? <p className="text-sm text-slate-600 dark:text-slate-300">{description}</p> : null}
|
||||
</div>
|
||||
{endSlot}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
resources/js/admin/components/tenant/stat-carousel.tsx
Normal file
35
resources/js/admin/components/tenant/stat-carousel.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StatItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon?: React.ReactNode;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
interface StatCarouselProps {
|
||||
items: StatItem[];
|
||||
}
|
||||
|
||||
export function StatCarousel({ items }: StatCarouselProps) {
|
||||
return (
|
||||
<div className="-mx-4 flex snap-x snap-mandatory gap-3 overflow-x-auto px-4 pb-2 sm:mx-0 sm:grid sm:snap-none sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className="min-w-[70%] snap-center sm:min-w-0">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{item.label}</span>
|
||||
{item.icon ? <span className="text-rose-500 dark:text-rose-200">{item.icon}</span> : null}
|
||||
</div>
|
||||
<div className="mt-3 text-2xl font-semibold text-slate-900 dark:text-white">{item.value}</div>
|
||||
{item.hint ? (
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{item.hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user