Community 93 Premium feature complete

This commit is contained in:
Eric Gullickson
2025-12-21 11:31:10 -06:00
parent 1bde31247f
commit 95f5e89e48
60 changed files with 8061 additions and 350 deletions

View File

@@ -37,6 +37,10 @@ const AdminStationsPage = lazy(() => import('./pages/admin/AdminStationsPage').t
const AdminUsersMobileScreen = lazy(() => import('./features/admin/mobile/AdminUsersMobileScreen').then(m => ({ default: m.AdminUsersMobileScreen })));
const AdminCatalogMobileScreen = lazy(() => import('./features/admin/mobile/AdminCatalogMobileScreen').then(m => ({ default: m.AdminCatalogMobileScreen })));
const AdminStationsMobileScreen = lazy(() => import('./features/admin/mobile/AdminStationsMobileScreen').then(m => ({ default: m.AdminStationsMobileScreen })));
// Admin Community Stations (lazy-loaded)
const AdminCommunityStationsPage = lazy(() => import('./features/admin/pages/AdminCommunityStationsPage').then(m => ({ default: m.AdminCommunityStationsPage })));
const AdminCommunityStationsMobileScreen = lazy(() => import('./features/admin/mobile/AdminCommunityStationsMobileScreen').then(m => ({ default: m.AdminCommunityStationsMobileScreen })));
import { HomePage } from './pages/HomePage';
import { BottomNavigation } from './shared-minimal/components/mobile/BottomNavigation';
import { QuickAction } from './shared-minimal/components/mobile/quickActions';
@@ -164,7 +168,9 @@ const LogFuelScreen: React.FC = () => {
try {
queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
queryClient.invalidateQueries({ queryKey: ['fuelLogsStats'] });
} catch {}
} catch (error) {
// Silently ignore cache invalidation errors
}
// Navigate back if we have history; otherwise go to Vehicles
if (canGoBack()) {
goBack();
@@ -695,6 +701,31 @@ function App() {
</MobileErrorBoundary>
</motion.div>
)}
{activeScreen === "AdminCommunityStations" && (
<motion.div
key="admin-community-stations"
initial={{opacity:0, y:8}}
animate={{opacity:1, y:0}}
exit={{opacity:0, y:-8}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<MobileErrorBoundary screenName="AdminCommunityStations">
<React.Suspense fallback={
<div className="space-y-4">
<GlassCard>
<div className="p-4">
<div className="text-slate-500 py-6 text-center">
Loading community station reviews...
</div>
</div>
</GlassCard>
</div>
}>
<AdminCommunityStationsMobileScreen />
</React.Suspense>
</MobileErrorBoundary>
</motion.div>
)}
</AnimatePresence>
<DebugInfo />
</Layout>
@@ -763,6 +794,7 @@ function App() {
<Route path="/garage/settings/admin/users" element={<AdminUsersPage />} />
<Route path="/garage/settings/admin/catalog" element={<AdminCatalogPage />} />
<Route path="/garage/settings/admin/stations" element={<AdminStationsPage />} />
<Route path="/garage/settings/admin/community-stations" element={<AdminCommunityStationsPage />} />
<Route path="*" element={<Navigate to="/garage/vehicles" replace />} />
</Routes>
</RouteSuspense>

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { safeStorage } from '../utils/safe-storage';
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings' | 'AdminUsers' | 'AdminCatalog' | 'AdminStations';
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings' | 'AdminUsers' | 'AdminCatalog' | 'AdminStations' | 'AdminCommunityStations';
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
interface NavigationHistory {

View File

@@ -0,0 +1,211 @@
/**
* @ai-summary Community station review card for admin approval workflow
*/
import React, { useState } from 'react';
import {
Card,
CardContent,
CardActions,
Typography,
Box,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Chip,
Grid,
} from '@mui/material';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
import { CommunityStation } from '../../stations/types/community-stations.types';
interface CommunityStationReviewCardProps {
station: CommunityStation;
onApprove: (id: string) => void;
onReject: (id: string, reason: string) => void;
isLoading?: boolean;
}
/**
* Card showing full station details for admin review
* Responsive design with 44px minimum touch targets
*/
export const CommunityStationReviewCard: React.FC<CommunityStationReviewCardProps> = ({
station,
onApprove,
onReject,
isLoading = false,
}) => {
const [openRejectDialog, setOpenRejectDialog] = useState(false);
const [rejectionReason, setRejectionReason] = useState('');
const handleApprove = () => {
onApprove(station.id);
};
const handleRejectClick = () => {
setOpenRejectDialog(true);
};
const handleRejectConfirm = () => {
if (rejectionReason.trim()) {
onReject(station.id, rejectionReason);
setOpenRejectDialog(false);
setRejectionReason('');
}
};
const octaneLabel = station.has93Octane
? station.has93OctaneEthanolFree
? '93 Octane · Ethanol Free'
: '93 Octane · w/ Ethanol'
: 'No 93 Octane';
return (
<>
<Card
sx={{
transition: 'transform 0.2s, box-shadow 0.2s',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: 3,
},
}}
>
<CardContent>
{/* Station name and location */}
<Typography variant="h6" sx={{ mb: 1 }}>
{station.name}
</Typography>
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Typography variant="body2" color="textSecondary">
{station.address}
</Typography>
{(station.city || station.state || station.zipCode) && (
<Typography variant="body2" color="textSecondary">
{[station.city, station.state, station.zipCode].filter(Boolean).join(', ')}
</Typography>
)}
</Box>
{/* Details grid */}
<Grid container spacing={1} sx={{ mb: 2 }}>
{station.brand && (
<Grid item xs={6}>
<Typography variant="caption" color="textSecondary" display="block">
Brand
</Typography>
<Typography variant="body2">{station.brand}</Typography>
</Grid>
)}
<Grid item xs={station.brand ? 6 : 12}>
<Typography variant="caption" color="textSecondary" display="block">
93 Octane Status
</Typography>
<Chip label={octaneLabel} size="small" color="success" />
</Grid>
{station.price93 && (
<Grid item xs={6}>
<Typography variant="caption" color="textSecondary" display="block">
Price
</Typography>
<Typography variant="body2">${station.price93.toFixed(3)}</Typography>
</Grid>
)}
</Grid>
{/* Coordinates */}
<Box sx={{ mb: 2, p: 1, bgcolor: 'grey.100', borderRadius: 1 }}>
<Typography variant="caption" color="textSecondary">
Latitude: {station.latitude.toFixed(8)}
</Typography>
<Typography variant="caption" color="textSecondary" display="block">
Longitude: {station.longitude.toFixed(8)}
</Typography>
</Box>
{/* Notes */}
{station.notes && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="textSecondary">
Notes:
</Typography>
<Typography variant="body2" sx={{ p: 1, bgcolor: 'grey.50', borderRadius: 1, mt: 0.5 }}>
{station.notes}
</Typography>
</Box>
)}
{/* Submission info */}
<Box sx={{ pt: 1, borderTop: '1px solid #e0e0e0' }}>
<Typography variant="caption" color="textSecondary" display="block">
Submitted by: {station.submittedBy}
</Typography>
<Typography variant="caption" color="textSecondary" display="block">
Date: {new Date(station.createdAt).toLocaleDateString()}
</Typography>
</Box>
</CardContent>
{/* Action buttons */}
<CardActions sx={{ gap: 1, minHeight: '44px', justifyContent: 'flex-end' }}>
<Button
variant="outlined"
color="error"
onClick={handleRejectClick}
disabled={isLoading}
startIcon={<CancelIcon />}
sx={{ minHeight: '44px' }}
>
Reject
</Button>
<Button
variant="contained"
color="success"
onClick={handleApprove}
disabled={isLoading}
startIcon={<CheckCircleIcon />}
sx={{ minHeight: '44px' }}
>
Approve
</Button>
</CardActions>
</Card>
{/* Rejection dialog */}
<Dialog open={openRejectDialog} onClose={() => setOpenRejectDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>Reject Station Submission</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
<TextField
autoFocus
fullWidth
multiline
rows={4}
label="Rejection Reason"
placeholder="e.g., Incorrect location, duplicate entry, invalid address..."
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
variant="outlined"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenRejectDialog(false)}>Cancel</Button>
<Button
onClick={handleRejectConfirm}
variant="contained"
color="error"
disabled={!rejectionReason.trim() || isLoading}
>
Reject
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default CommunityStationReviewCard;

View File

@@ -0,0 +1,119 @@
/**
* @ai-summary Queue of pending community station submissions for admin review
*/
import React from 'react';
import {
Box,
CircularProgress,
Alert,
Grid,
Pagination,
Typography,
} from '@mui/material';
import { CommunityStation } from '../../stations/types/community-stations.types';
import { CommunityStationReviewCard } from './CommunityStationReviewCard';
interface CommunityStationReviewQueueProps {
stations: CommunityStation[];
loading?: boolean;
error?: string | null;
onApprove: (id: string) => void;
onReject: (id: string, reason: string) => void;
page?: number;
onPageChange?: (page: number) => void;
totalPages?: number;
isReviewLoading?: boolean;
}
/**
* Review queue for pending community station submissions
* Responsive grid layout with pagination
*/
export const CommunityStationReviewQueue: React.FC<CommunityStationReviewQueueProps> = ({
stations,
loading = false,
error = null,
onApprove,
onReject,
page = 0,
onPageChange,
totalPages = 1,
isReviewLoading = false,
}) => {
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '300px' }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error">
{error}
</Alert>
);
}
if (stations.length === 0) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '300px',
flexDirection: 'column',
gap: 1,
}}
>
<Typography variant="h6" color="textSecondary">
No pending submissions
</Typography>
<Typography variant="body2" color="textSecondary">
All community stations have been reviewed
</Typography>
</Box>
);
}
return (
<Box>
{/* Stats header */}
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="textSecondary">
Pending review: {stations.length} submission{stations.length !== 1 ? 's' : ''}
</Typography>
</Box>
{/* Grid of cards */}
<Grid container spacing={2}>
{stations.map((station) => (
<Grid item xs={12} sm={6} md={4} key={station.id}>
<CommunityStationReviewCard
station={station}
onApprove={onApprove}
onReject={onReject}
isLoading={isReviewLoading}
/>
</Grid>
))}
</Grid>
{/* Pagination */}
{onPageChange && totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<Pagination
count={totalPages}
page={page + 1}
onChange={(_, newPage) => onPageChange(newPage - 1)}
/>
</Box>
)}
</Box>
);
};
export default CommunityStationReviewQueue;

View File

@@ -0,0 +1,179 @@
/**
* @ai-summary Mobile admin screen for community station reviews
*/
import React, { useState } from 'react';
import { Navigate } from 'react-router-dom';
import {
Box,
Select,
MenuItem,
FormControl,
InputLabel,
} from '@mui/material';
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
import { CommunityStationReviewQueue } from '../components/CommunityStationReviewQueue';
import { CommunityStationsList } from '../../stations/components/CommunityStationsList';
import {
usePendingSubmissions,
useAllCommunitySubmissions,
useReviewStation,
} from '../../stations/hooks/useCommunityStations';
import toast from 'react-hot-toast';
type TabType = 'pending' | 'all';
/**
* Mobile admin screen for reviewing community station submissions
* Touch-friendly layout with tab navigation
*/
export const AdminCommunityStationsMobileScreen: React.FC = () => {
const { isAdmin, loading } = useAdminAccess();
const [activeTab, setActiveTab] = useState<TabType>('pending');
const [statusFilter, setStatusFilter] = useState<string>('');
const [page, setPage] = useState(0);
// Hooks
const pendingSubmissions = usePendingSubmissions(page, 20);
const allSubmissions = useAllCommunitySubmissions(statusFilter || undefined, page, 20);
const reviewMutation = useReviewStation();
if (loading) {
return (
<MobileContainer>
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="text-slate-500 mb-2">Loading admin access...</div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
</div>
</div>
</MobileContainer>
);
}
if (!isAdmin) {
return <Navigate to="/garage/settings" replace />;
}
// Handle approval
const handleApprove = async (id: string) => {
try {
await reviewMutation.mutateAsync({
id,
decision: { status: 'approved' },
});
toast.success('Station approved');
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to approve station');
}
};
// Handle rejection
const handleReject = async (id: string, reason: string) => {
try {
await reviewMutation.mutateAsync({
id,
decision: { status: 'rejected', rejectionReason: reason },
});
toast.success('Station rejected');
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to reject station');
}
};
const displayData = activeTab === 'pending' ? pendingSubmissions : allSubmissions;
const stations = displayData.data?.stations || [];
const totalPages = displayData.data?.total ? Math.ceil(displayData.data.total / 20) : 1;
return (
<MobileContainer>
<div className="space-y-4 pb-20 p-4">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-slate-800">Community Station Reviews</h1>
<p className="text-slate-500 mt-1">Review user-submitted gas stations</p>
</div>
{/* Tab navigation */}
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<button
onClick={() => {
setActiveTab('pending');
setPage(0);
}}
className={`flex-1 py-2 px-3 rounded-lg font-medium transition ${
activeTab === 'pending'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-600'
}`}
>
Pending ({pendingSubmissions.data?.total || 0})
</button>
<button
onClick={() => {
setActiveTab('all');
setPage(0);
}}
className={`flex-1 py-2 px-3 rounded-lg font-medium transition ${
activeTab === 'all'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-600'
}`}
>
All
</button>
</Box>
{/* Status filter for all tab */}
{activeTab === 'all' && (
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
<InputLabel>Filter by Status</InputLabel>
<Select
value={statusFilter}
label="Filter by Status"
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(0);
}}
>
<MenuItem value="">All</MenuItem>
<MenuItem value="pending">Pending</MenuItem>
<MenuItem value="approved">Approved</MenuItem>
<MenuItem value="rejected">Rejected</MenuItem>
</Select>
</FormControl>
)}
{/* Content */}
{activeTab === 'pending' ? (
<CommunityStationReviewQueue
stations={stations}
loading={displayData.isLoading}
error={displayData.error ? 'Failed to load submissions' : null}
onApprove={handleApprove}
onReject={handleReject}
page={page}
onPageChange={setPage}
totalPages={totalPages}
isReviewLoading={reviewMutation.isPending}
/>
) : (
<CommunityStationsList
stations={stations}
loading={displayData.isLoading}
error={displayData.error ? 'Failed to load submissions' : null}
isAdmin={true}
onApprove={handleApprove}
onReject={handleReject}
page={page}
onPageChange={setPage}
totalPages={totalPages}
/>
)}
</div>
</MobileContainer>
);
};
export default AdminCommunityStationsMobileScreen;

