YeboCars Frontend
React application architecture with TikTok-style car browsing and dealer dashboard.
Technology Stack
| Technology | Purpose |
|---|---|
| React | UI framework |
| TypeScript | Type safety |
| React Router | Navigation |
| TanStack Query | Server state management |
| Zustand | Client state |
| Tailwind CSS | Styling |
| Framer Motion | Animations |
Project Structure
src/
├── components/
│ ├── TikTokBrowser/ # Main car browser
│ │ ├── cards/ # Full-screen car cards
│ │ ├── media/ # Media player components
│ │ ├── modals/ # Search, filters, contact
│ │ ├── slider/ # Car slider logic
│ │ ├── ui/ # Car info, actions
│ │ └── desktop/ # Desktop layout
│ ├── auth/ # Login, signup, OTP
│ ├── dealer/ # Dealer dashboard
│ ├── search/ # Search components
│ └── common/ # Shared components
├── pages/ # Route pages
├── hooks/ # Custom React hooks
├── services/ # API clients
├── stores/ # Zustand stores
├── types/ # TypeScript types
└── utils/ # Helper functionsCore Components
TikTok-Style Car Browser
Full-screen vertical swipe interface for car discovery:
tsx
// components/TikTokBrowser/cards/FullScreenCarCard.tsx
export function FullScreenCarCard({ car, onSwipe, onInquiry }) {
return (
<div className="h-screen w-full relative snap-start">
{/* Full-screen media */}
<MediaPlayer
media={car.images}
videos={car.videos}
className="absolute inset-0 object-cover"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
{/* Car info (bottom left) */}
<CarInfo
make={car.make}
model={car.model}
year={car.year}
price={car.formattedPrice}
mileage={car.mileage}
seller={car.sellerName}
className="absolute bottom-20 left-4"
/>
{/* Action buttons (bottom right) */}
<CarActions
carId={car.id}
favorites={car.favorites}
onInquiry={onInquiry}
onFavorite={() => handleFavorite(car.id)}
onShare={() => handleShare(car)}
className="absolute bottom-20 right-4"
/>
{/* Quick specs badge */}
<div className="absolute top-4 left-4">
<SpecsBadge
fuelType={car.fuelType}
transmission={car.transmission}
bodyType={car.bodyType}
/>
</div>
</div>
);
}Car Info Component
tsx
// components/TikTokBrowser/ui/CarInfo.tsx
export function CarInfo({ make, model, year, price, mileage, seller, className }) {
return (
<div className={cn("text-white", className)}>
{/* Title */}
<h1 className="text-2xl font-bold">
{year} {make} {model}
</h1>
{/* Price */}
<p className="text-3xl font-bold text-orange-400 mt-1">
{price}
</p>
{/* Mileage */}
<p className="text-sm text-gray-300 mt-1">
{mileage.toLocaleString()} km
</p>
{/* Seller */}
<div className="flex items-center gap-2 mt-3">
<Avatar src={seller.avatar} size="sm" />
<div>
<p className="text-sm font-medium">{seller.name}</p>
{seller.isDealer && (
<Badge variant="dealer" size="xs">Dealer</Badge>
)}
</div>
</div>
</div>
);
}Car Actions Component
tsx
// components/TikTokBrowser/ui/CarActions.tsx
export function CarActions({ carId, favorites, onInquiry, onFavorite, onShare }) {
const [isFavorited, setIsFavorited] = useState(false);
return (
<div className="flex flex-col gap-4 items-center">
{/* Favorite */}
<ActionButton
icon={isFavorited ? HeartFilledIcon : HeartIcon}
count={favorites}
active={isFavorited}
onClick={() => {
setIsFavorited(!isFavorited);
onFavorite();
}}
/>
{/* Inquiry */}
<ActionButton
icon={MessageIcon}
label="Inquire"
onClick={onInquiry}
/>
{/* Test Drive */}
<ActionButton
icon={CarIcon}
label="Test Drive"
onClick={() => openTestDriveModal(carId)}
/>
{/* Share */}
<ActionButton
icon={ShareIcon}
onClick={onShare}
/>
</div>
);
}Search Components
AI Search Modal
tsx
// components/search/AISearchModal.tsx
export function AISearchModal({ onClose, onResults }) {
const [query, setQuery] = useState('');
const aiSearch = useMutation({
mutationFn: (q: string) => api.ai.search(q),
onSuccess: (data) => {
onResults(data.availableCars);
onClose();
}
});
const suggestions = [
"Reliable family SUV under $30,000",
"Electric car with good range",
"Affordable first car for a student",
"Luxury sedan for business"
];
return (
<FullScreenModal onClose={onClose}>
<div className="p-4">
<h2 className="text-xl font-bold mb-4">Find Your Perfect Car</h2>
{/* Search input */}
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Describe what you're looking for..."
className="w-full p-4 pr-12 border rounded-xl text-lg"
autoFocus
/>
<button
onClick={() => aiSearch.mutate(query)}
disabled={query.length < 3}
className="absolute right-2 top-2 p-2 bg-orange-500 rounded-lg"
>
<SearchIcon className="w-6 h-6 text-white" />
</button>
</div>
{/* Suggestions */}
<div className="mt-6">
<p className="text-sm text-gray-500 mb-3">Try searching for:</p>
<div className="flex flex-wrap gap-2">
{suggestions.map((suggestion) => (
<button
key={suggestion}
onClick={() => {
setQuery(suggestion);
aiSearch.mutate(suggestion);
}}
className="px-3 py-2 bg-gray-100 rounded-full text-sm"
>
{suggestion}
</button>
))}
</div>
</div>
{/* Search intent interpretation */}
{aiSearch.data?.searchIntent && (
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-600">
🤖 Looking for: {aiSearch.data.searchIntent.intent}
</p>
<p className="text-xs text-blue-400 mt-1">
Confidence: {(aiSearch.data.searchIntent.confidence * 100).toFixed(0)}%
</p>
</div>
)}
</div>
</FullScreenModal>
);
}Filter Panel
tsx
// components/search/FilterPanel.tsx
export function FilterPanel({ filters, onChange }) {
return (
<div className="p-4 space-y-6">
{/* Make */}
<div>
<label className="block text-sm font-medium mb-2">Make</label>
<Select
value={filters.make}
onChange={(v) => onChange({ ...filters, make: v })}
options={makes}
placeholder="Any make"
/>
</div>
{/* Price Range */}
<div>
<label className="block text-sm font-medium mb-2">Price Range</label>
<div className="flex gap-2">
<Input
type="number"
value={filters.minPrice}
onChange={(e) => onChange({ ...filters, minPrice: e.target.value })}
placeholder="Min"
/>
<Input
type="number"
value={filters.maxPrice}
onChange={(e) => onChange({ ...filters, maxPrice: e.target.value })}
placeholder="Max"
/>
</div>
</div>
{/* Year Range */}
<div>
<label className="block text-sm font-medium mb-2">Year</label>
<RangeSlider
min={1990}
max={new Date().getFullYear()}
value={[filters.minYear, filters.maxYear]}
onChange={([min, max]) => onChange({ ...filters, minYear: min, maxYear: max })}
/>
</div>
{/* Fuel Type */}
<div>
<label className="block text-sm font-medium mb-2">Fuel Type</label>
<ChipGroup
options={['Petrol', 'Diesel', 'Electric', 'Hybrid']}
value={filters.fuelType}
onChange={(v) => onChange({ ...filters, fuelType: v })}
/>
</div>
{/* Transmission */}
<div>
<label className="block text-sm font-medium mb-2">Transmission</label>
<ChipGroup
options={['Automatic', 'Manual']}
value={filters.transmission}
onChange={(v) => onChange({ ...filters, transmission: v })}
/>
</div>
{/* Body Type */}
<div>
<label className="block text-sm font-medium mb-2">Body Type</label>
<ChipGroup
options={['Sedan', 'SUV', 'Hatchback', 'Truck', 'Coupe', 'Van']}
value={filters.bodyType}
onChange={(v) => onChange({ ...filters, bodyType: v })}
/>
</div>
</div>
);
}Dealer Dashboard
Dashboard Overview
tsx
// components/dealer/DealerDashboard.tsx
export function DealerDashboard() {
const { data: stats } = useQuery({
queryKey: ['dealer-stats'],
queryFn: api.dealer.getStats
});
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">Dealer Dashboard</h1>
{/* Stats cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<StatCard
title="Active Listings"
value={stats.totalListings}
icon={CarIcon}
/>
<StatCard
title="Total Views"
value={stats.totalViews}
icon={EyeIcon}
trend="+12%"
/>
<StatCard
title="Inquiries"
value={stats.monthlyInquiries}
icon={MessageIcon}
trend="+5%"
/>
<StatCard
title="Test Drives"
value={stats.monthlyTestDrives}
icon={CalendarIcon}
/>
</div>
{/* Quick actions */}
<div className="flex gap-4 mb-8">
<Button onClick={() => navigate('/dealer/add-car')}>
<PlusIcon /> Add New Listing
</Button>
<Button variant="outline" onClick={() => navigate('/dealer/inventory')}>
Manage Inventory
</Button>
</div>
{/* Recent leads */}
<section>
<h2 className="text-lg font-semibold mb-4">Recent Leads</h2>
<LeadsList leads={stats.recentLeads} />
</section>
</div>
);
}Inventory Management
tsx
// components/dealer/InventoryManager.tsx
export function InventoryManager() {
const { data: inventory } = useQuery({
queryKey: ['dealer-inventory'],
queryFn: api.dealer.getInventory
});
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Inventory</h1>
<Button onClick={() => navigate('/dealer/add-car')}>
Add New Car
</Button>
</div>
{/* Filters */}
<div className="flex gap-4 mb-6">
<Select
placeholder="Status"
options={['All', 'Active', 'Pending', 'Sold']}
/>
<Input placeholder="Search by make, model..." />
</div>
{/* Car list */}
<div className="bg-white rounded-lg shadow">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="p-4 text-left">Car</th>
<th className="p-4 text-left">Price</th>
<th className="p-4 text-left">Status</th>
<th className="p-4 text-left">Views</th>
<th className="p-4 text-left">Actions</th>
</tr>
</thead>
<tbody>
{inventory?.cars.map(car => (
<tr key={car.id} className="border-t">
<td className="p-4">
<div className="flex items-center gap-3">
<img src={car.images[0]} className="w-16 h-12 object-cover rounded" />
<div>
<p className="font-medium">{car.year} {car.make} {car.model}</p>
<p className="text-sm text-gray-500">{car.mileage.toLocaleString()} km</p>
</div>
</div>
</td>
<td className="p-4">{car.formattedPrice}</td>
<td className="p-4">
<StatusBadge status={car.status} />
</td>
<td className="p-4">{car.views}</td>
<td className="p-4">
<Dropdown>
<DropdownItem onClick={() => navigate(`/dealer/edit/${car.id}`)}>
Edit
</DropdownItem>
<DropdownItem onClick={() => handleMarkSold(car.id)}>
Mark as Sold
</DropdownItem>
<DropdownItem onClick={() => handleDelete(car.id)} variant="danger">
Delete
</DropdownItem>
</Dropdown>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}Add/Edit Car Form
tsx
// components/dealer/CarForm.tsx
export function CarForm({ car, onSubmit }) {
const { register, handleSubmit, watch } = useForm({
defaultValues: car || {}
});
const vinLookup = useMutation({
mutationFn: (vin: string) => api.vin.lookup(vin),
onSuccess: (data) => {
// Auto-fill form with VIN data
setValue('make', data.make);
setValue('model', data.model);
setValue('year', data.year);
setValue('fuelType', data.fuelType);
setValue('transmission', data.transmission);
setValue('bodyType', data.bodyType);
}
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* VIN Lookup */}
<section>
<h2 className="text-lg font-semibold mb-4">Quick Fill with VIN</h2>
<div className="flex gap-2">
<Input
{...register('vin')}
placeholder="Enter VIN to auto-fill details"
/>
<Button
type="button"
onClick={() => vinLookup.mutate(watch('vin'))}
loading={vinLookup.isPending}
>
Lookup
</Button>
</div>
</section>
{/* Basic Info */}
<section>
<h2 className="text-lg font-semibold mb-4">Basic Information</h2>
<div className="grid grid-cols-2 gap-4">
<Select label="Make" {...register('make')} options={makes} required />
<Select label="Model" {...register('model')} options={models} required />
<Input label="Year" type="number" {...register('year')} required />
<Input label="Price" type="number" {...register('price')} required />
<Input label="Mileage (km)" type="number" {...register('mileage')} required />
<Select label="Condition" {...register('condition')} options={['New', 'Used', 'Certified']} />
</div>
</section>
{/* Specifications */}
<section>
<h2 className="text-lg font-semibold mb-4">Specifications</h2>
<div className="grid grid-cols-2 gap-4">
<Select label="Fuel Type" {...register('fuelType')} options={fuelTypes} />
<Select label="Transmission" {...register('transmission')} options={transmissions} />
<Select label="Body Type" {...register('bodyType')} options={bodyTypes} />
<Input label="Engine Size" {...register('engineSize')} placeholder="e.g., 2.0L" />
<Input label="Exterior Color" {...register('exteriorColor')} />
<Input label="Interior Color" {...register('interiorColor')} />
</div>
</section>
{/* Features */}
<section>
<h2 className="text-lg font-semibold mb-4">Features</h2>
<FeatureSelector
value={watch('features') || []}
onChange={(f) => setValue('features', f)}
/>
</section>
{/* Media */}
<section>
<h2 className="text-lg font-semibold mb-4">Photos & Videos</h2>
<MediaUploader
images={watch('images') || []}
videos={watch('videos') || []}
onChange={(media) => {
setValue('images', media.images);
setValue('videos', media.videos);
}}
/>
</section>
{/* Description */}
<section>
<h2 className="text-lg font-semibold mb-4">Description</h2>
<Textarea
{...register('description')}
rows={5}
placeholder="Describe the car, its history, condition..."
/>
<Button
type="button"
variant="outline"
className="mt-2"
onClick={() => generateDescription()}
>
Generate with AI
</Button>
</section>
<div className="flex gap-4">
<Button type="submit" className="flex-1">
{car ? 'Update Listing' : 'Create Listing'}
</Button>
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
Cancel
</Button>
</div>
</form>
);
}Subscription/Billing UI
tsx
// components/dealer/SubscriptionPage.tsx
export function SubscriptionPage() {
const { data: plans } = useQuery({
queryKey: ['plans'],
queryFn: api.billing.getPlans
});
const { data: subscription } = useQuery({
queryKey: ['subscription'],
queryFn: api.billing.getSubscription
});
const checkout = useMutation({
mutationFn: (planId: string) => api.billing.createCheckout({ planId }),
onSuccess: (data) => {
window.location.href = data.url;
}
});
return (
<div className="p-6 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-2">Choose Your Plan</h1>
<p className="text-gray-600 mb-8">
Unlock more listings and premium features
</p>
{/* Current plan */}
{subscription && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-8">
<p className="text-green-800">
Current plan: <strong>{subscription.plan}</strong>
{subscription.planExpiry && (
<span className="text-sm ml-2">
(expires {formatDate(subscription.planExpiry)})
</span>
)}
</p>
</div>
)}
{/* Plans */}
<div className="grid md:grid-cols-3 gap-6">
{plans?.map(plan => (
<PlanCard
key={plan.id}
name={plan.name}
price={plan.displayFormatted}
usdPrice={plan.usdFormatted}
showUsdNote={plan.showUsdNote}
features={plan.features}
listingLimit={plan.listingLimit}
isPopular={plan.id === 'DEALER'}
isCurrent={subscription?.plan === plan.id}
onSelect={() => checkout.mutate(plan.id)}
loading={checkout.isPending}
/>
))}
</div>
</div>
);
}API Services
typescript
// services/api.ts
const api = {
auth: {
login: (data) => post('/auth/login', data),
signup: (data) => post('/auth/signup', data)
},
cars: {
getFeed: (params) => get('/cars/feed', params),
getById: (id) => get(`/cars/${id}`),
search: (params) => get('/cars/search', params),
create: (data) => post('/cars', data),
update: (id, data) => put(`/cars/${id}`, data),
favorite: (id) => post(`/cars/${id}/favorite`),
inquiry: (id, data) => post(`/cars/${id}/inquiry`, data)
},
ai: {
search: (query) => post('/ai/search', { query }),
recommendations: (profile) => post('/ai/recommendations', profile),
pricingInsights: (carId) => get(`/ai/pricing-insights/${carId}`)
},
vin: {
lookup: (vin) => get(`/vin/lookup/${vin}`)
},
dealer: {
getStats: () => get('/dealers/me/stats'),
getInventory: () => get('/dealers/me/inventory'),
getLeads: () => get('/dealers/me/leads')
},
billing: {
getPlans: () => get('/api/billing/plans'),
createCheckout: (data) => post('/api/billing/checkout', data),
getSubscription: () => get('/api/billing/subscription')
}
};