This implements the mobile optimization agent (P3 - Enhancement) with: PWA Configuration: - Add next-pwa integration with offline caching strategies - Create web app manifest for installability - Add service worker with background sync support - Create offline fallback page Mobile Components: - BottomNav: Touch-friendly bottom navigation bar - MobileHeader: Responsive header with back navigation - InstallPrompt: Smart PWA install prompt (iOS & Android) - SwipeableCard: Gesture-based swipeable cards - PullToRefresh: Native-like pull to refresh - QRScanner: Camera-based QR code scanning Mobile Library: - camera.ts: Camera access and photo capture utilities - offline.ts: IndexedDB-based offline storage and sync - gestures.ts: Touch gesture detection (swipe, pinch, tap) - pwa.ts: PWA status, install prompts, service worker management Mobile Pages: - /m: Mobile dashboard with quick actions and stats - /m/scan: QR code scanner for plant lookup - /m/quick-add: Streamlined plant registration form - /m/profile: User profile with offline status Dependencies added: next-pwa, idb
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import * as React from 'react';
|
|
import Head from 'next/head';
|
|
import Link from 'next/link';
|
|
import { BottomNav, MobileHeader, InstallPrompt, PullToRefresh } from 'components/mobile';
|
|
import { isOnline, syncAll } from 'lib/mobile/offline';
|
|
|
|
interface QuickAction {
|
|
href: string;
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
color: string;
|
|
}
|
|
|
|
const quickActions: QuickAction[] = [
|
|
{
|
|
href: '/m/scan',
|
|
icon: (
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
|
</svg>
|
|
),
|
|
label: 'Scan QR',
|
|
color: 'bg-blue-500',
|
|
},
|
|
{
|
|
href: '/m/quick-add',
|
|
icon: (
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
</svg>
|
|
),
|
|
label: 'Add Plant',
|
|
color: 'bg-green-500',
|
|
},
|
|
{
|
|
href: '/plants/explore',
|
|
icon: (
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
|
</svg>
|
|
),
|
|
label: 'Explore',
|
|
color: 'bg-purple-500',
|
|
},
|
|
{
|
|
href: '/plants/register',
|
|
icon: (
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
),
|
|
label: 'Register',
|
|
color: 'bg-orange-500',
|
|
},
|
|
];
|
|
|
|
interface StatCardProps {
|
|
label: string;
|
|
value: string | number;
|
|
icon: React.ReactNode;
|
|
trend?: 'up' | 'down';
|
|
trendValue?: string;
|
|
}
|
|
|
|
function StatCard({ label, value, icon, trend, trendValue }: StatCardProps) {
|
|
return (
|
|
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500">{label}</p>
|
|
<p className="text-2xl font-bold text-gray-900 mt-1">{value}</p>
|
|
{trend && trendValue && (
|
|
<p className={`text-xs mt-1 ${trend === 'up' ? 'text-green-600' : 'text-red-600'}`}>
|
|
{trend === 'up' ? '+' : '-'}{trendValue}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="p-2 bg-gray-50 rounded-lg">{icon}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function MobileHome() {
|
|
const [online, setOnline] = React.useState(true);
|
|
const [refreshing, setRefreshing] = React.useState(false);
|
|
const [stats] = React.useState({
|
|
plants: 24,
|
|
tracked: 18,
|
|
carbonSaved: '12.5',
|
|
foodMiles: '156',
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
setOnline(isOnline());
|
|
const handleOnline = () => setOnline(true);
|
|
const handleOffline = () => setOnline(false);
|
|
|
|
window.addEventListener('online', handleOnline);
|
|
window.addEventListener('offline', handleOffline);
|
|
|
|
return () => {
|
|
window.removeEventListener('online', handleOnline);
|
|
window.removeEventListener('offline', handleOffline);
|
|
};
|
|
}, []);
|
|
|
|
const handleRefresh = async () => {
|
|
setRefreshing(true);
|
|
try {
|
|
await syncAll();
|
|
// Simulate data refresh
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>LocalGreenChain - Mobile</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
|
<meta name="theme-color" content="#16a34a" />
|
|
<link rel="manifest" href="/manifest.json" />
|
|
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
|
|
</Head>
|
|
|
|
<div className="min-h-screen bg-gray-50 pb-20 md:hidden">
|
|
<MobileHeader />
|
|
|
|
{/* Offline indicator */}
|
|
{!online && (
|
|
<div className="fixed top-14 left-0 right-0 bg-amber-500 text-white text-sm text-center py-1 z-40">
|
|
You're offline. Some features may be limited.
|
|
</div>
|
|
)}
|
|
|
|
<PullToRefresh onRefresh={handleRefresh} className="min-h-screen pt-14">
|
|
<div className="p-4 space-y-6">
|
|
{/* Welcome Section */}
|
|
<section>
|
|
<h2 className="text-xl font-bold text-gray-900">Welcome back</h2>
|
|
<p className="text-gray-600 mt-1">Track your plants, save the planet.</p>
|
|
</section>
|
|
|
|
{/* Quick Actions */}
|
|
<section>
|
|
<h3 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">Quick Actions</h3>
|
|
<div className="grid grid-cols-4 gap-3">
|
|
{quickActions.map((action) => (
|
|
<Link key={action.href} href={action.href}>
|
|
<a className="flex flex-col items-center">
|
|
<div className={`${action.color} text-white p-3 rounded-xl shadow-sm`}>
|
|
{action.icon}
|
|
</div>
|
|
<span className="text-xs text-gray-600 mt-2 text-center">{action.label}</span>
|
|
</a>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Stats Grid */}
|
|
<section>
|
|
<h3 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">Your Impact</h3>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<StatCard
|
|
label="My Plants"
|
|
value={stats.plants}
|
|
trend="up"
|
|
trendValue="3 this week"
|
|
icon={
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
}
|
|
/>
|
|
<StatCard
|
|
label="Tracked"
|
|
value={stats.tracked}
|
|
icon={
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
|
</svg>
|
|
}
|
|
/>
|
|
<StatCard
|
|
label="CO2 Saved (kg)"
|
|
value={stats.carbonSaved}
|
|
trend="up"
|
|
trendValue="2.3 kg"
|
|
icon={
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
}
|
|
/>
|
|
<StatCard
|
|
label="Food Miles"
|
|
value={stats.foodMiles}
|
|
icon={
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Recent Activity */}
|
|
<section>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-medium text-gray-500 uppercase tracking-wide">Recent Activity</h3>
|
|
<Link href="/plants/explore">
|
|
<a className="text-sm text-green-600 font-medium">View all</a>
|
|
</Link>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<ActivityItem
|
|
title="Cherry Tomato planted"
|
|
description="Registered to blockchain"
|
|
time="2 hours ago"
|
|
icon={
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
</svg>
|
|
}
|
|
iconBg="bg-green-100"
|
|
iconColor="text-green-600"
|
|
/>
|
|
<ActivityItem
|
|
title="Transport logged"
|
|
description="Farm to market - 12 miles"
|
|
time="Yesterday"
|
|
icon={
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
|
</svg>
|
|
}
|
|
iconBg="bg-blue-100"
|
|
iconColor="text-blue-600"
|
|
/>
|
|
<ActivityItem
|
|
title="Basil harvested"
|
|
description="Generation 3 complete"
|
|
time="3 days ago"
|
|
icon={
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
}
|
|
iconBg="bg-purple-100"
|
|
iconColor="text-purple-600"
|
|
/>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</PullToRefresh>
|
|
|
|
<BottomNav />
|
|
<InstallPrompt />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface ActivityItemProps {
|
|
title: string;
|
|
description: string;
|
|
time: string;
|
|
icon: React.ReactNode;
|
|
iconBg: string;
|
|
iconColor: string;
|
|
}
|
|
|
|
function ActivityItem({ title, description, time, icon, iconBg, iconColor }: ActivityItemProps) {
|
|
return (
|
|
<div className="flex items-center space-x-3 bg-white p-3 rounded-xl border border-gray-100">
|
|
<div className={`${iconBg} ${iconColor} p-2 rounded-lg`}>{icon}</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-gray-900 truncate">{title}</p>
|
|
<p className="text-xs text-gray-500">{description}</p>
|
|
</div>
|
|
<span className="text-xs text-gray-400 whitespace-nowrap">{time}</span>
|
|
</div>
|
|
);
|
|
}
|