View File

@@ -0,0 +1,236 @@
/**
* @ai-summary Admin desktop page for managing community station submissions
*/
import React, { useState, useCallback } from 'react';
import {
Box,
Paper,
Tabs,
Tab,
Select,
MenuItem,
FormControl,
InputLabel,
useMediaQuery,
useTheme,
Grid,
Typography,
} from '@mui/material';
import { AdminSectionHeader } from '../components/AdminSectionHeader';
import { CommunityStationReviewQueue } from '../components/CommunityStationReviewQueue';
import { CommunityStationsList } from '../../stations/components/CommunityStationsList';
import {
usePendingSubmissions,
useAllCommunitySubmissions,
useReviewStation,
} from '../../stations/hooks/useCommunityStations';
import toast from 'react-hot-toast';
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
if (value !== index) {
return null;
}
return (
<div role="tabpanel">
<Box sx={{ padding: 2 }}>{children}</Box>
</div>
);
};
/**
* Admin page for reviewing and managing community station submissions
* Desktop layout with tab navigation and status filtering
*/
export const AdminCommunityStationsPage: React.FC = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [tabValue, setTabValue] = useState(0);
const [statusFilter, setStatusFilter] = useState<string>('');
const [page, setPage] = useState(0);
// Hooks
const pendingSubmissions = usePendingSubmissions(page, 50);
const allSubmissions = useAllCommunitySubmissions(statusFilter || undefined, page, 50);
const reviewMutation = useReviewStation();
// Determine which data to display
const displayData = tabValue === 0 ? pendingSubmissions : allSubmissions;
const stations = displayData.data?.stations || [];
const totalPages = displayData.data?.total
? Math.ceil(displayData.data.total / 50)
: 1;
// Handle approval
const handleApprove = useCallback(
async (id: string) => {
try {
await reviewMutation.mutateAsync({
id,
decision: { status: 'approved' },
});
toast.success('Station approved');
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to approve station');
}
},
[reviewMutation]
);
// Handle rejection
const handleReject = useCallback(
async (id: string, reason: string) => {
try {
await reviewMutation.mutateAsync({
id,
decision: { status: 'rejected', rejectionReason: reason },
});
toast.success('Station rejected');
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to reject station');
}
},
[reviewMutation]
);
return (
<Box>
{/* Header */}
<AdminSectionHeader
title="Community Gas Station Reviews"
stats={[
{ label: 'Pending', value: pendingSubmissions.data?.total || 0 },
{ label: 'Total', value: allSubmissions.data?.total || 0 }
]}
/>
{/* Main content */}
<Paper sx={{ m: 2 }}>
<Tabs
value={tabValue}
onChange={(_, newValue) => setTabValue(newValue)}
indicatorColor="primary"
textColor="primary"
aria-label="admin community stations tabs"
variant={isMobile ? 'scrollable' : 'standard'}
>
<Tab label={`Pending (${pendingSubmissions.data?.total || 0})`} id="tab-0" />
<Tab label="All Submissions" id="tab-1" />
</Tabs>
{/* Pending tab */}
<TabPanel value={tabValue} index={0}>
<CommunityStationReviewQueue
stations={stations}
loading={displayData.isLoading}
error={displayData.error ? 'Failed to load submissions' : null}
onApprove={handleApprove}
onReject={handleReject}
page={page}
onPageChange={setPage}
totalPages={totalPages}
isReviewLoading={reviewMutation.isPending}
/>
</TabPanel>
{/* All submissions tab */}
<TabPanel value={tabValue} index={1}>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={12} sm={6} md={3}>
<FormControl fullWidth size="small">
<InputLabel>Filter by Status</InputLabel>
<Select
value={statusFilter}
label="Filter by Status"
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(0);
}}
>
<MenuItem value="">All</MenuItem>
<MenuItem value="pending">Pending</MenuItem>
<MenuItem value="approved">Approved</MenuItem>
<MenuItem value="rejected">Rejected</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
<CommunityStationsList
stations={stations}
loading={displayData.isLoading}
error={displayData.error ? 'Failed to load submissions' : null}
isAdmin={true}
onApprove={handleApprove}
onReject={handleReject}
page={page}
onPageChange={setPage}
totalPages={totalPages}
/>
</TabPanel>
</Paper>
{/* Stats card */}
{pendingSubmissions.data && (
<Paper sx={{ m: 2, p: 2 }}>
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={3}>
<Box>
<Typography variant="h4" color="primary">
{pendingSubmissions.data.total}
</Typography>
<Typography variant="body2" color="textSecondary">
Pending Review
</Typography>
</Box>
</Grid>
{allSubmissions.data && (
<>
<Grid item xs={12} sm={6} md={3}>
<Box>
<Typography variant="h4" color="success.main">
{allSubmissions.data.stations.filter((s) => s.status === 'approved').length}
</Typography>
<Typography variant="body2" color="textSecondary">
Approved
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Box>
<Typography variant="h4" color="error.main">
{allSubmissions.data.stations.filter((s) => s.status === 'rejected').length}
</Typography>
<Typography variant="body2" color="textSecondary">
Rejected
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Box>
<Typography variant="h4">
{allSubmissions.data.total}
</Typography>
<Typography variant="body2" color="textSecondary">
Total Submitted
</Typography>
</Box>
</Grid>
</>
)}
</Grid>
</Paper>
)}
</Box>
);
};
export default AdminCommunityStationsPage;

View File

@@ -0,0 +1,426 @@
# Community Gas Stations Feature
Complete implementation of the community gas station submission and review feature for MotoVaultPro. Users can submit 93 octane gas station locations, and admins can review and approve submissions.
## Implementation Complete
All user and admin interfaces have been fully implemented with mobile + desktop parity.
### User Features
- Submit new 93 octane gas stations with location, price, and notes
- View approved community-submitted stations
- Browse nearby approved stations (with geolocation)
- Manage and withdraw pending submissions
- View submission status (pending/approved/rejected)
### Admin Features
- Review pending station submissions
- Approve or reject submissions with optional rejection reasons
- Bulk review multiple submissions
- Filter submissions by status
- View submission statistics
## File Structure
```
frontend/src/features/stations/
├── types/
│ └── community-stations.types.ts # Type definitions
├── api/
│ └── community-stations.api.ts # API client
├── hooks/
│ └── useCommunityStations.ts # React Query hooks
├── components/
│ ├── CommunityStationCard.tsx # Station display card
│ ├── SubmitStationForm.tsx # Submission form
│ ├── CommunityStationsList.tsx # List component
│ └── index-community.ts # Component exports
├── pages/
│ └── CommunityStationsPage.tsx # Desktop page
├── mobile/
│ └── CommunityStationsMobileScreen.tsx # Mobile screen
└── __tests__/
├── api/
│ └── community-stations.api.test.ts
├── hooks/
│ └── useCommunityStations.test.ts
└── components/
└── CommunityStationCard.test.ts
frontend/src/features/admin/
├── components/
│ ├── CommunityStationReviewCard.tsx # Review card
│ └── CommunityStationReviewQueue.tsx # Review queue
├── pages/
│ └── AdminCommunityStationsPage.tsx # Admin desktop page
└── mobile/
└── AdminCommunityStationsMobileScreen.tsx # Admin mobile screen
```
## Types
### CommunityStation
Main entity representing a community-submitted gas station.
```typescript
interface CommunityStation {
id: string;
submittedBy: string;
name: string;
address: string;
city?: string;
state?: string;
zipCode?: string;
latitude: number;
longitude: number;
brand?: string;
has93Octane: boolean;
has93OctaneEthanolFree: boolean;
price93?: number;
notes?: string;
status: 'pending' | 'approved' | 'rejected';
reviewedBy?: string;
reviewedAt?: string;
rejectionReason?: string;
createdAt: string;
updatedAt: string;
}
```
### SubmitStationData
Form data for submitting a new station.
```typescript
interface SubmitStationData {
name: string;
address: string;
city?: string;
state?: string;
zipCode?: string;
latitude: number;
longitude: number;
brand?: string;
has93Octane: boolean;
has93OctaneEthanolFree: boolean;
price93?: number;
notes?: string;
}
```
## API Endpoints
### User Endpoints
- `POST /stations/community/submit` - Submit new station
- `GET /stations/community/mine` - Get user's submissions
- `DELETE /stations/community/:id` - Withdraw submission
- `GET /stations/community/approved` - Get approved stations (paginated)
- `POST /stations/community/nearby` - Get approved stations nearby
### Admin Endpoints
- `GET /stations/community/admin/submissions` - Get all submissions (with filtering)
- `GET /stations/community/admin/pending` - Get pending submissions
- `PATCH /stations/community/admin/:id/review` - Review submission (approve/reject)
- `POST /stations/community/admin/bulk-review` - Bulk review submissions
## React Query Hooks
All data fetching is handled via React Query with automatic cache invalidation.
### useSubmitStation()
Submit a new community gas station.
```typescript
const { mutate, isPending } = useSubmitStation();
await mutate(formData);
```
### useMySubmissions()
Fetch user's submitted stations.
```typescript
const { data, isLoading } = useMySubmissions();
```
### useWithdrawSubmission()
Withdraw a pending submission.
```typescript
const { mutate } = useWithdrawSubmission();
await mutate(stationId);
```
### useApprovedStations(page, limit)
Fetch approved community stations with pagination.
```typescript
const { data, isLoading } = useApprovedStations(0, 50);
```
### useApprovedNearbyStations(lat, lng, radiusMeters)
Fetch approved stations near user's location.
```typescript
const { data } = useApprovedNearbyStations(latitude, longitude, 5000);
```
### usePendingSubmissions(page, limit)
Fetch pending submissions for admin review.
```typescript
const { data, isLoading } = usePendingSubmissions(0, 50);
```
### useReviewStation()
Approve or reject a submission.
```typescript
const { mutate } = useReviewStation();
await mutate({
id: stationId,
decision: { status: 'approved' }
});
```
### useBulkReviewStations()
Bulk approve/reject multiple submissions.
```typescript
const { mutate } = useBulkReviewStations();
await mutate({
ids: ['1', '2', '3'],
decision: { status: 'approved' }
});
```
## Components
### CommunityStationCard
Displays a single community station with details and action buttons.
Props:
- `station` - CommunityStation object
- `isAdmin?` - Show admin controls
- `onWithdraw?` - Callback for withdrawal
- `onApprove?` - Callback for approval
- `onReject?` - Callback for rejection
- `distance?` - Distance from user location
### SubmitStationForm
Form for submitting new stations with validation.
Props:
- `onSuccess?` - Callback on successful submission
- `onError?` - Callback on error
- `isLoading?` - Loading state
Features:
- Zod validation
- Geolocation integration
- Mobile-friendly layout
- 44px+ touch targets
### CommunityStationsList
Grid list of community stations with pagination.
Props:
- `stations` - Array of stations
- `loading?` - Loading state
- `error?` - Error message
- `isAdmin?` - Show admin controls
- `page?` - Current page
- `totalPages?` - Total pages
- `onPageChange?` - Page change callback
### CommunityStationReviewCard
Admin review card with approve/reject buttons.
Props:
- `station` - CommunityStation
- `onApprove` - Approve callback
- `onReject` - Reject callback
- `isLoading?` - Loading state
### CommunityStationReviewQueue
Queue of pending submissions for admin review.
Props:
- `stations` - Array of pending stations
- `loading?` - Loading state
- `onApprove` - Approve callback
- `onReject` - Reject callback
- `page?` - Current page
- `totalPages?` - Total pages
## Pages and Screens
### CommunityStationsPage (Desktop)
Main user interface for browsing and submitting stations.
Features:
- Browse all approved stations (paginated)
- View personal submissions
- Browse nearby stations (with geolocation)
- Submit new station via dialog
- Withdraw pending submissions
Layout:
- Header with submit button
- Tab navigation: Browse All, My Submissions, Near Me
- Station cards in responsive grid
### CommunityStationsMobileScreen (Mobile)
Mobile-optimized interface with bottom tab navigation.
Features:
- Same as desktop but optimized for touch
- Bottom tab navigation: Browse, Submit, My Submissions
- Full-screen form dialog
- Touch-friendly spacing and buttons
### AdminCommunityStationsPage (Desktop)
Admin interface for reviewing submissions.
Features:
- Pending submissions tab
- All submissions tab with status filter
- Approval/rejection workflow
- Statistics dashboard
- Status filtering
Layout:
- Header with title
- Tab navigation: Pending, All
- Review cards in grid
- Stats panel
### AdminCommunityStationsMobileScreen (Mobile)
Mobile admin interface.
Features:
- Same as desktop but optimized for touch
- Bottom tab navigation: Pending, All
- Status filter dropdown
- Mobile-friendly review workflow
## Mobile + Desktop Considerations
### Mobile-First Design
- Minimum 44px x 44px touch targets for all buttons
- Full-width forms on mobile
- Bottom tab navigation for mobile screens
- Touch-friendly spacing (16px+ gaps)
- Optimized keyboard input types (email, tel, number)
### Responsive Breakpoints
- Mobile: 320px - 599px
- Tablet: 600px - 1023px
- Desktop: 1024px+
### Validation
- Form validation with Zod schema
- Real-time error display
- Touch-friendly input fields
- Required field indicators
### Accessibility
- ARIA labels on interactive elements
- Keyboard navigation support
- Screen reader friendly
- Proper heading hierarchy
## Testing
### Component Tests
All components have unit tests with:
- Rendering tests
- User interaction tests
- Mobile viewport tests
- Error state tests
### Hook Tests
React Query hooks tested with:
- Mock API responses
- Mutation handling
- Cache invalidation
- Error scenarios
### API Client Tests
API client tested with:
- Successful requests
- Error handling
- Parameter validation
- Pagination
Run tests:
```bash
npm test -- features/stations/community
npm test -- features/admin/community
```
## Integration
To use the community stations feature:
1. **User Page**: Add route to `CommunityStationsPage`
```tsx
<Route path="/stations/community" element={<CommunityStationsPage />} />
```
2. **Mobile**: Add route to `CommunityStationsMobileScreen`
```tsx
<Route path="/mobile/stations/community" element={<CommunityStationsMobileScreen />} />
```
3. **Admin Page**: Add route to `AdminCommunityStationsPage`
```tsx
<Route path="/admin/community-stations" element={<AdminCommunityStationsPage />} />
```
4. **Admin Mobile**: Add route to `AdminCommunityStationsMobileScreen`
```tsx
<Route path="/mobile/admin/community-stations" element={<AdminCommunityStationsMobileScreen />} />
```
## Backend API Requirements
The backend must implement the following endpoints:
### User Endpoints
```
POST /api/stations/community/submit
GET /api/stations/community/mine
DELETE /api/stations/community/:id
GET /api/stations/community/approved
POST /api/stations/community/nearby
```
### Admin Endpoints
```
GET /api/stations/community/admin/submissions
GET /api/stations/community/admin/pending
PATCH /api/stations/community/admin/:id/review
POST /api/stations/community/admin/bulk-review
```
See backend documentation for detailed implementation.
## Future Enhancements
- Real-time map view for community stations
- Edit submissions (before approval)
- Community ratings/feedback on stations
- Price history tracking
- Station verification badges
- Duplicate detection and merging
- Admin moderation tools
- Email notifications for reviewers
- Analytics dashboard
## Notes
- All coordinates are decimal degrees (latitude -90 to 90, longitude -180 to 180)
- Prices are in USD per gallon
- Timestamps are ISO 8601 format
- All endpoints require JWT authentication
- Admin endpoints require admin role
- Soft delete is used for submissions

