feat: full UI redesign with design system, Nielsen heuristics compliance

- Install lucide-react, framer-motion, react-hot-toast, clsx, tailwind-merge
- Custom Tailwind config: semantic color tokens, Inter font, shadow scale,
  border radius scale, custom animations (fadeIn, slideUp, shimmer)
- Shared component library: Button, Badge, Card, Input, Select, Textarea,
  EmptyState, Skeleton, LoadingSpinner
- Global CSS with @layer components (.btn, .card, .input, .badge, .skeleton)
- Toast notification system via react-hot-toast + showToast utility
- ErrorBoundary wrapper for graceful error recovery
- Redesigned navigation: sticky, active state indicators, Lucide icons
- Dashboard: hero header, today highlighting, scrollable week grid,
  redesigned meal cards, empty states, skeleton loading
- Meal Detail: hero image with gradient overlay, metadata row with icons,
  Lucide star rating, edit-existing-feedback flow
- Pantry: inline add form, search/filter, visual quantity badges,
  expiry warnings, confirmation dialogs
- Shopping List: gradient summary cards, aisle grouping with badges,
  sale strikethrough pricing, empty state
- Login: centered card with icon, Input component, Button component
- All old gray/blue utility classes migrated to new surface/primary tokens
- TypeScript clean, production build passes
This commit is contained in:
2026-05-14 10:26:53 -07:00
parent f7ed10651b
commit 6a0c9d0c4e
23 changed files with 1772 additions and 528 deletions
+63
View File
@@ -0,0 +1,63 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from './ui/Button';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center bg-surface-50 px-4">
<div className="max-w-md w-full text-center animate-fade-in">
<div className="w-16 h-16 rounded-2xl bg-danger-50 flex items-center justify-center mx-auto mb-4">
<AlertTriangle className="w-8 h-8 text-danger-500" />
</div>
<h1 className="text-xl font-bold text-surface-900 mb-2">Something went wrong</h1>
<p className="text-sm text-surface-500 mb-6">
We encountered an unexpected error. Try refreshing the page or going back to the dashboard.
</p>
{this.state.error && (
<div className="bg-surface-100 rounded-lg p-3 mb-6 text-left">
<code className="text-xs text-danger-600 break-all">{this.state.error.message}</code>
</div>
)}
<div className="flex gap-3 justify-center">
<Button
variant="secondary"
icon={<RefreshCw className="w-4 h-4" />}
onClick={() => window.location.reload()}
>
Refresh page
</Button>
<Button onClick={() => window.location.href = '/'}>
Go to Dashboard
</Button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { cn } from '../../lib/utils';
interface BadgeProps {
variant?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'neutral';
children: React.ReactNode;
className?: string;
}
export function Badge({ variant = 'neutral', children, className }: BadgeProps) {
const variants = {
primary: 'bg-primary-50 text-primary-700 border border-primary-200',
success: 'bg-success-50 text-success-700 border border-success-200',
warning: 'bg-warning-50 text-warning-700 border border-warning-200',
danger: 'bg-danger-50 text-danger-700 border border-danger-200',
info: 'bg-blue-50 text-blue-700 border border-blue-200',
neutral: 'bg-surface-100 text-surface-600 border border-surface-200',
};
return (
<span className={cn('badge', variants[variant], className)}>
{children}
</span>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Loader2 } from 'lucide-react';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
icon?: ReactNode;
}
export function Button({
variant = 'primary',
size = 'md',
loading = false,
icon,
children,
className = '',
disabled,
...props
}: ButtonProps) {
const base = 'btn';
const variants = {
primary: 'btn-primary',
secondary: 'btn-secondary',
ghost: 'btn-ghost',
danger: 'btn-danger',
};
const sizes = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2.5 text-sm',
lg: 'px-6 py-3 text-base',
};
return (
<button
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled || loading}
{...props}
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{children}
</>
) : (
<>
{icon}
{children}
</>
)}
</button>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { cn } from '../../lib/utils';
interface CardProps {
children: React.ReactNode;
className?: string;
}
export function Card({ children, className }: CardProps) {
return <div className={cn('card', className)}>{children}</div>;
}
interface CardHeaderProps {
children: React.ReactNode;
className?: string;
}
export function CardHeader({ children, className }: CardHeaderProps) {
return <div className={cn('px-5 py-4 border-b border-surface-200', className)}>{children}</div>;
}
interface CardBodyProps {
children: React.ReactNode;
className?: string;
}
export function CardBody({ children, className }: CardBodyProps) {
return <div className={cn('p-5', className)}>{children}</div>;
}
interface CardFooterProps {
children: React.ReactNode;
className?: string;
}
export function CardFooter({ children, className }: CardFooterProps) {
return <div className={cn('px-5 py-4 border-t border-surface-200 bg-surface-50', className)}>{children}</div>;
}
+29
View File
@@ -0,0 +1,29 @@
import { LucideIcon } from 'lucide-react';
import { Button } from './Button';
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
action?: {
label: string;
onClick: () => void;
};
}
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center animate-fade-in">
<div className="w-16 h-16 rounded-2xl bg-surface-100 flex items-center justify-center mb-4">
<Icon className="w-8 h-8 text-surface-400" />
</div>
<h3 className="text-lg font-semibold text-surface-900 mb-1">{title}</h3>
<p className="text-sm text-surface-500 max-w-sm mb-6">{description}</p>
{action && (
<Button onClick={action.onClick}>
{action.label}
</Button>
)}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { forwardRef, type InputHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
hint?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, hint, className, ...props }, ref) => {
return (
<div className="w-full">
{label && <label className="label">{label}</label>}
<input
ref={ref}
className={cn(
'input',
error && 'border-danger-300 focus:border-danger-500 focus:ring-danger-100',
className
)}
{...props}
/>
{error && <p className="mt-1 text-sm text-danger-600">{error}</p>}
{hint && !error && <p className="mt-1 text-sm text-surface-500">{hint}</p>}
</div>
);
}
);
Input.displayName = 'Input';
@@ -0,0 +1,23 @@
import { Loader2 } from 'lucide-react';
import { cn } from '../../lib/utils';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
label?: string;
}
export function LoadingSpinner({ size = 'md', className, label }: LoadingSpinnerProps) {
const sizes = {
sm: 'w-4 h-4',
md: 'w-6 h-6',
lg: 'w-8 h-8',
};
return (
<div className={cn('flex items-center gap-2', className)}>
<Loader2 className={cn('animate-spin text-primary-600', sizes[size])} />
{label && <span className="text-sm text-surface-500">{label}</span>}
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { cn } from '../../lib/utils';
import type { SelectHTMLAttributes } from 'react';
import { forwardRef } from 'react';
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
hint?: string;
options: Array<{ value: string; label: string }>;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ label, error, hint, options, className, ...props }, ref) => {
return (
<div className="w-full">
{label && <label className="label">{label}</label>}
<select
ref={ref}
className={cn(
'input appearance-none bg-[url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%2020%2020%27%20fill%3D%27%236b7280%27%3E%3Cpath%20fill-rule%3D%27evenodd%27%20d%3D%27M5.293%207.293a1%201%200%20011.414%200L10%2010.586l3.293-3.293a1%201%200%20111.414%201.414l-4%204a1%201%200%2001-1.414%200l-4-4a1%201%200%20010-1.414z%27%20clip-rule%3D%27evenodd%27%2F%3E%3C%2Fsvg%3E")] bg-[length:1.5em_1.5em] bg-[right_0.5rem_center] bg-no-repeat pr-10',
error && 'border-danger-300 focus:border-danger-500 focus:ring-danger-100',
className
)}
{...props}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{error && <p className="mt-1 text-sm text-danger-600">{error}</p>}
{hint && !error && <p className="mt-1 text-sm text-surface-500">{hint}</p>}
</div>
);
}
);
Select.displayName = 'Select';
+37
View File
@@ -0,0 +1,37 @@
interface SkeletonProps {
className?: string;
count?: number;
}
export function Skeleton({ className = 'h-4 w-full', count = 1 }: SkeletonProps) {
return (
<>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className={`skeleton ${className}`} />
))}
</>
);
}
export function SkeletonCard() {
return (
<div className="card p-5 space-y-4 animate-fade-in">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<div className="flex gap-3">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
</div>
);
}
export function SkeletonText({ lines = 3 }: { lines?: number }) {
return (
<div className="space-y-3">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} className={`h-4 ${i === lines - 1 ? 'w-2/3' : 'w-full'}`} />
))}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { forwardRef, type TextareaHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
hint?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ label, error, hint, className, ...props }, ref) => {
return (
<div className="w-full">
{label && <label className="label">{label}</label>}
<textarea
ref={ref}
className={cn(
'input min-h-[100px] resize-y',
error && 'border-danger-300 focus:border-danger-500 focus:ring-danger-100',
className
)}
{...props}
/>
{error && <p className="mt-1 text-sm text-danger-600">{error}</p>}
{hint && !error && <p className="mt-1 text-sm text-surface-500">{hint}</p>}
</div>
);
}
);
Textarea.displayName = 'Textarea';