Public Access
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user