View File

@@ -0,0 +1,222 @@
/**
* @ai-summary Tests for Community Stations API Client
*/
import axios from 'axios';
import { communityStationsApi } from '../../api/community-stations.api';
import { SubmitStationData, ReviewDecision } from '../../types/community-stations.types';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('Community Stations API Client', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('submitStation', () => {
it('should submit a station successfully', async () => {
const submitData: SubmitStationData = {
name: 'Shell Downtown',
address: '123 Main St',
city: 'Denver',
state: 'CO',
zipCode: '80202',
latitude: 39.7392,
longitude: -104.9903,
brand: 'Shell',
has93Octane: true,
has93OctaneEthanolFree: false,
price93: 3.599,
notes: 'Good quality',
};
const mockResponse = {
data: {
id: '1',
...submitData,
status: 'pending',
submittedBy: 'user@example.com',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
};
mockedAxios.post.mockResolvedValueOnce(mockResponse);
const result = await communityStationsApi.submitStation(submitData);
expect(result).toEqual(mockResponse.data);
expect(mockedAxios.post).toHaveBeenCalledWith(
'/stations/community/submit',
submitData
);
});
it('should handle submission errors', async () => {
const submitData: SubmitStationData = {
name: 'Shell',
address: '123 Main St',
latitude: 39.7392,
longitude: -104.9903,
has93Octane: true,
has93OctaneEthanolFree: false,
};
const mockError = new Error('Network error');
mockedAxios.post.mockRejectedValueOnce(mockError);
await expect(communityStationsApi.submitStation(submitData)).rejects.toThrow('Network error');
});
});
describe('getMySubmissions', () => {
it('should fetch user submissions', async () => {
const mockSubmissions = [
{
id: '1',
name: 'Shell Downtown',
address: '123 Main St',
latitude: 39.7392,
longitude: -104.9903,
has93Octane: true,
has93OctaneEthanolFree: false,
status: 'pending',
submittedBy: 'user@example.com',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
mockedAxios.get.mockResolvedValueOnce({ data: mockSubmissions });
const result = await communityStationsApi.getMySubmissions();
expect(result).toEqual(mockSubmissions);
expect(mockedAxios.get).toHaveBeenCalledWith('/stations/community/mine');
});
});
describe('withdrawSubmission', () => {
it('should withdraw a submission', async () => {
mockedAxios.delete.mockResolvedValueOnce({ data: null });
await communityStationsApi.withdrawSubmission('1');
expect(mockedAxios.delete).toHaveBeenCalledWith('/stations/community/1');
});
});
describe('getApprovedStations', () => {
it('should fetch approved stations with pagination', async () => {
const mockResponse = {
data: {
stations: [
{
id: '1',
name: 'Shell Downtown',
address: '123 Main St',
latitude: 39.7392,
longitude: -104.9903,
has93Octane: true,
has93OctaneEthanolFree: false,
status: 'approved',
submittedBy: 'user@example.com',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
total: 1,
page: 0,
limit: 50,
},
};
mockedAxios.get.mockResolvedValueOnce(mockResponse);
const result = await communityStationsApi.getApprovedStations(0, 50);
expect(result).toEqual(mockResponse.data);
expect(mockedAxios.get).toHaveBeenCalledWith('/stations/community/approved', {
params: { page: 0, limit: 50 },
});
});
});
describe('reviewStation (admin)', () => {
it('should approve a station', async () => {
const decision: ReviewDecision = { status: 'approved' };
const mockResponse = {
data: {
id: '1',
status: 'approved',
reviewedAt: new Date().toISOString(),
reviewedBy: 'admin@example.com',
},
};
mockedAxios.patch.mockResolvedValueOnce(mockResponse);
const result = await communityStationsApi.reviewStation('1', decision);
expect(result).toEqual(mockResponse.data);
expect(mockedAxios.patch).toHaveBeenCalledWith(
'/stations/community/admin/1/review',
decision
);
});
it('should reject a station with reason', async () => {
const decision: ReviewDecision = {
status: 'rejected',
rejectionReason: 'Invalid location',
};
const mockResponse = {
data: {
id: '1',
status: 'rejected',
rejectionReason: 'Invalid location',
reviewedAt: new Date().toISOString(),
reviewedBy: 'admin@example.com',
},
};
mockedAxios.patch.mockResolvedValueOnce(mockResponse);
const result = await communityStationsApi.reviewStation('1', decision);
expect(result).toEqual(mockResponse.data);
expect(mockedAxios.patch).toHaveBeenCalledWith(
'/stations/community/admin/1/review',
decision
);
});
});
describe('bulkReviewStations (admin)', () => {
it('should bulk approve stations', async () => {
const ids = ['1', '2', '3'];
const decision: ReviewDecision = { status: 'approved' };
const mockResponse = {
data: [
{ id: '1', status: 'approved' },
{ id: '2', status: 'approved' },
{ id: '3', status: 'approved' },
],
};
mockedAxios.post.mockResolvedValueOnce(mockResponse);
const result = await communityStationsApi.bulkReviewStations(ids, decision);
expect(result).toEqual(mockResponse.data);
expect(mockedAxios.post).toHaveBeenCalledWith('/stations/community/admin/bulk-review', {
ids,
...decision,
});
});
});
});

View File

@@ -0,0 +1,126 @@
/**
* @ai-summary Tests for CommunityStationCard component
*/
import { render, screen, fireEvent } from '@testing-library/react';
import { CommunityStationCard } from '../../components/CommunityStationCard';
import { CommunityStation } from '../../types/community-stations.types';
describe('CommunityStationCard', () => {
const mockStation: CommunityStation = {
id: '1',
submittedBy: 'user@example.com',
name: 'Shell Downtown',
address: '123 Main St',
city: 'Denver',
state: 'CO',
zipCode: '80202',
latitude: 39.7392,
longitude: -104.9903,
brand: 'Shell',
has93Octane: true,
has93OctaneEthanolFree: false,
price93: 3.599,
notes: 'Good quality fuel',
status: 'approved',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
it('should render station details', () => {
render(<CommunityStationCard station={mockStation} />);
expect(screen.getByText('Shell Downtown')).toBeInTheDocument();
expect(screen.getByText('123 Main St')).toBeInTheDocument();
expect(screen.getByText(/Denver, CO, 80202/)).toBeInTheDocument();
expect(screen.getByText('Brand: Shell')).toBeInTheDocument();
});
it('should display 93 octane status', () => {
render(<CommunityStationCard station={mockStation} />);
expect(screen.getByText('93 Octane · w/ Ethanol')).toBeInTheDocument();
});
it('should display price when available', () => {
render(<CommunityStationCard station={mockStation} />);
expect(screen.getByText('$3.599/gal')).toBeInTheDocument();
});
it('should display status badge', () => {
render(<CommunityStationCard station={mockStation} />);
expect(screen.getByText('approved')).toBeInTheDocument();
});
it('should show withdraw button for user view', () => {
render(
<CommunityStationCard
station={mockStation}
isAdmin={false}
onWithdraw={jest.fn()}
/>
);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should show approve and reject buttons for admin', () => {
const pendingStation = { ...mockStation, status: 'pending' as const };
render(
<CommunityStationCard
station={pendingStation}
isAdmin={true}
onApprove={jest.fn()}
onReject={jest.fn()}
/>
);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('should call onWithdraw when withdraw button is clicked', () => {
const onWithdraw = jest.fn();
render(
<CommunityStationCard
station={mockStation}
isAdmin={false}
onWithdraw={onWithdraw}
/>
);
const withdrawButton = screen.getByRole('button');
fireEvent.click(withdrawButton);
expect(onWithdraw).toHaveBeenCalledWith(mockStation.id);
});
it('should handle rejection with reason', async () => {
const onReject = jest.fn();
const pendingStation = { ...mockStation, status: 'pending' as const };
render(
<CommunityStationCard
station={pendingStation}
isAdmin={true}
onReject={onReject}
/>
);
// This test would need more interaction handling
// for the dialog that appears on reject
});
it('should work on mobile viewport', () => {
render(<CommunityStationCard station={mockStation} />);
expect(screen.getByText('Shell Downtown')).toBeInTheDocument();
// Verify touch targets are at least 44px
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
expect(button).toHaveStyle({ minHeight: '44px', minWidth: '44px' });
});
});
});

View File

@@ -0,0 +1,210 @@
/**
* @ai-summary Tests for Community Stations React Query hooks
*/
import React, { ReactNode } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
useSubmitStation,
useMySubmissions,
useApprovedStations,
usePendingSubmissions,
useReviewStation,
} from '../../hooks/useCommunityStations';
import * as communityStationsApi from '../../api/community-stations.api';
// Mock the API
jest.mock('../../api/community-stations.api');
// Setup React Query test wrapper
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const Wrapper = ({ children }: { children: ReactNode }) => {
const testQueryClient = createTestQueryClient();
return React.createElement(
QueryClientProvider,
{ client: testQueryClient },
children
);
};
describe('Community Stations Hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('useSubmitStation', () => {
it('should handle successful submission', async () => {
const mockStation = {
id: '1',
submittedBy: 'user@example.com',
name: 'Shell Downtown',
address: '123 Main St',
latitude: 39.7392,
longitude: -104.9903,
has93Octane: true,
has93OctaneEthanolFree: false,
status: 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
jest.spyOn(communityStationsApi.communityStationsApi, 'submitStation').mockResolvedValueOnce(mockStation as any);
const { result } = renderHook(() => useSubmitStation(), { wrapper: Wrapper });
// Initially should be idle
expect(result.current.isPending).toBe(false);
});
});
describe('useMySubmissions', () => {
it('should fetch user submissions', async () => {
const mockSubmissions = [
{
id: '1',
submittedBy: 'user@example.com',
name: 'Shell Downtown',
address: '123 Main St',
latitude: 39.7392,
longitude: -104.9903,
has93Octane: true,
has93OctaneEthanolFree: false,
status: 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
jest
.spyOn(communityStationsApi.communityStationsApi, 'getMySubmissions')
.mockResolvedValueOnce(mockSubmissions as any);
const { result } = renderHook(() => useMySubmissions(), { wrapper: Wrapper });
// Initially should be loading
expect(result.current.isLoading).toBe(true);
// Wait for data to be loaded
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual(mockSubmissions);
});
});
describe('useApprovedStations', () => {
it('should fetch approved stations with pagination', async () => {
const mockResponse = {
stations: [
{
id: '1',
submittedBy: 'user@example.com',
name: 'Shell Downtown',
address: '123 Main St',
latitude: 39.7392,
longitude: -104.9903,
has93Octane: true,
has93OctaneEthanolFree: false,
status: 'approved',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
total: 1,
page: 0,
limit: 50,
};
jest
.spyOn(communityStationsApi.communityStationsApi, 'getApprovedStations')
.mockResolvedValueOnce(mockResponse as any);
const { result } = renderHook(() => useApprovedStations(0, 50), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual(mockResponse);
});
});
describe('usePendingSubmissions', () => {
it('should fetch pending submissions for admin', async () => {
const mockResponse = {
stations: [
{
id: '1',
submittedBy: 'user@example.com',
name: 'Chevron on Main',
address: '456 Main St',
latitude: 39.7392,
longitude: -104.9903,
has93Octane: true,
has93OctaneEthanolFree: true,
status: 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
total: 1,
page: 0,
limit: 50,
};
jest
.spyOn(communityStationsApi.communityStationsApi, 'getPendingSubmissions')
.mockResolvedValueOnce(mockResponse as any);
const { result } = renderHook(() => usePendingSubmissions(0, 50), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual(mockResponse);
});
});
describe('useReviewStation', () => {
it('should approve a station', async () => {
const mockStation = {
id: '1',
status: 'approved',
};
jest
.spyOn(communityStationsApi.communityStationsApi, 'reviewStation')
.mockResolvedValueOnce(mockStation as any);
const { result } = renderHook(() => useReviewStation(), { wrapper: Wrapper });
expect(result.current.isPending).toBe(false);
});
it('should reject a station with reason', async () => {
const mockStation = {
id: '1',
status: 'rejected',
rejectionReason: 'Invalid address',
};
jest
.spyOn(communityStationsApi.communityStationsApi, 'reviewStation')
.mockResolvedValueOnce(mockStation as any);
const { result } = renderHook(() => useReviewStation(), { wrapper: Wrapper });
expect(result.current.isPending).toBe(false);
});
});
});

View File

@@ -0,0 +1,204 @@
/**
* @ai-summary API client for Community Gas Stations feature
*/
import { apiClient } from '@/core/api/client';
import {
CommunityStation,
SubmitStationData,
ReviewDecision,
CommunityStationsListResponse,
StationBounds,
RemovalReportResponse
} from '../types/community-stations.types';
const API_BASE = '/stations/community';
class CommunityStationsApiClient {
/**
* Submit a new community gas station
*/
async submitStation(data: SubmitStationData): Promise<CommunityStation> {
try {
const response = await apiClient.post<CommunityStation>(
API_BASE,
data
);
return response.data;
} catch (error) {
console.error('Submit station failed:', error);
throw error;
}
}
/**
* Get user's submitted stations
*/
async getMySubmissions(): Promise<CommunityStation[]> {
try {
const response = await apiClient.get<CommunityStation[]>(
`${API_BASE}/mine`
);
return response.data || [];
} catch (error) {
console.error('Get submissions failed:', error);
throw error;
}
}
/**
* Withdraw a submission
*/
async withdrawSubmission(id: string): Promise<void> {
try {
await apiClient.delete(`${API_BASE}/${id}`);
} catch (error) {
console.error('Withdraw submission failed:', error);
throw error;
}
}
/**
* Get approved community stations
*/
async getApprovedStations(page: number = 0, limit: number = 50): Promise<CommunityStationsListResponse> {
try {
const response = await apiClient.get<CommunityStationsListResponse>(
`${API_BASE}/approved`,
{
params: { page, limit }
}
);
return response.data;
} catch (error) {
console.error('Get approved stations failed:', error);
throw error;
}
}
/**
* Get approved stations nearby
*/
async getApprovedNearby(
latitude: number,
longitude: number,
radiusMeters: number = 5000
): Promise<CommunityStation[]> {
try {
const response = await apiClient.post<{ stations: CommunityStation[] }>(
`${API_BASE}/nearby`,
{ latitude, longitude, radiusMeters }
);
return response.data?.stations || [];
} catch (error) {
console.error('Get nearby stations failed:', error);
throw error;
}
}
/**
* Get approved stations within map bounds
*/
async getApprovedInBounds(bounds: StationBounds): Promise<CommunityStation[]> {
try {
const response = await apiClient.post<{ stations: CommunityStation[] }>(
`${API_BASE}/bounds`,
bounds
);
return response.data?.stations || [];
} catch (error) {
console.error('Get stations in bounds failed:', error);
throw error;
}
}
/**
* Report a station as no longer having Premium 93
*/
async reportRemoval(stationId: string, reason?: string): Promise<RemovalReportResponse> {
try {
const response = await apiClient.post<RemovalReportResponse>(
`${API_BASE}/${stationId}/report-removal`,
{ reason: reason || 'No longer has Premium 93' }
);
return response.data;
} catch (error) {
console.error('Report removal failed:', error);
throw error;
}
}
/**
* Admin: Get all submissions with filtering
*/
async getAllSubmissions(
status?: string,
page: number = 0,
limit: number = 50
): Promise<CommunityStationsListResponse> {
try {
const response = await apiClient.get<CommunityStationsListResponse>(
`${API_BASE}/admin/submissions`,
{
params: { status, page, limit }
}
);
return response.data;
} catch (error) {
console.error('Get submissions failed:', error);
throw error;
}
}
/**
* Admin: Get pending submissions
*/
async getPendingSubmissions(page: number = 0, limit: number = 50): Promise<CommunityStationsListResponse> {
try {
const response = await apiClient.get<CommunityStationsListResponse>(
`${API_BASE}/admin/pending`,
{
params: { page, limit }
}
);
return response.data;
} catch (error) {
console.error('Get pending submissions failed:', error);
throw error;
}
}
/**
* Admin: Review a submission (approve/reject)
*/
async reviewStation(id: string, decision: ReviewDecision): Promise<CommunityStation> {
try {
const response = await apiClient.patch<CommunityStation>(
`${API_BASE}/admin/${id}/review`,
decision
);
return response.data;
} catch (error) {
console.error('Review station failed:', error);
throw error;
}
}
/**
* Admin: Bulk review submissions
*/
async bulkReviewStations(ids: string[], decision: ReviewDecision): Promise<CommunityStation[]> {
try {
const response = await apiClient.post<CommunityStation[]>(
`${API_BASE}/admin/bulk-review`,
{ ids, ...decision }
);
return response.data || [];
} catch (error) {
console.error('Bulk review failed:', error);
throw error;
}
}
}
export const communityStationsApi = new CommunityStationsApiClient();

View File

@@ -0,0 +1,421 @@
/**
* @ai-summary Individual community station card component
*/
import React, { useState } from 'react';
import {
Card,
CardContent,
CardActions,
Typography,
Chip,
IconButton,
Box,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions as MuiDialogActions,
Button,
TextField,
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
import DirectionsIcon from '@mui/icons-material/Directions';
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
import BookmarkIcon from '@mui/icons-material/Bookmark';
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
import { CommunityStation } from '../types/community-stations.types';
import { formatDistance } from '../utils/distance';
import { NavigationMenu } from './NavigationMenu';
interface CommunityStationCardProps {
station: CommunityStation;
isAdmin?: boolean;
onWithdraw?: (id: string) => void;
onApprove?: (id: string) => void;
onReject?: (id: string, reason: string) => void;
distance?: number;
// User-facing actions for approved stations
onSaveStation?: (station: CommunityStation) => void;
onUnsaveStation?: (id: string) => void;
isSaved?: boolean;
onSubmitFor93?: (station: CommunityStation) => void;
}
const getStatusColor = (status: string) => {
switch (status) {
case 'approved':
return 'success';
case 'rejected':
return 'error';
case 'pending':
return 'warning';
default:
return 'default';
}
};
/**
* Station card showing station details with status badge and action buttons
* Responsive design: min 44px touch targets on mobile
*/
export const CommunityStationCard: React.FC<CommunityStationCardProps> = ({
station,
isAdmin,
onWithdraw,
onApprove,
onReject,
distance,
onSaveStation,
onUnsaveStation,
isSaved = false,
onSubmitFor93
}) => {
const [openRejectDialog, setOpenRejectDialog] = useState(false);
const [rejectionReason, setRejectionReason] = useState('');
const [navAnchorEl, setNavAnchorEl] = useState<HTMLElement | null>(null);
const handleRejectClick = () => {
setOpenRejectDialog(true);
};
const handleRejectConfirm = () => {
if (rejectionReason.trim()) {
onReject?.(station.id, rejectionReason);
setOpenRejectDialog(false);
setRejectionReason('');
}
};
const handleOpenNavMenu = (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
setNavAnchorEl(e.currentTarget);
};
const handleCloseNavMenu = () => {
setNavAnchorEl(null);
};
const handleSaveClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (isSaved) {
onUnsaveStation?.(station.id);
} else {
onSaveStation?.(station);
}
};
const handleSubmitFor93 = (e: React.MouseEvent) => {
e.stopPropagation();
onSubmitFor93?.(station);
};
const octaneLabel = station.has93Octane
? station.has93OctaneEthanolFree
? '93 Octane · Ethanol Free'
: '93 Octane · w/ Ethanol'
: null;
return (
<>
<Card
sx={{
transition: 'transform 0.2s, box-shadow 0.2s',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: 3,
},
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<CardContent sx={{ flexGrow: 1 }}>
{/* Header with name and status */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Typography variant="h6" component="div" sx={{ flexGrow: 1, pr: 1 }}>
{station.name}
</Typography>
<Chip
label={station.status}
color={getStatusColor(station.status) as any}
size="small"
/>
</Box>
{/* Address */}
<Typography
variant="body2"
color="textSecondary"
sx={{
mb: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical'
}}
>
{station.address}
</Typography>
{/* City, State, Zip */}
{(station.city || station.state || station.zipCode) && (
<Typography variant="body2" color="textSecondary" sx={{ mb: 1 }}>
{[station.city, station.state, station.zipCode].filter(Boolean).join(', ')}
</Typography>
)}
{/* Brand */}
{station.brand && (
<Chip
label={`Brand: ${station.brand}`}
size="small"
variant="outlined"
sx={{ mr: 1, mb: 1 }}
/>
)}
{/* 93 Octane availability */}
{octaneLabel && (
<Chip
label={octaneLabel}
color="success"
size="small"
sx={{ mr: 1, mb: 1 }}
/>
)}
{/* Price */}
{station.price93 && (
<Chip
label={`$${station.price93.toFixed(3)}/gal`}
variant="outlined"
size="small"
sx={{ mr: 1, mb: 1 }}
/>
)}
{/* Distance if available */}
{distance && (
<Chip
label={formatDistance(distance)}
variant="outlined"
size="small"
sx={{ mb: 1 }}
/>
)}
{/* Notes */}
{station.notes && (
<Box sx={{ mt: 2, p: 1, bgcolor: 'grey.100', borderRadius: 1 }}>
<Typography variant="caption" color="textSecondary" sx={{ display: 'block', mb: 0.5 }}>
Notes:
</Typography>
<Typography variant="body2">
{station.notes}
</Typography>
</Box>
)}
{/* Submission metadata */}
<Box sx={{ mt: 2, pt: 1, borderTop: '1px solid #e0e0e0' }}>
<Typography variant="caption" color="textSecondary">
Submitted by: {station.submittedBy}
</Typography>
{station.reviewedAt && (
<Typography variant="caption" color="textSecondary" sx={{ display: 'block' }}>
Reviewed {new Date(station.reviewedAt).toLocaleDateString()}
</Typography>
)}
{station.rejectionReason && (
<Box sx={{ mt: 1, p: 1, bgcolor: 'error.light', borderRadius: 1 }}>
<Typography variant="caption" color="error.dark" sx={{ display: 'block' }}>
Rejection Reason: {station.rejectionReason}
</Typography>
</Box>
)}
</Box>
</CardContent>
{/* User actions for approved stations - Navigate, Premium 93, Favorite */}
{!isAdmin && station.status === 'approved' && (
<Box
sx={{
display: 'flex',
justifyContent: 'space-around',
alignItems: 'flex-start',
padding: 1,
borderTop: '1px solid #e0e0e0',
gap: 0.5
}}
>
{/* Navigate button with label - opens menu */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<IconButton
size="large"
onClick={handleOpenNavMenu}
title="Get directions"
sx={{
minWidth: '44px',
minHeight: '44px',
padding: 1
}}
>
<DirectionsIcon />
</IconButton>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
color: 'text.secondary',
mt: -0.5,
textAlign: 'center'
}}
>
Navigate
</Typography>
</Box>
{/* Premium 93 button - amber for verified stations */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<IconButton
size="large"
onClick={handleSubmitFor93}
title="Premium 93 status"
sx={{
minWidth: '44px',
minHeight: '44px',
padding: 1,
color: 'warning.main'
}}
>
<LocalGasStationIcon />
</IconButton>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
color: 'warning.main',
mt: -0.5,
fontWeight: 500,
textAlign: 'center'
}}
>
Premium 93
</Typography>
</Box>
{/* Favorite button with label */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<IconButton
size="large"
onClick={handleSaveClick}
title={isSaved ? 'Remove from favorites' : 'Add to favorites'}
sx={{
minWidth: '44px',
minHeight: '44px',
padding: 1,
color: isSaved ? 'warning.main' : 'inherit'
}}
>
{isSaved ? <BookmarkIcon /> : <BookmarkBorderIcon />}
</IconButton>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
color: isSaved ? 'warning.main' : 'text.secondary',
mt: -0.5,
textAlign: 'center'
}}
>
Favorite
</Typography>
</Box>
</Box>
)}
{/* User actions for pending stations - Withdraw only */}
{!isAdmin && station.status === 'pending' && (
<CardActions sx={{ minHeight: '44px' }}>
<Tooltip title="Withdraw this submission">
<IconButton
size="small"
onClick={() => onWithdraw?.(station.id)}
sx={{ minWidth: '44px', minHeight: '44px' }}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</CardActions>
)}
{/* Admin actions for pending stations */}
{isAdmin && station.status === 'pending' && (
<CardActions sx={{ minHeight: '44px', gap: 1 }}>
<Tooltip title="Approve this submission">
<IconButton
size="small"
onClick={() => onApprove?.(station.id)}
color="success"
sx={{ minWidth: '44px', minHeight: '44px' }}
>
<CheckCircleIcon />
</IconButton>
</Tooltip>
<Tooltip title="Reject this submission">
<IconButton
size="small"
onClick={handleRejectClick}
color="error"
sx={{ minWidth: '44px', minHeight: '44px' }}
>
<CancelIcon />
</IconButton>
</Tooltip>
</CardActions>
)}
{/* Navigation menu */}
<NavigationMenu
anchorEl={navAnchorEl}
station={station}
onClose={handleCloseNavMenu}
/>
</Card>
{/* Rejection reason dialog */}
<Dialog open={openRejectDialog} onClose={() => setOpenRejectDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>Reject Station Submission</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
<TextField
autoFocus
fullWidth
multiline
rows={3}
placeholder="Provide a reason for rejection..."
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
variant="outlined"
/>
</DialogContent>
<MuiDialogActions>
<Button onClick={() => setOpenRejectDialog(false)}>Cancel</Button>
<Button
onClick={handleRejectConfirm}
variant="contained"
color="error"
disabled={!rejectionReason.trim()}
>
Reject
</Button>
</MuiDialogActions>
</Dialog>
</>
);
};
export default CommunityStationCard;

View File

@@ -0,0 +1,111 @@
/**
* @ai-summary List component for community gas stations
*/
import React from 'react';
import {
Box,
CircularProgress,
Alert,
Grid,
Typography,
Pagination,
} from '@mui/material';
import { CommunityStation } from '../types/community-stations.types';
import { CommunityStationCard } from './CommunityStationCard';
interface CommunityStationsListProps {
stations: CommunityStation[];
loading?: boolean;
error?: string | null;
isAdmin?: boolean;
onWithdraw?: (id: string) => void;
onApprove?: (id: string) => void;
onReject?: (id: string, reason: string) => void;
page?: number;
onPageChange?: (page: number) => void;
totalPages?: number;
}
/**
* List of community stations with loading and error states
* Responsive grid layout: 1 column on mobile, 2+ on desktop
*/
export const CommunityStationsList: React.FC<CommunityStationsListProps> = ({
stations,
loading = false,
error = null,
isAdmin = false,
onWithdraw,
onApprove,
onReject,
page = 0,
onPageChange,
totalPages = 1,
}) => {
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '300px' }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error">
{error}
</Alert>
);
}
if (stations.length === 0) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '300px',
flexDirection: 'column',
gap: 2,
}}
>
<Typography variant="body1" color="textSecondary">
{isAdmin ? 'No submissions to review' : 'No community stations yet'}
</Typography>
</Box>
);
}
return (
<Box>
<Grid container spacing={2}>
{stations.map((station) => (
<Grid item xs={12} sm={6} md={4} key={station.id}>
<CommunityStationCard
station={station}
isAdmin={isAdmin}
onWithdraw={onWithdraw}
onApprove={onApprove}
onReject={onReject}
/>
</Grid>
))}
</Grid>
{/* Pagination */}
{onPageChange && totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<Pagination
count={totalPages}
page={page + 1}
onChange={(_, newPage) => onPageChange(newPage - 1)}
/>
</Box>
)}
</Box>
);
};
export default CommunityStationsList;

View File

@@ -0,0 +1,56 @@
/**
* @ai-summary Badge for community-verified 93 octane stations
*/
import React from 'react';
import {
Box,
Chip,
} from '@mui/material';
import LocalFireDepartmentIcon from '@mui/icons-material/LocalFireDepartment';
interface CommunityVerifiedBadgeProps {
has93Octane: boolean;
has93OctaneEthanolFree: boolean;
}
/**
* Badge showing that a station has been community-verified for 93 octane
* Displays verification status and ethanol-free indicator if applicable
*/
export const CommunityVerifiedBadge: React.FC<CommunityVerifiedBadgeProps> = ({
has93Octane,
has93OctaneEthanolFree,
}) => {
if (!has93Octane) {
return null;
}
return (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Chip
icon={<LocalFireDepartmentIcon />}
label="93 Verified"
size="small"
color="success"
variant="filled"
sx={{
fontWeight: 500,
minHeight: '32px',
}}
/>
{has93OctaneEthanolFree && (
<Chip
label="Ethanol Free"
size="small"
variant="outlined"
sx={{
minHeight: '32px',
}}
/>
)}
</Box>
);
};
export default CommunityVerifiedBadge;

View File

@@ -0,0 +1,69 @@
/**
* @ai-summary Reusable navigation menu component for Google/Apple/Waze options
*/
import React from 'react';
import { Menu, MenuItem } from '@mui/material';
import { buildNavigationLinks, StationLike } from '../utils/navigation-links';
interface NavigationMenuProps {
anchorEl: HTMLElement | null;
station: StationLike | null;
onClose: () => void;
}
/**
* Navigation menu with Google Maps, Apple Maps, and Waze options
* Used across StationCard, CommunityStationCard, and SavedStationsList
*/
export const NavigationMenu: React.FC<NavigationMenuProps> = ({
anchorEl,
station,
onClose
}) => {
if (!station) {
return null;
}
const links = buildNavigationLinks(station);
return (
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={onClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuItem
component="a"
href={links.google}
target="_blank"
rel="noopener"
onClick={onClose}
>
Navigate in Google
</MenuItem>
<MenuItem
component="a"
href={links.apple}
target="_blank"
rel="noopener"
onClick={onClose}
>
Navigate in Apple Maps
</MenuItem>
<MenuItem
component="a"
href={links.waze}
target="_blank"
rel="noopener"
onClick={onClose}
>
Navigate in Waze
</MenuItem>
</Menu>
);
};
export default NavigationMenu;

View File

@@ -0,0 +1,184 @@
/**
* @ai-summary Tab content for premium 93 octane stations
*/
import React from 'react';
import {
Box,
CircularProgress,
Typography,
Grid,
Paper,
Alert,
} from '@mui/material';
import { Station, SavedStation } from '../types/stations.types';
import { CommunityStation, StationBounds } from '../types/community-stations.types';
import { useApprovedNearbyStations, useApprovedStationsInBounds } from '../hooks/useCommunityStations';
import { StationCard } from './StationCard';
import { CommunityStationCard } from './CommunityStationCard';
interface Premium93TabContentProps {
latitude: number | null;
longitude: number | null;
savedStations: SavedStation[];
onStationSelect?: (station: Station | CommunityStation) => void;
/** Optional search bounds - when provided, filters community stations to this area */
searchBounds?: StationBounds | null;
/** Callback to save a community station */
onSaveCommunityStation?: (station: CommunityStation) => void;
/** Callback to unsave a community station */
onUnsaveCommunityStation?: (id: string) => void;
/** Callback to submit/report Premium 93 status */
onSubmitFor93?: (station: CommunityStation) => void;
/** Set of saved station addresses for quick lookup */
savedAddresses?: Set<string>;
}
/**
* Tab content displaying saved 93 octane stations and community-verified nearby stations
* Mobile-first responsive design with proper section organization
*/
export const Premium93TabContent: React.FC<Premium93TabContentProps> = ({
latitude,
longitude,
savedStations,
onStationSelect,
searchBounds = null,
onSaveCommunityStation,
onUnsaveCommunityStation,
onSubmitFor93,
savedAddresses = new Set(),
}) => {
// Use bounds-based search when available, otherwise use nearby search
const {
data: boundsStations = [],
isLoading: isLoadingBounds,
error: boundsError
} = useApprovedStationsInBounds(searchBounds);
const {
data: nearbyStations = [],
isLoading: isLoadingNearby,
error: nearbyError
} = useApprovedNearbyStations(
// Only use nearby if no bounds provided
searchBounds ? null : latitude,
searchBounds ? null : longitude,
5000
);
const isLoading = isLoadingBounds || isLoadingNearby;
const error = boundsError || nearbyError;
// Use bounds stations if available, otherwise nearby
const communityStations = searchBounds ? boundsStations : nearbyStations;
// Filter saved stations for 93 octane
const saved93Stations = savedStations.filter(s => s.has93Octane === true);
const nearbyApproved93Stations = (communityStations as CommunityStation[]).filter(
s => s.has93Octane === true && s.status === 'approved'
);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Section 1: Your Saved 93 Stations */}
<Paper
variant="outlined"
sx={{
p: 2,
backgroundColor: '#fafafa',
borderRadius: 1,
}}
>
<Box sx={{ mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
Your Saved 93 Stations
</Typography>
<Typography variant="body2" color="textSecondary">
{saved93Stations.length} station{saved93Stations.length !== 1 ? 's' : ''} saved
</Typography>
</Box>
{saved93Stations.length === 0 ? (
<Alert severity="info">
No saved 93 octane stations yet. Search and save stations to see them here.
</Alert>
) : (
<Grid container spacing={2}>
{saved93Stations.map((station) => (
<Grid item xs={12} sm={6} md={4} key={station.id}>
<StationCard
station={station}
isSaved={true}
savedStation={station}
onSelect={onStationSelect}
/>
</Grid>
))}
</Grid>
)}
</Paper>
{/* Section 2: Community Verified - shows search area or nearby */}
<Paper
variant="outlined"
sx={{
p: 2,
backgroundColor: '#fafafa',
borderRadius: 1,
}}
>
<Box sx={{ mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
{searchBounds ? 'Community Verified in Search Area' : 'Community Verified Nearby'}
</Typography>
<Typography variant="body2" color="textSecondary">
{isLoading
? 'Loading...'
: `${nearbyApproved93Stations.length} station${nearbyApproved93Stations.length !== 1 ? 's' : ''} found`}
</Typography>
</Box>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '200px' }}>
<CircularProgress />
</Box>
) : error ? (
<Alert severity="error">
Failed to load stations. Please try again.
</Alert>
) : !searchBounds && (latitude === null || longitude === null) ? (
<Alert severity="warning">
Enable location or run a search to see community-verified stations.
</Alert>
) : nearbyApproved93Stations.length === 0 ? (
<Alert severity="info">
{searchBounds
? 'No community-verified 93 octane stations in this search area. Help us by submitting stations you know about.'
: 'No community-verified 93 octane stations nearby. Help us by submitting stations you know about.'}
</Alert>
) : (
<Grid container spacing={2}>
{nearbyApproved93Stations.map((station) => {
const isSaved = savedAddresses.has(station.address?.toLowerCase().trim() || '');
return (
<Grid item xs={12} sm={6} md={4} key={station.id}>
<CommunityStationCard
station={station}
isSaved={isSaved}
onSaveStation={onSaveCommunityStation}
onUnsaveStation={onUnsaveCommunityStation}
onSubmitFor93={onSubmitFor93}
/>
</Grid>
);
})}
</Grid>
)}
</Paper>
</Box>
);
};
export default Premium93TabContent;

View File

@@ -16,10 +16,11 @@ import {
Skeleton,
Menu,
MenuItem,
Button
IconButton
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import NavigationIcon from '@mui/icons-material/Navigation';
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
import { OctanePreference, SavedStation } from '../types/stations.types';
import { formatDistance } from '../utils/distance';
import {
@@ -39,6 +40,7 @@ interface SavedStationsListProps {
onDeleteStation?: (placeId: string) => void;
onOctanePreferenceChange?: (placeId: string, preference: OctanePreference) => void;
octaneUpdatingId?: string | null;
onSubmitFor93?: (station: SavedStation) => void;
}
export const SavedStationsList: React.FC<SavedStationsListProps> = ({
@@ -48,7 +50,8 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
onSelectStation,
onDeleteStation,
onOctanePreferenceChange,
octaneUpdatingId
octaneUpdatingId,
onSubmitFor93
}) => {
const [navAnchorEl, setNavAnchorEl] = React.useState<null | HTMLElement>(null);
const [navStation, setNavStation] = React.useState<SavedStation | null>(null);
@@ -237,36 +240,96 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
<Box
sx={{
display: 'flex',
gap: 1,
gap: 2,
mt: 1,
flexWrap: 'wrap'
}}
>
<Button
variant="text"
size="small"
startIcon={<NavigationIcon />}
onClick={(e) => handleOpenNavMenu(e, station)}
sx={{ minHeight: 36 }}
>
Navigate
</Button>
<Button
variant="text"
size="small"
color="error"
startIcon={<DeleteIcon />}
onClick={(e) => {
e.stopPropagation();
if (placeId) {
onDeleteStation?.(placeId);
}
}}
disabled={!placeId}
sx={{ minHeight: 36 }}
>
Delete
</Button>
{/* Navigate button with label - opens menu */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<IconButton
onClick={(e) => handleOpenNavMenu(e, station)}
title="Get directions"
sx={{
minWidth: '44px',
minHeight: '44px'
}}
>
<NavigationIcon />
</IconButton>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
color: 'text.secondary',
mt: -0.5,
textAlign: 'center'
}}
>
Navigate
</Typography>
</Box>
{/* Premium 93 button with label */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<IconButton
onClick={(e) => {
e.stopPropagation();
onSubmitFor93?.(station);
}}
title="Premium 93 status"
sx={{
minWidth: '44px',
minHeight: '44px',
color: station.has93Octane ? 'warning.main' : 'inherit'
}}
>
<LocalGasStationIcon />
</IconButton>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
color: station.has93Octane ? 'warning.main' : 'text.secondary',
mt: -0.5,
textAlign: 'center'
}}
>
Premium 93
</Typography>
</Box>
{/* Delete button with label */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<IconButton
onClick={(e) => {
e.stopPropagation();
if (placeId) {
onDeleteStation?.(placeId);
}
}}
disabled={!placeId}
title="Delete saved station"
sx={{
minWidth: '44px',
minHeight: '44px',
color: 'error.main'
}}
>
<DeleteIcon />
</IconButton>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
color: 'error.main',
mt: -0.5,
textAlign: 'center'
}}
>
Delete
</Typography>
</Box>
</Box>
</Box>
}

