Modernize mobile UI
- StatsTiles: on-brand compact tiles (token accents) vs generic gradients - RecordView: pill search, filter chips, elevated list cards w/ chevron - Bottom nav: active sunrise pill + elevation
This commit is contained in:
parent
73850d6e0a
commit
b85775a413
@ -1,60 +1,45 @@
|
||||
import { Card } from "./Card";
|
||||
import type { TileItem } from "../../api/types";
|
||||
|
||||
export interface StatsTilesProps {
|
||||
tiles?: TileItem[];
|
||||
}
|
||||
|
||||
const colors = [
|
||||
"from-blue-500 to-cyan-500",
|
||||
"from-emerald-500 to-green-500",
|
||||
"from-orange-500 to-amber-500",
|
||||
"from-violet-500 to-fuchsia-500",
|
||||
"from-pink-500 to-rose-500",
|
||||
"from-indigo-500 to-blue-500",
|
||||
const TONES = [
|
||||
"bg-sunrise-500",
|
||||
"bg-emerald-500",
|
||||
"bg-sky-500",
|
||||
"bg-amber-500",
|
||||
"bg-navy-600",
|
||||
"bg-ruby-500",
|
||||
];
|
||||
|
||||
export function StatsTiles({ tiles }: StatsTilesProps) {
|
||||
if (!tiles?.length) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-5 md:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
|
||||
{tiles.map((tile, idx) => {
|
||||
const displayLabel = (tile.key || `tile_${idx}`)
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const gradient = colors[idx % colors.length];
|
||||
|
||||
return (
|
||||
<Card
|
||||
<div
|
||||
key={tile.tile_uid || idx}
|
||||
className="group relative overflow-hidden rounded-2xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"
|
||||
className="relative overflow-hidden rounded-xl border border-border-subtle bg-card p-4 shadow-sm"
|
||||
>
|
||||
{/* Top Gradient */}
|
||||
<div
|
||||
className={`absolute left-0 top-0 h-1.5 w-full bg-gradient-to-r ${gradient}`}
|
||||
<span
|
||||
className={`absolute inset-x-0 top-0 h-1 ${TONES[idx % TONES.length]}`}
|
||||
/>
|
||||
|
||||
{/* Background Decoration */}
|
||||
<div
|
||||
className={`absolute -right-6 -top-6 h-24 w-24 rounded-full bg-gradient-to-br ${gradient} opacity-10 transition-all duration-300 group-hover:scale-125`}
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.15em] text-gray-500">
|
||||
{displayLabel}
|
||||
</p>
|
||||
|
||||
<h2 className="text-4xl font-bold tracking-tight text-gray-900">
|
||||
{(tile.value as React.ReactNode) ?? "-"}
|
||||
</h2>
|
||||
|
||||
<div className="h-1 w-12 rounded-full bg-gray-200 transition-all duration-300 group-hover:w-20 group-hover:bg-blue-500" />
|
||||
</div>
|
||||
</Card>
|
||||
<p className="text-2xs font-semibold uppercase tracking-[0.12em] text-muted">
|
||||
{displayLabel}
|
||||
</p>
|
||||
<p className="mt-2 font-numeric text-3xl font-bold leading-none tracking-tight text-strong nums">
|
||||
{(tile.value as React.ReactNode) ?? "-"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Search, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import type { ZinoClient } from '../../api/client';
|
||||
import type { RecordViewField, RecordViewResponse } from '../../api/types';
|
||||
import { Card } from '../reusable/Card';
|
||||
import { Pagination } from '../reusable/Pagination';
|
||||
import { Spinner } from '../reusable/Spinner';
|
||||
import { EmptyState } from '../reusable/EmptyState';
|
||||
@ -119,27 +118,37 @@ export function RecordView({
|
||||
|
||||
const chartData = resp?.chart_data;
|
||||
|
||||
const filterEntries = resp?.config?.filter_options
|
||||
? Object.entries(resp.config.filter_options).filter(([, o]) => o && o.length > 0)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
<StatsTiles tiles={tileValues} />
|
||||
|
||||
<AnalyticsChart data={chartData} />
|
||||
|
||||
<Card
|
||||
title={title}
|
||||
action={headerActions}
|
||||
pad={false}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center justify-between gap-3 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<div className="flex flex-col sm:flex-row flex-wrap gap-3 w-full sm:w-auto">
|
||||
{resp?.config?.filter_options && Object.entries(resp.config.filter_options).map(([key, options]) => {
|
||||
if (!options || options.length === 0) return null;
|
||||
|
||||
// Find the field in config to get its proper label
|
||||
const fieldDef = resp.config.fields.find(f => f.field_key === key);
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-bold tracking-tight text-strong">{title}</h2>
|
||||
{headerActions}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="relative focus-ring rounded-pill border border-border-subtle bg-card shadow-sm">
|
||||
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search…"
|
||||
className="w-full h-11 rounded-pill bg-transparent pl-11 pr-4 font-sans text-sm text-strong outline-none placeholder:text-faint"
|
||||
/>
|
||||
</div>
|
||||
{filterEntries.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto scrollbar-slim -mx-1 px-1 pb-1">
|
||||
{filterEntries.map(([key, options]) => {
|
||||
const fieldDef = resp?.config.fields.find(f => f.field_key === key);
|
||||
const label = fieldDef?.output_label || key;
|
||||
|
||||
const opts = options.map(o => ({ value: o, label: o }));
|
||||
const opts = options!.map(o => ({ value: o, label: o }));
|
||||
return (
|
||||
<Select
|
||||
key={key}
|
||||
@ -149,72 +158,70 @@ export function RecordView({
|
||||
setPage(1);
|
||||
}}
|
||||
options={[{ value: '', label: `All ${label}` }, ...opts]}
|
||||
className="w-full sm:w-40 shrink-0"
|
||||
className="w-40 shrink-0"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="relative w-full sm:w-[240px] shrink-0">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search…"
|
||||
className="w-full h-9 rounded-md border border-border-default bg-card pl-9 pr-3 font-sans text-sm text-strong outline-none focus-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<EmptyState title="Couldn’t load records" hint={error} />
|
||||
) : loading && !resp ? (
|
||||
<div className="p-10 flex justify-center">
|
||||
<Spinner label="Loading records…" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState title="No records" hint={debounced ? 'Nothing matches your search.' : undefined} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{rows.map((row, i) => {
|
||||
const [primary, ...secondary] = fields;
|
||||
return (
|
||||
<div
|
||||
key={rowKey ? rowKey(row, i) : String(row.instance_id ?? i)}
|
||||
onClick={onRowClick ? () => onRowClick(row, i) : undefined}
|
||||
className={cn(
|
||||
'rounded-lg border border-border-subtle bg-card p-4 min-h-[56px] transition-colors duration-150',
|
||||
onRowClick && 'cursor-pointer active:opacity-80',
|
||||
)}
|
||||
>
|
||||
{primary && (
|
||||
<p className="text-md font-semibold text-strong break-words">
|
||||
{formatValue(row[primary.field_key])}
|
||||
</p>
|
||||
)}
|
||||
{secondary.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-col gap-0.5">
|
||||
{secondary.map((f) => (
|
||||
<p key={f.field_key} className="text-xs text-faint break-words">
|
||||
<span className="text-muted">{f.output_label}:</span> {formatValue(row[f.field_key])}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rowActions && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-3 pt-3 border-t border-border-subtle flex justify-end gap-2"
|
||||
>
|
||||
{rowActions(row)}
|
||||
</div>
|
||||
{error ? (
|
||||
<EmptyState title="Couldn’t load records" hint={error} />
|
||||
) : loading && !resp ? (
|
||||
<div className="p-10 flex justify-center">
|
||||
<Spinner label="Loading records…" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState title="No records" hint={debounced ? 'Nothing matches your search.' : undefined} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{rows.map((row, i) => {
|
||||
const [primary, ...secondary] = fields;
|
||||
return (
|
||||
<div
|
||||
key={rowKey ? rowKey(row, i) : String(row.instance_id ?? i)}
|
||||
onClick={onRowClick ? () => onRowClick(row, i) : undefined}
|
||||
className={cn(
|
||||
'rounded-xl border border-border-subtle bg-card p-4 shadow-sm transition-transform duration-150',
|
||||
onRowClick && 'cursor-pointer active:scale-[0.99]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{primary && (
|
||||
<p className="text-[15px] font-semibold text-strong truncate">
|
||||
{formatValue(row[primary.field_key])}
|
||||
</p>
|
||||
)}
|
||||
{secondary.length > 0 && (
|
||||
<div className="mt-1 flex flex-col gap-0.5">
|
||||
{secondary.map((f) => (
|
||||
<p key={f.field_key} className="text-xs text-muted truncate">
|
||||
<span className="text-faint">{f.output_label}:</span> {formatValue(row[f.field_key])}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onRowClick && (
|
||||
<ChevronRight size={18} className="shrink-0 text-slate-300" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Pagination page={page} pageSize={pageSize} total={total} onPage={setPage} />
|
||||
</Card>
|
||||
{rowActions && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-3 pt-3 border-t border-border-subtle flex justify-end gap-2"
|
||||
>
|
||||
{rowActions(row)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Pagination page={page} pageSize={pageSize} total={total} onPage={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ export function ConsoleLayout() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app flex flex-col">
|
||||
<header className="sticky top-0 z-20 shrink-0 flex items-center justify-between gap-3 px-4 h-14 pt-[env(safe-area-inset-top)] bg-navy-grad border-b border-border-navy">
|
||||
<header className="sticky top-0 z-20 shrink-0 flex items-center justify-between gap-3 px-4 h-14 pt-[env(safe-area-inset-top)] bg-navy-grad shadow-md">
|
||||
<div className="flex flex-col justify-center leading-none">
|
||||
<span className="text-base font-extrabold text-on-navy tracking-[-0.01em] leading-none">Krishna Sales</span>
|
||||
<span className="text-2xs text-on-navy-muted">Sandbox {APP_ID}{user?.name ? ` · ${user.name}` : ''}</span>
|
||||
@ -50,7 +50,7 @@ export function ConsoleLayout() {
|
||||
</main>
|
||||
|
||||
<nav
|
||||
className="fixed bottom-0 inset-x-0 z-20 h-16 pb-[env(safe-area-inset-bottom)] bg-card border-t border-border-subtle grid"
|
||||
className="fixed bottom-0 inset-x-0 z-20 h-16 pb-[env(safe-area-inset-bottom)] bg-card border-t border-border-subtle grid shadow-[0_-2px_16px_rgba(11,27,59,0.06)]"
|
||||
style={{ gridTemplateColumns: `repeat(${SCREENS.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{SCREENS.map((t) => {
|
||||
@ -59,15 +59,28 @@ export function ConsoleLayout() {
|
||||
<NavLink
|
||||
key={t.key}
|
||||
to={`/${t.key}`}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex flex-col items-center justify-center gap-1 no-underline min-h-[48px]',
|
||||
isActive ? 'text-sunrise-500' : 'text-faint',
|
||||
)
|
||||
}
|
||||
className="flex flex-col items-center justify-center gap-1 no-underline min-h-[48px]"
|
||||
>
|
||||
<Icon size={22} />
|
||||
<span className="text-2xs font-medium leading-none">{t.label}</span>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center justify-center h-7 w-14 rounded-pill transition-colors duration-150',
|
||||
isActive ? 'bg-sunrise-100 text-sunrise-600' : 'text-faint',
|
||||
)}
|
||||
>
|
||||
<Icon size={20} />
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-2xs leading-none transition-colors duration-150',
|
||||
isActive ? 'font-semibold text-sunrise-600' : 'font-medium text-faint',
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user