Skip to content

Commit 557b902

Browse files
authored
Merge pull request #945 from snowrugar-beep/fix/issue-871-carrier-profile-page
[FE-03] Build CarrierProfilePage component
2 parents f5760d2 + 69da676 commit 557b902

2 files changed

Lines changed: 324 additions & 0 deletions

File tree

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
'use client';
2+
3+
import { useState, useEffect } from 'react';
4+
import { useParams } from 'next/navigation';
5+
import Link from 'next/link';
6+
import { useQuery } from '@tanstack/react-query';
7+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
8+
import { Button } from '@/components/ui/button';
9+
import { Skeleton } from '@/components/ui/skeleton';
10+
import { apiClient } from '@/lib/api/client';
11+
12+
interface CarrierProfileData {
13+
id: string;
14+
firstName: string;
15+
lastName: string;
16+
email: string;
17+
createdAt: string;
18+
completedShipments: number;
19+
averageRating: number;
20+
totalReviews: number;
21+
certifications: string[];
22+
bio?: string;
23+
avatarUrl?: string;
24+
onTimeRate?: number;
25+
responseRate?: number;
26+
totalEarnings?: number;
27+
}
28+
29+
interface Review {
30+
id: string;
31+
rating: number;
32+
comment: string;
33+
reviewerName: string;
34+
createdAt: string;
35+
}
36+
37+
function StarRating({ rating }: { rating: number }) {
38+
return (
39+
<div className="flex items-center gap-0.5" aria-label={`Rating: ${rating} out of 5`}>
40+
{Array.from({ length: 5 }).map((_, i) => (
41+
<svg
42+
key={i}
43+
className={`w-4 h-4 ${i < Math.round(rating) ? 'text-yellow-400' : 'text-muted-foreground/30'}`}
44+
fill="currentColor"
45+
viewBox="0 0 20 20"
46+
aria-hidden="true"
47+
>
48+
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
49+
</svg>
50+
))}
51+
<span className="ml-1 text-sm text-muted-foreground">{rating.toFixed(1)}</span>
52+
</div>
53+
);
54+
}
55+
56+
function ReputationBar({ score }: { score: number }) {
57+
const percentage = Math.min((score / 5) * 100, 100);
58+
const color =
59+
score >= 4.5 ? 'bg-green-500' : score >= 3.5 ? 'bg-blue-500' : score >= 2.5 ? 'bg-amber-500' : 'bg-red-500';
60+
61+
return (
62+
<div className="space-y-1">
63+
<div className="flex justify-between text-xs">
64+
<span className="text-muted-foreground">Reputation Score</span>
65+
<span className="font-medium">{score.toFixed(1)} / 5.0</span>
66+
</div>
67+
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
68+
<div
69+
className={`h-full rounded-full ${color} transition-all`}
70+
style={{ width: `${percentage}%` }}
71+
role="progressbar"
72+
aria-valuenow={score}
73+
aria-valuemin={0}
74+
aria-valuemax={5}
75+
/>
76+
</div>
77+
</div>
78+
);
79+
}
80+
81+
export function CarrierProfilePage() {
82+
const { id } = useParams<{ id: string }>();
83+
84+
const {
85+
data: carrier,
86+
isLoading,
87+
isError,
88+
error,
89+
refetch,
90+
} = useQuery({
91+
queryKey: ['carrier-profile', id],
92+
queryFn: () => apiClient<CarrierProfileData>(`/carriers/${id}`),
93+
enabled: !!id,
94+
});
95+
96+
const {
97+
data: reviews,
98+
isLoading: reviewsLoading,
99+
} = useQuery({
100+
queryKey: ['carrier-reviews', id],
101+
queryFn: () => apiClient<Review[]>(`/carriers/${id}/reviews`),
102+
enabled: !!id,
103+
});
104+
105+
if (isError) {
106+
return (
107+
<div className="p-8 text-center">
108+
<Card>
109+
<CardContent className="py-12">
110+
<p className="text-destructive text-sm mb-2">
111+
Failed to load carrier profile.
112+
</p>
113+
<p className="text-muted-foreground text-xs mb-4">
114+
{(error as Error)?.message || 'An unexpected error occurred.'}
115+
</p>
116+
<Button variant="outline" size="sm" onClick={() => refetch()}>
117+
Retry
118+
</Button>
119+
</CardContent>
120+
</Card>
121+
</div>
122+
);
123+
}
124+
125+
return (
126+
<div className="max-w-3xl mx-auto space-y-6">
127+
{/* Profile Header */}
128+
<Card>
129+
<CardContent className="pt-6">
130+
{isLoading ? (
131+
<div className="flex items-start gap-4">
132+
<Skeleton className="h-16 w-16 rounded-full shrink-0" />
133+
<div className="flex-1 space-y-2">
134+
<Skeleton className="h-6 w-48" />
135+
<Skeleton className="h-4 w-32" />
136+
<Skeleton className="h-4 w-64" />
137+
</div>
138+
</div>
139+
) : carrier ? (
140+
<div className="flex flex-col sm:flex-row items-start gap-4">
141+
<div className="h-16 w-16 rounded-full bg-primary/20 flex items-center justify-center text-xl font-bold text-primary shrink-0">
142+
{carrier.avatarUrl ? (
143+
<img
144+
src={carrier.avatarUrl}
145+
alt={`${carrier.firstName} ${carrier.lastName}`}
146+
className="h-full w-full rounded-full object-cover"
147+
/>
148+
) : (
149+
`${carrier.firstName[0]}${carrier.lastName[0]}`
150+
)}
151+
</div>
152+
<div className="flex-1 min-w-0">
153+
<h1 className="text-2xl font-bold text-foreground">
154+
{carrier.firstName} {carrier.lastName}
155+
</h1>
156+
<p className="text-sm text-muted-foreground mt-0.5">
157+
Member since{' '}
158+
{new Date(carrier.createdAt).toLocaleDateString('en-US', {
159+
month: 'long',
160+
year: 'numeric',
161+
})}
162+
</p>
163+
{carrier.bio && (
164+
<p className="text-sm text-muted-foreground mt-2">{carrier.bio}</p>
165+
)}
166+
</div>
167+
<Button asChild className="shrink-0">
168+
<Link
169+
href={`/shipments/new?carrierId=${carrier.id}&carrierName=${encodeURIComponent(`${carrier.firstName} ${carrier.lastName}`)}`}
170+
>
171+
Contact / Hire
172+
</Link>
173+
</Button>
174+
</div>
175+
) : null}
176+
</CardContent>
177+
</Card>
178+
179+
{/* Stats Grid */}
180+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
181+
{isLoading
182+
? Array.from({ length: 4 }).map((_, i) => (
183+
<Card key={i}>
184+
<CardHeader className="pb-1">
185+
<Skeleton className="h-3 w-20" />
186+
</CardHeader>
187+
<CardContent>
188+
<Skeleton className="h-7 w-12" />
189+
</CardContent>
190+
</Card>
191+
))
192+
: carrier && (
193+
<>
194+
<Card>
195+
<CardHeader className="pb-1">
196+
<CardTitle className="text-xs font-medium text-muted-foreground">
197+
Completed
198+
</CardTitle>
199+
</CardHeader>
200+
<CardContent>
201+
<p className="text-2xl font-bold">{carrier.completedShipments}</p>
202+
</CardContent>
203+
</Card>
204+
<Card>
205+
<CardHeader className="pb-1">
206+
<CardTitle className="text-xs font-medium text-muted-foreground">
207+
Rating
208+
</CardTitle>
209+
</CardHeader>
210+
<CardContent>
211+
<StarRating rating={carrier.averageRating} />
212+
</CardContent>
213+
</Card>
214+
<Card>
215+
<CardHeader className="pb-1">
216+
<CardTitle className="text-xs font-medium text-muted-foreground">
217+
On-Time
218+
</CardTitle>
219+
</CardHeader>
220+
<CardContent>
221+
<p className="text-2xl font-bold">
222+
{carrier.onTimeRate != null ? `${carrier.onTimeRate}%` : '—'}
223+
</p>
224+
</CardContent>
225+
</Card>
226+
<Card>
227+
<CardHeader className="pb-1">
228+
<CardTitle className="text-xs font-medium text-muted-foreground">
229+
Response Rate
230+
</CardTitle>
231+
</CardHeader>
232+
<CardContent>
233+
<p className="text-2xl font-bold">
234+
{carrier.responseRate != null ? `${carrier.responseRate}%` : '—'}
235+
</p>
236+
</CardContent>
237+
</Card>
238+
</>
239+
)}
240+
</div>
241+
242+
{/* Reputation Score */}
243+
{!isLoading && carrier && (
244+
<Card>
245+
<CardContent className="pt-6">
246+
<ReputationBar score={carrier.averageRating} />
247+
</CardContent>
248+
</Card>
249+
)}
250+
251+
{/* Certifications */}
252+
{!isLoading && carrier && carrier.certifications.length > 0 && (
253+
<Card>
254+
<CardHeader>
255+
<CardTitle className="text-base">Certifications</CardTitle>
256+
</CardHeader>
257+
<CardContent>
258+
<div className="flex flex-wrap gap-2">
259+
{carrier.certifications.map((cert) => (
260+
<span
261+
key={cert}
262+
className="inline-flex items-center px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium"
263+
>
264+
{cert}
265+
</span>
266+
))}
267+
</div>
268+
</CardContent>
269+
</Card>
270+
)}
271+
272+
{/* Reviews */}
273+
<Card>
274+
<CardHeader>
275+
<CardTitle className="text-base">
276+
Reviews {reviews && `(${reviews.length})`}
277+
</CardTitle>
278+
</CardHeader>
279+
<CardContent>
280+
{reviewsLoading ? (
281+
<div className="space-y-3">
282+
{Array.from({ length: 3 }).map((_, i) => (
283+
<div key={i} className="rounded-lg border p-4 space-y-2">
284+
<Skeleton className="h-4 w-32" />
285+
<Skeleton className="h-3 w-full" />
286+
<Skeleton className="h-3 w-3/4" />
287+
</div>
288+
))}
289+
</div>
290+
) : !reviews || reviews.length === 0 ? (
291+
<p className="text-sm text-muted-foreground py-4">No reviews yet.</p>
292+
) : (
293+
<div className="space-y-3">
294+
{reviews.slice(0, 5).map((review) => (
295+
<div key={review.id} className="rounded-lg border p-4 space-y-2">
296+
<div className="flex items-center justify-between gap-2">
297+
<div className="flex items-center gap-2">
298+
<div className="h-8 w-8 rounded-full bg-primary/20 flex items-center justify-center text-xs font-bold text-primary">
299+
{review.reviewerName[0]}
300+
</div>
301+
<span className="text-sm font-medium">{review.reviewerName}</span>
302+
</div>
303+
<StarRating rating={review.rating} />
304+
</div>
305+
{review.comment && (
306+
<p className="text-sm text-muted-foreground">{review.comment}</p>
307+
)}
308+
<p className="text-xs text-muted-foreground">
309+
{new Date(review.createdAt).toLocaleDateString('en-US', {
310+
month: 'short',
311+
day: 'numeric',
312+
year: 'numeric',
313+
})}
314+
</p>
315+
</div>
316+
))}
317+
</div>
318+
)}
319+
</CardContent>
320+
</Card>
321+
</div>
322+
);
323+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { CarrierProfilePage } from './CarrierProfilePage';

0 commit comments

Comments
 (0)