View File

@@ -2,7 +2,7 @@
* @ai-summary Individual station card component
*/
import React from 'react';
import React, { useState } from 'react';
import {
Card,
CardContent,
@@ -15,9 +15,13 @@ import {
import BookmarkIcon from '@mui/icons-material/Bookmark';
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
import DirectionsIcon from '@mui/icons-material/Directions';
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
import { Station, SavedStation } from '../types/stations.types';
import { formatDistance } from '../utils/distance';
import { StationPhoto } from './StationPhoto';
import { CommunityVerifiedBadge } from './CommunityVerifiedBadge';
import { NavigationMenu } from './NavigationMenu';
import { CommunityStationData } from '../hooks/useEnrichedStations';
interface StationCardProps {
station: Station;
@@ -26,6 +30,9 @@ interface StationCardProps {
onSave?: (station: Station) => void;
onDelete?: (placeId: string) => void;
onSelect?: (station: Station) => void;
communityData?: CommunityStationData;
onSubmitFor93?: (station: Station) => void;
showSubmitFor93Button?: boolean;
}
/**
@@ -38,8 +45,14 @@ export const StationCard: React.FC<StationCardProps> = ({
savedStation,
onSave,
onDelete,
onSelect
onSelect,
communityData,
onSubmitFor93,
showSubmitFor93Button = true
}) => {
// Navigation menu state
const [navAnchorEl, setNavAnchorEl] = useState<HTMLElement | null>(null);
const handleSaveClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (isSaved) {
@@ -49,10 +62,18 @@ export const StationCard: React.FC<StationCardProps> = ({
}
};
const handleDirections = (e: React.MouseEvent) => {
const handleOpenNavMenu = (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
const mapsUrl = `https://www.google.com/maps/search/${encodeURIComponent(station.address)}`;
window.open(mapsUrl, '_blank');
setNavAnchorEl(e.currentTarget);
};
const handleCloseNavMenu = () => {
setNavAnchorEl(null);
};
const handleSubmitFor93 = (e: React.MouseEvent) => {
e.stopPropagation();
onSubmitFor93?.(station);
};
const savedMetadata = savedStation
@@ -144,46 +165,151 @@ export const StationCard: React.FC<StationCardProps> = ({
sx={{ marginTop: 0.5 }}
/>
)}
{/* Community verified badge */}
{communityData?.isVerified && (
<Box sx={{ marginTop: 1 }}>
<CommunityVerifiedBadge
has93Octane={communityData.has93Octane}
has93OctaneEthanolFree={communityData.has93OctaneEthanolFree}
/>
</Box>
)}
</CardContent>
{/* Actions */}
{/* Actions with labels */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
justifyContent: 'space-around',
alignItems: 'flex-start',
padding: 1,
borderTop: '1px solid #e0e0e0',
minHeight: '44px',
alignItems: 'center'
gap: 0.5
}}
>
<IconButton
size="large"
onClick={handleDirections}
title="Get directions"
sx={{
minWidth: '44px',
minHeight: '44px',
padding: 1
}}
>
<DirectionsIcon />
</IconButton>
{/* Navigate button with label - opens menu */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<IconButton
size="large"
onClick={handleOpenNavMenu}
title="Get directions"
sx={{
minWidth: '44px',
minHeight: '44px',
padding: 1
}}
>
<DirectionsIcon />
</IconButton>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
color: 'text.secondary',
mt: -0.5,
textAlign: 'center'
}}
>
Navigate
</Typography>
</Box>
<IconButton
size="large"
onClick={handleSaveClick}
title={isSaved ? 'Remove from favorites' : 'Add to favorites'}
sx={{
minWidth: '44px',
minHeight: '44px',
padding: 1,
color: isSaved ? 'warning.main' : 'inherit'
}}
>
{isSaved ? <BookmarkIcon /> : <BookmarkBorderIcon />}
</IconButton>
{/* Premium 93 button with label - show when not verified (allows submission) */}
{showSubmitFor93Button && !communityData?.isVerified && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<IconButton
size="large"
onClick={handleSubmitFor93}
title="Submit for 93"
sx={{
minWidth: '44px',
minHeight: '44px',
padding: 1
}}
>
<LocalGasStationIcon />
</IconButton>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
color: 'text.secondary',
mt: -0.5,
textAlign: 'center'
}}
>
Premium 93
</Typography>
</Box>
)}
{/* Premium 93 verified indicator - show when verified */}
{communityData?.isVerified && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<IconButton
size="large"
onClick={handleSubmitFor93}
title="Community verified Premium 93"
sx={{
minWidth: '44px',
minHeight: '44px',
padding: 1,
color: 'warning.main'
}}
>
<LocalGasStationIcon />
</IconButton>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
color: 'warning.main',
mt: -0.5,
fontWeight: 500,
textAlign: 'center'
}}
>
Premium 93
</Typography>
</Box>
)}
{/* Favorite button with label */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<IconButton
size="large"
onClick={handleSaveClick}
title={isSaved ? 'Remove from favorites' : 'Add to favorites'}
sx={{
minWidth: '44px',
minHeight: '44px',
padding: 1,
color: isSaved ? 'warning.main' : 'inherit'
}}
>
{isSaved ? <BookmarkIcon /> : <BookmarkBorderIcon />}
</IconButton>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
color: isSaved ? 'warning.main' : 'text.secondary',
mt: -0.5,
textAlign: 'center'
}}
>
Favorite
</Typography>
</Box>
</Box>
{/* Navigation menu */}
<NavigationMenu
anchorEl={navAnchorEl}
station={station}
onClose={handleCloseNavMenu}
/>
</Card>
);
};

