Community 93 Premium feature complete
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
236
frontend/src/features/admin/pages/AdminCommunityStationsPage.tsx
Normal file
236
frontend/src/features/admin/pages/AdminCommunityStationsPage.tsx
Normal 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;
|
||||
426
frontend/src/features/stations/COMMUNITY-STATIONS-README.md
Normal file
426
frontend/src/features/stations/COMMUNITY-STATIONS-README.md
Normal 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
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
204
frontend/src/features/stations/api/community-stations.api.ts
Normal file
204
frontend/src/features/stations/api/community-stations.api.ts
Normal 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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
69
frontend/src/features/stations/components/NavigationMenu.tsx
Normal file
69
frontend/src/features/stations/components/NavigationMenu.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
244
frontend/src/features/stations/components/SubmitFor93Dialog.tsx
Normal file
244
frontend/src/features/stations/components/SubmitFor93Dialog.tsx
Normal 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;
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
5
frontend/src/features/stations/hooks/index-community.ts
Normal file
5
frontend/src/features/stations/hooks/index-community.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* @ai-summary Export index for community stations hooks
|
||||
*/
|
||||
|
||||
export * from './useCommunityStations';
|
||||
@@ -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';
|
||||
|
||||
182
frontend/src/features/stations/hooks/useCommunityStations.ts
Normal file
182
frontend/src/features/stations/hooks/useCommunityStations.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
182
frontend/src/features/stations/hooks/useEnrichedStations.ts
Normal file
182
frontend/src/features/stations/hooks/useEnrichedStations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user