localgreenchain/pages/m/index.tsx
Claude c2a1b05677
Implement Agent 10: Mobile Optimization with PWA capabilities
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
2025-11-23 03:56:30 +00:00

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>
);
}