View File

@@ -12,17 +12,21 @@ import {
Button
} from '@mui/material';
import { Station, SavedStation } from '../types/stations.types';
import { CommunityStationData } from '../hooks/useEnrichedStations';
import StationCard from './StationCard';
interface StationsListProps {
stations: Station[];
savedPlaceIds?: Set<string>;
savedStationsMap?: Map<string, SavedStation>;
communityStationsMap?: Map<string, CommunityStationData>;
loading?: boolean;
error?: string | null;
onSaveStation?: (station: Station) => void;
onDeleteStation?: (placeId: string) => void;
onSelectStation?: (station: Station) => void;
onSubmitFor93?: (station: Station) => void;
showSubmitFor93Button?: boolean;
onRetry?: () => void;
}
@@ -34,13 +38,32 @@ export const StationsList: React.FC<StationsListProps> = ({
stations,
savedPlaceIds = new Set(),
savedStationsMap,
communityStationsMap,
loading = false,
error = null,
onSaveStation,
onDeleteStation,
onSelectStation,
onSubmitFor93,
showSubmitFor93Button = true,
onRetry
}) => {
/**
* Helper function to get community data for a station
* Uses normalized address to look up in the map
*/
const getCommunityData = (station: Station): CommunityStationData | undefined => {
if (!communityStationsMap) return undefined;
// Normalize address for lookup
const normalizedAddress = station.address
.toLowerCase()
.trim()
.replace(/\s+/g, ' ')
.replace(/[,]/g, '');
return communityStationsMap.get(normalizedAddress);
};
// Loading state
if (loading) {
return (
@@ -95,9 +118,12 @@ export const StationsList: React.FC<StationsListProps> = ({
station={station}
isSaved={savedPlaceIds.has(station.placeId)}
savedStation={savedStationsMap?.get(station.placeId)}
communityData={getCommunityData(station)}
onSave={onSaveStation}
onDelete={onDeleteStation}
onSelect={onSelectStation}
onSubmitFor93={onSubmitFor93}
showSubmitFor93Button={showSubmitFor93Button}
/>
</Grid>
))}

View File

@@ -0,0 +1,244 @@
/**
* @ai-summary Dialog for submitting a station for 93 octane verification
*/
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControlLabel,
Radio,
RadioGroup,
FormControl,
FormLabel,
Button,
Box,
CircularProgress,
Alert,
} from '@mui/material';
import { Station } from '../types/stations.types';
import { OctaneSubmissionType } from '../types/community-stations.types';
import { useSubmitStation, useReportRemoval } from '../hooks/useCommunityStations';
interface SubmitFor93DialogProps {
open: boolean;
onClose: () => void;
station: Station | null;
/** Optional: existing community station ID for removal reports */
communityStationId?: string;
}
/**
* Dialog to submit a station for 93 octane verification
* Mobile-first responsive design with 44px minimum touch targets
*/
export const SubmitFor93Dialog: React.FC<SubmitFor93DialogProps> = ({
open,
onClose,
station,
communityStationId,
}) => {
const [octaneType, setOctaneType] = useState<OctaneSubmissionType>('has_93_with_ethanol');
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { mutate: submitStation, isPending: isSubmitting } = useSubmitStation();
const { mutate: reportRemoval, isPending: isReporting } = useReportRemoval();
const isLoading = isSubmitting || isReporting;
// Don't render anything if station is null
if (!station) {
return null;
}
const handleSubmit = () => {
setErrorMessage(null);
if (octaneType === 'no_longer_has_93') {
// Handle removal report
if (!communityStationId) {
setErrorMessage('This station is not in the community database yet');
return;
}
reportRemoval(
{ stationId: communityStationId, reason: 'No longer has Premium 93' },
{
onSuccess: (result) => {
if (result.stationRemoved) {
setSuccessMessage('Station has been removed due to multiple reports. Thank you for helping keep data accurate.');
} else {
setSuccessMessage('Thank you for reporting. Your feedback helps keep our data accurate.');
}
setTimeout(handleClose, 2000);
},
onError: (error) => {
const errorResponse = error as { response?: { data?: { message?: string } } };
setErrorMessage(
errorResponse?.response?.data?.message ||
'Failed to submit report. Please try again.'
);
},
}
);
} else {
// Handle regular submission (with ethanol or without)
const submitData = {
name: station.name,
address: station.address,
latitude: station.latitude,
longitude: station.longitude,
has93Octane: true,
has93OctaneEthanolFree: octaneType === 'has_93_without_ethanol',
};
submitStation(submitData, {
onSuccess: () => {
setSuccessMessage('Station verified for Premium 93. Thank you!');
setTimeout(handleClose, 1500);
},
onError: (error) => {
const errorResponse = error as { response?: { data?: { message?: string } } };
setErrorMessage(
errorResponse?.response?.data?.message ||
'Failed to submit. Please try again.'
);
},
});
}
};
const handleClose = () => {
setSuccessMessage(null);
setErrorMessage(null);
setOctaneType('has_93_with_ethanol');
onClose();
};
const isRemovalSelected = octaneType === 'no_longer_has_93';
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
sx={{
'& .MuiDialog-paper': {
borderRadius: 1,
},
}}
>
<DialogTitle>Premium 93 Status</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
{/* Station name for context */}
<Box sx={{ backgroundColor: '#f5f5f5', p: 1.5, borderRadius: 1 }}>
<strong>{station.name}</strong>
<Box sx={{ fontSize: '0.875rem', color: 'text.secondary', mt: 0.5 }}>
{station.address}
</Box>
</Box>
{/* Success message */}
{successMessage && (
<Alert severity="success">{successMessage}</Alert>
)}
{/* Error message */}
{errorMessage && (
<Alert severity="error">{errorMessage}</Alert>
)}
{/* Radio buttons */}
{!successMessage && (
<FormControl component="fieldset" sx={{ mt: 1 }}>
<FormLabel component="legend" sx={{ mb: 1 }}>
Select Premium 93 status:
</FormLabel>
<RadioGroup
value={octaneType}
onChange={(e) => setOctaneType(e.target.value as OctaneSubmissionType)}
>
<FormControlLabel
value="has_93_with_ethanol"
control={
<Radio
sx={{ minWidth: '44px', minHeight: '44px' }}
disabled={isLoading}
/>
}
label="Premium 93 with Ethanol"
sx={{ ml: 0.5, my: 0.5 }}
/>
<FormControlLabel
value="has_93_without_ethanol"
control={
<Radio
sx={{ minWidth: '44px', minHeight: '44px' }}
disabled={isLoading}
/>
}
label="Premium 93 w/o Ethanol"
sx={{ ml: 0.5, my: 0.5 }}
/>
<FormControlLabel
value="no_longer_has_93"
control={
<Radio
sx={{ minWidth: '44px', minHeight: '44px' }}
disabled={isLoading || !communityStationId}
/>
}
label="No longer has Premium 93"
sx={{
ml: 0.5,
my: 0.5,
opacity: communityStationId ? 1 : 0.5
}}
/>
</RadioGroup>
{!communityStationId && (
<Box sx={{ fontSize: '0.75rem', color: 'text.secondary', mt: 0.5, ml: 4 }}>
(Report removal only available for community-verified stations)
</Box>
)}
</FormControl>
)}
</DialogContent>
<DialogActions sx={{ p: 2, gap: 1 }}>
<Button
onClick={handleClose}
disabled={isLoading}
sx={{ minHeight: '44px', px: 2 }}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={isLoading || successMessage !== null || (isRemovalSelected && !communityStationId)}
color={isRemovalSelected ? 'error' : 'primary'}
sx={{ minHeight: '44px', px: 2 }}
>
{isLoading ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Submitting...
</>
) : isRemovalSelected ? (
'Report Removal'
) : (
'Confirm'
)}
</Button>
</DialogActions>
</Dialog>
);
};
export default SubmitFor93Dialog;

