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:
Bhanu Prakash Sai Potteri 2026-07-08 11:34:43 +05:30
parent 73850d6e0a
commit b85775a413
3 changed files with 126 additions and 121 deletions

View File

@ -1,58 +1,43 @@
import { Card } from "./Card";
import type { TileItem } from "../../api/types"; import type { TileItem } from "../../api/types";
export interface StatsTilesProps { export interface StatsTilesProps {
tiles?: TileItem[]; tiles?: TileItem[];
} }
const colors = [ const TONES = [
"from-blue-500 to-cyan-500", "bg-sunrise-500",
"from-emerald-500 to-green-500", "bg-emerald-500",
"from-orange-500 to-amber-500", "bg-sky-500",
"from-violet-500 to-fuchsia-500", "bg-amber-500",
"from-pink-500 to-rose-500", "bg-navy-600",
"from-indigo-500 to-blue-500", "bg-ruby-500",
]; ];
export function StatsTiles({ tiles }: StatsTilesProps) { export function StatsTiles({ tiles }: StatsTilesProps) {
if (!tiles?.length) return null; if (!tiles?.length) return null;
return ( 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) => { {tiles.map((tile, idx) => {
const displayLabel = (tile.key || `tile_${idx}`) const displayLabel = (tile.key || `tile_${idx}`)
.replace(/_/g, " ") .replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase()); .replace(/\b\w/g, (c) => c.toUpperCase());
const gradient = colors[idx % colors.length];
return ( return (
<Card <div
key={tile.tile_uid || idx} 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 */} <span
<div className={`absolute inset-x-0 top-0 h-1 ${TONES[idx % TONES.length]}`}
className={`absolute left-0 top-0 h-1.5 w-full bg-gradient-to-r ${gradient}`}
/> />
<p className="text-2xs font-semibold uppercase tracking-[0.12em] text-muted">
{/* Background Decoration */} {displayLabel}
<div </p>
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`} <p className="mt-2 font-numeric text-3xl font-bold leading-none tracking-tight text-strong nums">
/> {(tile.value as React.ReactNode) ?? "-"}
</p>
<div className="relative flex flex-col gap-3"> </div>
<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>
); );
})} })}
</div> </div>

View File

@ -1,10 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Search } from 'lucide-react'; import { Search, ChevronRight } from 'lucide-react';
import { cn } from '../../lib/cn'; import { cn } from '../../lib/cn';
import { formatValue } from '../../lib/format'; import { formatValue } from '../../lib/format';
import type { ZinoClient } from '../../api/client'; import type { ZinoClient } from '../../api/client';
import type { RecordViewField, RecordViewResponse } from '../../api/types'; import type { RecordViewField, RecordViewResponse } from '../../api/types';
import { Card } from '../reusable/Card';
import { Pagination } from '../reusable/Pagination'; import { Pagination } from '../reusable/Pagination';
import { Spinner } from '../reusable/Spinner'; import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState'; import { EmptyState } from '../reusable/EmptyState';
@ -119,27 +118,37 @@ export function RecordView({
const chartData = resp?.chart_data; const chartData = resp?.chart_data;
const filterEntries = resp?.config?.filter_options
? Object.entries(resp.config.filter_options).filter(([, o]) => o && o.length > 0)
: [];
return ( return (
<div className="flex flex-col gap-6 w-full"> <div className="flex flex-col gap-5 w-full">
<StatsTiles tiles={tileValues} /> <StatsTiles tiles={tileValues} />
<AnalyticsChart data={chartData} /> <AnalyticsChart data={chartData} />
<Card <div className="flex items-center justify-between gap-3">
title={title} <h2 className="text-lg font-bold tracking-tight text-strong">{title}</h2>
action={headerActions} {headerActions}
pad={false} </div>
>
<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 <div className="flex flex-col gap-2.5">
const fieldDef = resp.config.fields.find(f => f.field_key === key); <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 label = fieldDef?.output_label || key;
const opts = options!.map(o => ({ value: o, label: o }));
const opts = options.map(o => ({ value: o, label: o }));
return ( return (
<Select <Select
key={key} key={key}
@ -149,72 +158,70 @@ export function RecordView({
setPage(1); setPage(1);
}} }}
options={[{ value: '', label: `All ${label}` }, ...opts]} options={[{ value: '', label: `All ${label}` }, ...opts]}
className="w-full sm:w-40 shrink-0" className="w-40 shrink-0"
/> />
); );
})} })}
</div> </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" /> </div>
<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>
{error ? ( {error ? (
<EmptyState title="Couldnt load records" hint={error} /> <EmptyState title="Couldnt load records" hint={error} />
) : loading && !resp ? ( ) : loading && !resp ? (
<div className="p-10 flex justify-center"> <div className="p-10 flex justify-center">
<Spinner label="Loading records…" /> <Spinner label="Loading records…" />
</div> </div>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<EmptyState title="No records" hint={debounced ? 'Nothing matches your search.' : undefined} /> <EmptyState title="No records" hint={debounced ? 'Nothing matches your search.' : undefined} />
) : ( ) : (
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3">
{rows.map((row, i) => { {rows.map((row, i) => {
const [primary, ...secondary] = fields; const [primary, ...secondary] = fields;
return ( return (
<div <div
key={rowKey ? rowKey(row, i) : String(row.instance_id ?? i)} key={rowKey ? rowKey(row, i) : String(row.instance_id ?? i)}
onClick={onRowClick ? () => onRowClick(row, i) : undefined} onClick={onRowClick ? () => onRowClick(row, i) : undefined}
className={cn( className={cn(
'rounded-lg border border-border-subtle bg-card p-4 min-h-[56px] transition-colors duration-150', 'rounded-xl border border-border-subtle bg-card p-4 shadow-sm transition-transform duration-150',
onRowClick && 'cursor-pointer active:opacity-80', onRowClick && 'cursor-pointer active:scale-[0.99]',
)} )}
> >
{primary && ( <div className="flex items-center gap-3">
<p className="text-md font-semibold text-strong break-words"> <div className="min-w-0 flex-1">
{formatValue(row[primary.field_key])} {primary && (
</p> <p className="text-[15px] font-semibold text-strong truncate">
)} {formatValue(row[primary.field_key])}
{secondary.length > 0 && ( </p>
<div className="mt-1.5 flex flex-col gap-0.5"> )}
{secondary.map((f) => ( {secondary.length > 0 && (
<p key={f.field_key} className="text-xs text-faint break-words"> <div className="mt-1 flex flex-col gap-0.5">
<span className="text-muted">{f.output_label}:</span> {formatValue(row[f.field_key])} {secondary.map((f) => (
</p> <p key={f.field_key} className="text-xs text-muted truncate">
))} <span className="text-faint">{f.output_label}:</span> {formatValue(row[f.field_key])}
</div> </p>
)} ))}
{rowActions && ( </div>
<div )}
onClick={(e) => e.stopPropagation()} </div>
className="mt-3 pt-3 border-t border-border-subtle flex justify-end gap-2" {onRowClick && (
> <ChevronRight size={18} className="shrink-0 text-slate-300" />
{rowActions(row)}
</div>
)} )}
</div> </div>
); {rowActions && (
})} <div
</div> onClick={(e) => e.stopPropagation()}
)} className="mt-3 pt-3 border-t border-border-subtle flex justify-end gap-2"
<Pagination page={page} pageSize={pageSize} total={total} onPage={setPage} /> >
</Card> {rowActions(row)}
</div>
)}
</div>
);
})}
</div>
)}
<Pagination page={page} pageSize={pageSize} total={total} onPage={setPage} />
</div> </div>
); );
} }

View File

@ -25,7 +25,7 @@ export function ConsoleLayout() {
return ( return (
<div className="min-h-screen bg-app flex flex-col"> <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"> <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-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> <span className="text-2xs text-on-navy-muted">Sandbox {APP_ID}{user?.name ? ` · ${user.name}` : ''}</span>
@ -50,7 +50,7 @@ export function ConsoleLayout() {
</main> </main>
<nav <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))` }} style={{ gridTemplateColumns: `repeat(${SCREENS.length}, minmax(0, 1fr))` }}
> >
{SCREENS.map((t) => { {SCREENS.map((t) => {
@ -59,15 +59,28 @@ export function ConsoleLayout() {
<NavLink <NavLink
key={t.key} key={t.key}
to={`/${t.key}`} to={`/${t.key}`}
className={({ isActive }) => className="flex flex-col items-center justify-center gap-1 no-underline min-h-[48px]"
cn(
'flex flex-col items-center justify-center gap-1 no-underline min-h-[48px]',
isActive ? 'text-sunrise-500' : 'text-faint',
)
}
> >
<Icon size={22} /> {({ isActive }) => (
<span className="text-2xs font-medium leading-none">{t.label}</span> <>
<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> </NavLink>
); );
})} })}