View File

@@ -0,0 +1,8 @@
/**
* @ai-summary Export index for community stations components
*/
export { CommunityStationCard } from './CommunityStationCard';
export { CommunityStationsList } from './CommunityStationsList';
export { SubmitFor93Dialog } from './SubmitFor93Dialog';
export { CommunityVerifiedBadge } from './CommunityVerifiedBadge';

View File

@@ -9,3 +9,7 @@ export { SavedStationsList } from './SavedStationsList';
export { StationsSearchForm } from './StationsSearchForm';
export { StationMap } from './StationMap';
export { GoogleMapsErrorBoundary } from './GoogleMapsErrorBoundary';
export { Premium93TabContent } from './Premium93TabContent';
export { SubmitFor93Dialog } from './SubmitFor93Dialog';
export { CommunityVerifiedBadge } from './CommunityVerifiedBadge';
export { NavigationMenu } from './NavigationMenu';

View File

@@ -0,0 +1,5 @@
/**
* @ai-summary Export index for community stations hooks
*/
export * from './useCommunityStations';

View File

@@ -8,3 +8,6 @@ export { useSaveStation } from './useSaveStation';
export { useUpdateSavedStation } from './useUpdateSavedStation';
export { useDeleteStation } from './useDeleteStation';
export { useGeolocation } from './useGeolocation';
export { useEnrichedStations } from './useEnrichedStations';
export type { CommunityStationData, EnrichedStation } from './useEnrichedStations';
export { useSubmitStation, useApprovedNearbyStations } from './useCommunityStations';

View File

@@ -0,0 +1,182 @@
/**
* @ai-summary React Query hooks for Community Gas Stations feature
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { communityStationsApi } from '../api/community-stations.api';
import { SubmitStationData, ReviewDecision, StationBounds } from '../types/community-stations.types';
// Query keys
export const communityStationsKeys = {
all: ['community-stations'] as const,
submissions: ['community-stations', 'submissions'] as const,
mySubmissions: ['community-stations', 'my-submissions'] as const,
approved: ['community-stations', 'approved'] as const,
nearby: ['community-stations', 'nearby'] as const,
bounds: ['community-stations', 'bounds'] as const,
adminAll: ['community-stations', 'admin', 'all'] as const,
adminPending: ['community-stations', 'admin', 'pending'] as const,
};
/**
* Hook to submit a new community gas station
* Note: Submissions are now auto-approved
*/
export const useSubmitStation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: SubmitStationData) => communityStationsApi.submitStation(data),
onSuccess: () => {
// Invalidate all relevant caches since submissions are auto-approved
queryClient.invalidateQueries({ queryKey: communityStationsKeys.mySubmissions });
queryClient.invalidateQueries({ queryKey: communityStationsKeys.approved });
queryClient.invalidateQueries({ queryKey: communityStationsKeys.nearby });
queryClient.invalidateQueries({ queryKey: communityStationsKeys.bounds });
},
});
};
/**
* Hook to get user's submitted stations
*/
export const useMySubmissions = () => {
return useQuery({
queryKey: communityStationsKeys.mySubmissions,
queryFn: () => communityStationsApi.getMySubmissions(),
});
};
/**
* Hook to withdraw a submission
*/
export const useWithdrawSubmission = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => communityStationsApi.withdrawSubmission(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: communityStationsKeys.mySubmissions });
},
});
};
/**
* Hook to get approved community stations
*/
export const useApprovedStations = (page: number = 0, limit: number = 50) => {
return useQuery({
queryKey: [...communityStationsKeys.approved, page, limit],
queryFn: () => communityStationsApi.getApprovedStations(page, limit),
});
};
/**
* Hook to get approved stations nearby
*/
export const useApprovedNearbyStations = (
latitude: number | null,
longitude: number | null,
radiusMeters: number = 5000
) => {
return useQuery({
queryKey: [...communityStationsKeys.nearby, latitude, longitude, radiusMeters],
queryFn: () => {
if (latitude === null || longitude === null) {
return Promise.resolve([]);
}
return communityStationsApi.getApprovedNearby(latitude, longitude, radiusMeters);
},
enabled: latitude !== null && longitude !== null,
});
};
/**
* Hook to get all submissions (admin)
*/
export const useAllCommunitySubmissions = (
status?: string,
page: number = 0,
limit: number = 50
) => {
return useQuery({
queryKey: [...communityStationsKeys.adminAll, status, page, limit],
queryFn: () => communityStationsApi.getAllSubmissions(status, page, limit),
});
};
/**
* Hook to get pending submissions (admin)
*/
export const usePendingSubmissions = (page: number = 0, limit: number = 50) => {
return useQuery({
queryKey: [...communityStationsKeys.adminPending, page, limit],
queryFn: () => communityStationsApi.getPendingSubmissions(page, limit),
});
};
/**
* Hook to review a submission (admin)
*/
export const useReviewStation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, decision }: { id: string; decision: ReviewDecision }) =>
communityStationsApi.reviewStation(id, decision),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: communityStationsKeys.adminPending });
queryClient.invalidateQueries({ queryKey: communityStationsKeys.adminAll });
},
});
};
/**
* Hook to bulk review submissions (admin)
*/
export const useBulkReviewStations = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ ids, decision }: { ids: string[]; decision: ReviewDecision }) =>
communityStationsApi.bulkReviewStations(ids, decision),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: communityStationsKeys.adminPending });
queryClient.invalidateQueries({ queryKey: communityStationsKeys.adminAll });
},
});
};
/**
* Hook to get approved stations within map bounds
*/
export const useApprovedStationsInBounds = (bounds: StationBounds | null) => {
return useQuery({
queryKey: [...communityStationsKeys.bounds, bounds],
queryFn: () => {
if (!bounds) {
return Promise.resolve([]);
}
return communityStationsApi.getApprovedInBounds(bounds);
},
enabled: bounds !== null,
});
};
/**
* Hook to report a station as no longer having Premium 93
*/
export const useReportRemoval = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ stationId, reason }: { stationId: string; reason?: string }) =>
communityStationsApi.reportRemoval(stationId, reason),
onSuccess: () => {
// Invalidate approved stations lists as station may have been removed
queryClient.invalidateQueries({ queryKey: communityStationsKeys.approved });
queryClient.invalidateQueries({ queryKey: communityStationsKeys.nearby });
queryClient.invalidateQueries({ queryKey: communityStationsKeys.bounds });
},
});
};

View File

@@ -0,0 +1,182 @@
/**
* @ai-summary Hook to enrich Google Maps search results with community station data
*/
import { useMemo } from 'react';
import { Station } from '../types/stations.types';
import { CommunityStation } from '../types/community-stations.types';
import { useApprovedNearbyStations } from './useCommunityStations';
import { calculateDistance } from '../utils/distance';
/**
* Community-verified data for a station
*/
export interface CommunityStationData {
/** Whether the station has 93 octane fuel */
has93Octane: boolean;
/** Whether the 93 octane is ethanol free */
has93OctaneEthanolFree: boolean;
/** Whether this data comes from approved community submissions */
isVerified: boolean;
/** When this community data was last verified */
verifiedAt?: string;
/** The community station ID for reference */
communityStationId?: string;
}
/**
* Station enriched with community verification data
*/
export interface EnrichedStation extends Station {
/** Community verification data if available */
communityData?: CommunityStationData;
}
/**
* Normalize an address for comparison
* Removes extra whitespace and converts to lowercase
*/
function normalizeAddress(address: string): string {
return address
.toLowerCase()
.trim()
.replace(/\s+/g, ' ')
.replace(/[,]/g, '');
}
/**
* Check if a search result matches a community station
* Uses multiple strategies: address matching and distance threshold
*/
function matchesStation(
searchResult: Station,
communityStation: CommunityStation,
matchDistanceThresholdMeters: number = 50
): boolean {
// Strategy 1: Compare normalized addresses
const normalizedSearchAddress = normalizeAddress(searchResult.address);
const normalizedCommunityAddress = normalizeAddress(communityStation.address);
if (normalizedSearchAddress === normalizedCommunityAddress) {
return true;
}
// Strategy 2: Check if addresses contain common elements
const searchParts = normalizedSearchAddress.split(' ');
const communityParts = normalizedCommunityAddress.split(' ');
// If both have street numbers and they don't match, not the same station
const searchStreetNum = searchParts[0];
const communityStreetNum = communityParts[0];
if (
searchStreetNum &&
communityStreetNum &&
/^\d+$/.test(searchStreetNum) &&
/^\d+$/.test(communityStreetNum)
) {
if (searchStreetNum !== communityStreetNum) {
return false;
}
}
// Strategy 3: Use distance threshold as a final check
const distance = calculateDistance(
searchResult.latitude,
searchResult.longitude,
communityStation.latitude,
communityStation.longitude
);
return distance <= matchDistanceThresholdMeters;
}
/**
* Enrich search results with community station verification data
*
* @param searchResults - Gas stations from Google Maps search
* @param latitude - User's current latitude (or null if unavailable)
* @param longitude - User's current longitude (or null if unavailable)
* @param radiusMeters - Search radius for community stations (default: 5000m)
* @returns Object containing enriched stations, loading state, and community data map
*/
export function useEnrichedStations(
searchResults: Station[],
latitude: number | null,
longitude: number | null,
radiusMeters: number = 5000
): {
enrichedStations: EnrichedStation[];
isLoading: boolean;
communityStationsMap: Map<string, CommunityStationData>;
} {
// Fetch approved nearby community stations
const { data: communityStations = [], isLoading } = useApprovedNearbyStations(
latitude,
longitude,
radiusMeters
);
// Enrich search results with community data
const result = useMemo(() => {
// If we don't have coordinates or no search results, return unchanged
if (latitude === null || longitude === null || searchResults.length === 0) {
return {
enrichedStations: searchResults,
communityStationsMap: new Map(),
};
}
// Create a map of community data indexed by placeId and address
const communityStationsMap = new Map<string, CommunityStationData>();
communityStations.forEach((communityStation: CommunityStation) => {
const data: CommunityStationData = {
has93Octane: communityStation.has93Octane,
has93OctaneEthanolFree: communityStation.has93OctaneEthanolFree,
isVerified: communityStation.status === 'approved',
verifiedAt: communityStation.reviewedAt,
communityStationId: communityStation.id,
};
// Store by normalized address as primary key
const normalizedAddress = normalizeAddress(communityStation.address);
communityStationsMap.set(normalizedAddress, data);
});
// Enrich each search result
const enrichedStations: EnrichedStation[] = searchResults.map(
(station: Station) => {
// Try to find a matching community station
const matchingCommunity = communityStations.find((community) =>
matchesStation(station, community)
);
if (matchingCommunity) {
return {
...station,
communityData: {
has93Octane: matchingCommunity.has93Octane,
has93OctaneEthanolFree: matchingCommunity.has93OctaneEthanolFree,
isVerified: matchingCommunity.status === 'approved',
verifiedAt: matchingCommunity.reviewedAt,
communityStationId: matchingCommunity.id,
},
};
}
return station;
}
);
return {
enrichedStations,
communityStationsMap,
};
}, [searchResults, communityStations, latitude, longitude]);
return {
enrichedStations: result.enrichedStations,
isLoading,
communityStationsMap: result.communityStationsMap,
};
}

View File

@@ -1,6 +1,6 @@
/**
* @ai-summary Mobile-optimized gas stations screen with bottom tab navigation
* @ai-context Three tabs: Search, Saved, Map with responsive mobile-first design
* @ai-context Four tabs: Search, Saved, Premium 93, Map with responsive mobile-first design
*/
import React, { useState, useCallback, useMemo } from 'react';
@@ -19,12 +19,15 @@ import {
import SearchIcon from '@mui/icons-material/Search';
import BookmarkIcon from '@mui/icons-material/Bookmark';
import MapIcon from '@mui/icons-material/Map';
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
import CloseIcon from '@mui/icons-material/Close';
import { StationsSearchForm } from '../components/StationsSearchForm';
import { StationsList } from '../components/StationsList';
import { SavedStationsList } from '../components/SavedStationsList';
import { StationMap } from '../components/StationMap';
import { Premium93TabContent } from '../components/Premium93TabContent';
import { SubmitFor93Dialog } from '../components/SubmitFor93Dialog';
import {
useStationsSearch,
@@ -32,7 +35,8 @@ import {
useSaveStation,
useDeleteStation,
useUpdateSavedStation,
useGeolocation
useGeolocation,
useEnrichedStations
} from '../hooks';
import {
@@ -41,13 +45,15 @@ import {
StationSearchRequest,
OctanePreference
} from '../types/stations.types';
import { CommunityStation, StationBounds } from '../types/community-stations.types';
import { octanePreferenceToFlags, resolveSavedStationPlaceId } from '../utils/savedStations';
import { buildNavigationLinks } from '../utils/navigation-links';
// Tab indices
const TAB_SEARCH = 0;
const TAB_SAVED = 1;
const TAB_MAP = 2;
const TAB_PREMIUM_93 = 2;
const TAB_MAP = 3;
// iOS swipeable drawer configuration
const iOS = typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent);
@@ -62,6 +68,8 @@ export const StationsMobileScreen: React.FC = () => {
const [selectedStation, setSelectedStation] = useState<Station | SavedStation | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [octaneUpdatingId, setOctaneUpdatingId] = useState<string | null>(null);
const [submitFor93Station, setSubmitFor93Station] = useState<Station | null>(null);
const [searchBounds, setSearchBounds] = useState<StationBounds | null>(null);
// Hooks
const { coordinates } = useGeolocation();
@@ -82,9 +90,17 @@ export const StationsMobileScreen: React.FC = () => {
const { mutateAsync: deleteStation } = useDeleteStation();
const { mutateAsync: updateSavedStation } = useUpdateSavedStation();
// Compute set of saved place IDs for quick lookup
const { savedStationsMap, savedPlaceIds } = useMemo(() => {
// Enrich search results with community station data
const { enrichedStations, communityStationsMap } = useEnrichedStations(
searchResults || [],
coordinates?.latitude ?? null,
coordinates?.longitude ?? null
);
// Compute set of saved place IDs and addresses for quick lookup
const { savedStationsMap, savedPlaceIds, savedAddresses } = useMemo(() => {
const map = new Map<string, SavedStation>();
const addresses = new Set<string>();
(savedStations || []).forEach((station) => {
const placeId = resolveSavedStationPlaceId(station);
@@ -96,16 +112,34 @@ export const StationsMobileScreen: React.FC = () => {
station.placeId === placeId ? station : { ...station, placeId };
map.set(placeId, normalizedStation);
// Also track addresses for community station matching
if (station.address) {
addresses.add(station.address.toLowerCase().trim());
}
});
return {
savedStationsMap: map,
savedPlaceIds: new Set(map.keys())
savedPlaceIds: new Set(map.keys()),
savedAddresses: addresses
};
}, [savedStations]);
// Handle search submission
const handleSearch = useCallback((request: StationSearchRequest) => {
// Calculate approximate bounds from search location and radius
const radiusKm = (request.radius || 5000) / 1000;
const latDelta = radiusKm / 111; // ~111km per degree latitude
const lngDelta = radiusKm / (111 * Math.cos(request.latitude * Math.PI / 180));
setSearchBounds({
north: request.latitude + latDelta,
south: request.latitude - latDelta,
east: request.longitude + lngDelta,
west: request.longitude - lngDelta
});
performSearch(request);
}, [performSearch]);
@@ -115,6 +149,16 @@ export const StationsMobileScreen: React.FC = () => {
setDrawerOpen(true);
}, []);
// Handle station selection from Premium 93 tab (supports CommunityStation)
const handleSelectPremium93Station = useCallback((station: Station | CommunityStation) => {
// CommunityStation doesn't have the same fields as Station/SavedStation
// For now, only handle actual Station types
if ('placeId' in station || 'isFavorite' in station) {
setSelectedStation(station as Station | SavedStation);
setDrawerOpen(true);
}
}, []);
// Handle save station
const handleSaveStation = useCallback(async (station: Station) => {
try {
@@ -227,6 +271,13 @@ export const StationsMobileScreen: React.FC = () => {
label="Saved"
sx={{ minHeight: 56 }}
/>
<Tab
value={TAB_PREMIUM_93}
icon={<LocalGasStationIcon fontSize="small" />}
iconPosition="start"
label="Premium 93"
sx={{ minHeight: 56 }}
/>
<Tab
value={TAB_MAP}
icon={<MapIcon fontSize="small" />}
@@ -253,10 +304,10 @@ export const StationsMobileScreen: React.FC = () => {
isSearching={isSearching}
/>
{searchResults && (
{enrichedStations.length > 0 && (
<Box sx={{ mt: 3 }}>
<StationsList
stations={searchResults}
stations={enrichedStations}
savedPlaceIds={savedPlaceIds}
savedStationsMap={savedStationsMap}
loading={isSearching}
@@ -265,6 +316,8 @@ export const StationsMobileScreen: React.FC = () => {
onDeleteStation={handleDeleteStation}
onSelectStation={handleSelectStation}
onRetry={handleRefresh}
communityStationsMap={communityStationsMap}
onSubmitFor93={(station) => setSubmitFor93Station(station)}
/>
</Box>
)}
@@ -282,6 +335,22 @@ export const StationsMobileScreen: React.FC = () => {
onDeleteStation={handleDeleteStation}
onOctanePreferenceChange={handleOctanePreferenceChange}
octaneUpdatingId={octaneUpdatingId}
onSubmitFor93={(station) => setSubmitFor93Station(station as unknown as Station)}
/>
</Box>
)}
{/* Premium 93 Tab */}
{activeTab === TAB_PREMIUM_93 && (
<Box sx={{ p: 2 }}>
<Premium93TabContent
latitude={coordinates?.latitude ?? null}
longitude={coordinates?.longitude ?? null}
savedStations={savedStations?.filter(s => s.has93Octane) || []}
onStationSelect={handleSelectPremium93Station}
searchBounds={searchBounds}
onSubmitFor93={(station) => setSubmitFor93Station(station as unknown as Station)}
savedAddresses={savedAddresses}
/>
</Box>
)}
@@ -290,7 +359,7 @@ export const StationsMobileScreen: React.FC = () => {
{activeTab === TAB_MAP && (
<Box sx={{ height: '100%', position: 'relative' }}>
<StationMap
stations={searchResults || []}
stations={enrichedStations}
savedPlaceIds={savedPlaceIds}
currentLocation={coordinates ? {
latitude: coordinates.latitude,
@@ -472,6 +541,22 @@ export const StationsMobileScreen: React.FC = () => {
</Box>
)}
</SwipeableDrawer>
{/* Submit for 93 Dialog */}
<SubmitFor93Dialog
open={!!submitFor93Station}
onClose={() => setSubmitFor93Station(null)}
station={submitFor93Station}
communityStationId={
submitFor93Station
? // If it's a CommunityStation from Premium 93 tab, use its id directly
('status' in submitFor93Station && submitFor93Station.status === 'approved')
? (submitFor93Station as unknown as CommunityStation).id
// Otherwise look up in map (for search results)
: communityStationsMap.get(submitFor93Station.address?.toLowerCase().trim() || '')?.communityStationId
: undefined
}
/>
</Box>
);
};

View File

@@ -15,7 +15,9 @@ import {
CircularProgress,
Typography
} from '@mui/material';
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
import { OctanePreference, SavedStation, Station, StationSearchRequest } from '../types/stations.types';
import { CommunityStation, StationBounds } from '../types/community-stations.types';
import {
useStationsSearch,
useSavedStations,
@@ -28,9 +30,12 @@ import {
StationsList,
SavedStationsList,
StationsSearchForm,
GoogleMapsErrorBoundary
GoogleMapsErrorBoundary,
SubmitFor93Dialog,
Premium93TabContent
} from '../components';
import { octanePreferenceToFlags, resolveSavedStationPlaceId } from '../utils/savedStations';
import { useEnrichedStations } from '../hooks/useEnrichedStations';
interface TabPanelProps {
children?: React.ReactNode;
@@ -75,6 +80,8 @@ export const StationsPage: React.FC = () => {
>();
const [isPageReady, setIsPageReady] = useState(false);
const [isMapReady, setIsMapReady] = useState(false);
const [submitFor93Station, setSubmitFor93Station] = useState<Station | null>(null);
const [searchBounds, setSearchBounds] = useState<StationBounds | null>(null);
// Queries and mutations
const { mutate: search, isPending: isSearching, error: searchError } = useStationsSearch();
@@ -87,6 +94,13 @@ export const StationsPage: React.FC = () => {
error: savedError
});
// Enrich search results with community station data
const { enrichedStations, communityStationsMap } = useEnrichedStations(
searchResults,
currentLocation?.latitude ?? null,
currentLocation?.longitude ?? null
);
// Multi-stage initialization: Wait for auth, data, and DOM
useEffect(() => {
// Stage 1: Wait for saved stations query to settle (loading complete or error)
@@ -124,9 +138,10 @@ export const StationsPage: React.FC = () => {
const { mutate: updateSavedStation } = useUpdateSavedStation();
const [octaneUpdatingId, setOctaneUpdatingId] = useState<string | null>(null);
// Create set of saved place IDs for quick lookup
const { savedStationsMap, savedPlaceIds } = useMemo(() => {
// Create set of saved place IDs and addresses for quick lookup
const { savedStationsMap, savedPlaceIds, savedAddresses } = useMemo(() => {
const map = new Map<string, SavedStation>();
const addresses = new Set<string>();
savedStations.forEach((station) => {
const placeId = resolveSavedStationPlaceId(station);
@@ -138,11 +153,17 @@ export const StationsPage: React.FC = () => {
station.placeId === placeId ? station : { ...station, placeId };
map.set(placeId, normalizedStation);
// Also track addresses for community station matching
if (station.address) {
addresses.add(station.address.toLowerCase().trim());
}
});
return {
savedStationsMap: map,
savedPlaceIds: new Set(map.keys())
savedPlaceIds: new Set(map.keys()),
savedAddresses: addresses
};
}, [savedStations]);
@@ -156,6 +177,15 @@ export const StationsPage: React.FC = () => {
station.longitude !== undefined
) as Station[];
}
if (tabValue === 2) {
// Premium 93 tab: show saved stations with 93 octane
return savedStations.filter(
(station) =>
station.has93Octane &&
station.latitude !== undefined &&
station.longitude !== undefined
) as Station[];
}
// Results tab: show search results
return searchResults;
}, [tabValue, savedStations, searchResults]);
@@ -165,6 +195,18 @@ export const StationsPage: React.FC = () => {
setCurrentLocation({ latitude: request.latitude, longitude: request.longitude });
setMapCenter({ lat: request.latitude, lng: request.longitude });
// Calculate approximate bounds from search location and radius
const radiusKm = (request.radius || 5000) / 1000;
const latDelta = radiusKm / 111; // ~111km per degree latitude
const lngDelta = radiusKm / (111 * Math.cos(request.latitude * Math.PI / 180));
setSearchBounds({
north: request.latitude + latDelta,
south: request.latitude - latDelta,
east: request.longitude + lngDelta,
west: request.longitude - lngDelta
});
search(request, {
onSuccess: (stations) => {
setSearchResults(stations);
@@ -215,7 +257,7 @@ export const StationsPage: React.FC = () => {
);
// Handle station selection - wrapped in useCallback to prevent infinite renders
const handleSelectStation = useCallback((station: Station) => {
const handleSelectStation = useCallback((station: Station | CommunityStation) => {
setMapCenter({
lat: station.latitude,
lng: station.longitude
@@ -283,17 +325,25 @@ export const StationsPage: React.FC = () => {
>
<Tab label={`Results (${searchResults.length})`} id="stations-tab-0" />
<Tab label={`Saved (${savedStations.length})`} id="stations-tab-1" />
<Tab
label="Premium 93"
icon={<LocalGasStationIcon />}
iconPosition="start"
id="stations-tab-2"
/>
</Tabs>
<TabPanel value={tabValue} index={0}>
<StationsList
stations={searchResults}
stations={enrichedStations}
savedPlaceIds={savedPlaceIds}
savedStationsMap={savedStationsMap}
loading={isSearching}
error={searchError ? (searchError as any).message : null}
onSaveStation={handleSave}
onDeleteStation={handleDelete}
communityStationsMap={communityStationsMap}
onSubmitFor93={(station) => setSubmitFor93Station(station)}
/>
</TabPanel>
@@ -306,6 +356,19 @@ export const StationsPage: React.FC = () => {
onDeleteStation={handleDelete}
onOctanePreferenceChange={handleOctanePreferenceChange}
octaneUpdatingId={octaneUpdatingId}
onSubmitFor93={(station) => setSubmitFor93Station(station as unknown as Station)}
/>
</TabPanel>
<TabPanel value={tabValue} index={2}>
<Premium93TabContent
latitude={currentLocation?.latitude ?? null}
longitude={currentLocation?.longitude ?? null}
savedStations={savedStations?.filter(s => s.has93Octane) || []}
onStationSelect={handleSelectStation}
searchBounds={searchBounds}
onSubmitFor93={(station) => setSubmitFor93Station(station as unknown as Station)}
savedAddresses={savedAddresses}
/>
</TabPanel>
</Box>
@@ -386,12 +449,18 @@ export const StationsPage: React.FC = () => {
>
<Tab label={`Results (${searchResults.length})`} id="stations-tab-0" />
<Tab label={`Saved (${savedStations.length})`} id="stations-tab-1" />
<Tab
label="Premium 93"
icon={<LocalGasStationIcon />}
iconPosition="start"
id="stations-tab-2"
/>
</Tabs>
<Box sx={{ flex: 1, overflow: 'auto', padding: 2 }}>
<TabPanel value={tabValue} index={0}>
<StationsList
stations={searchResults}
stations={enrichedStations}
savedPlaceIds={savedPlaceIds}
savedStationsMap={savedStationsMap}
loading={isSearching}
@@ -399,6 +468,8 @@ export const StationsPage: React.FC = () => {
onSaveStation={handleSave}
onDeleteStation={handleDelete}
onSelectStation={handleSelectStation}
communityStationsMap={communityStationsMap}
onSubmitFor93={(station) => setSubmitFor93Station(station)}
/>
</TabPanel>
@@ -411,10 +482,38 @@ export const StationsPage: React.FC = () => {
onDeleteStation={handleDelete}
onOctanePreferenceChange={handleOctanePreferenceChange}
octaneUpdatingId={octaneUpdatingId}
onSubmitFor93={(station) => setSubmitFor93Station(station as unknown as Station)}
/>
</TabPanel>
<TabPanel value={tabValue} index={2}>
<Premium93TabContent
latitude={currentLocation?.latitude ?? null}
longitude={currentLocation?.longitude ?? null}
savedStations={savedStations?.filter(s => s.has93Octane) || []}
onStationSelect={handleSelectStation}
searchBounds={searchBounds}
onSubmitFor93={(station) => setSubmitFor93Station(station as unknown as Station)}
savedAddresses={savedAddresses}
/>
</TabPanel>
</Box>
</Paper>
<SubmitFor93Dialog
open={!!submitFor93Station}
onClose={() => setSubmitFor93Station(null)}
station={submitFor93Station}
communityStationId={
submitFor93Station
? // If it's a CommunityStation from Premium 93 tab, use its id directly
('status' in submitFor93Station && submitFor93Station.status === 'approved')
? (submitFor93Station as unknown as CommunityStation).id
// Otherwise look up in map (for search results)
: communityStationsMap.get(submitFor93Station.address?.toLowerCase().trim() || '')?.communityStationId
: undefined
}
/>
</Box>
);
};

View File

@@ -0,0 +1,78 @@
/**
* @ai-summary Type definitions for Community Gas Stations feature
*/
/** Status for community station submissions */
export type CommunityStationStatus = 'pending' | 'approved' | 'rejected' | 'removed';
/** Octane submission type for radio button selection */
export type OctaneSubmissionType =
| 'has_93_with_ethanol'
| 'has_93_without_ethanol'
| 'no_longer_has_93';
export interface CommunityStation {
id: string;
submittedBy: string;
name: string;
address: string;
city?: string;
state?: string;
zipCode?: string;
latitude: number;
longitude: number;
brand?: string;
has93Octane: boolean;
has93OctaneEthanolFree: boolean;
price93?: number;
notes?: string;
status: CommunityStationStatus;
reviewedBy?: string;
reviewedAt?: string;
rejectionReason?: string;
removalReportCount?: number;
removedAt?: string;
createdAt: string;
updatedAt: string;
}
export interface SubmitStationData {
name: string;
address: string;
city?: string;
state?: string;
zipCode?: string;
latitude: number;
longitude: number;
brand?: string;
has93Octane: boolean;
has93OctaneEthanolFree: boolean;
price93?: number;
notes?: string;
}
export interface ReviewDecision {
status: 'approved' | 'rejected';
rejectionReason?: string;
}
export interface CommunityStationsListResponse {
stations: CommunityStation[];
total: number;
page: number;
limit: number;
}
/** Bounding box for map-based station queries */
export interface StationBounds {
north: number;
south: number;
east: number;
west: number;
}
/** Response from submitting a removal report */
export interface RemovalReportResponse {
reportCount: number;
stationRemoved: boolean;
}

View File

@@ -2,9 +2,15 @@
* @ai-summary Helpers to build navigation URLs for stations
*/
import { SavedStation, Station } from '../types/stations.types';
type StationLike = Pick<Station, 'placeId' | 'name' | 'address' | 'latitude' | 'longitude'> & Partial<SavedStation>;
// StationLike requires name, address, latitude, longitude for navigation
// placeId is optional (CommunityStation doesn't have it)
export interface StationLike {
name: string;
address: string;
latitude: number;
longitude: number;
placeId?: string;
}
const hasValidCoordinates = (station: StationLike): boolean => {
const { latitude, longitude } = station;