Skip to content

Commit 787cf17

Browse files
authored
Merge pull request #8 from mitoperni/feat/network-detail-stations
feat: add network detail page with stations table
2 parents 3374a09 + adf0301 commit 787cf17

13 files changed

Lines changed: 511 additions & 0 deletions

File tree

‎public/stations_header.jpg‎

264 KB
Loading

‎src/app/network/[id]/error.tsx‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use client';
2+
3+
import Link from 'next/link';
4+
import { AlertCircle, ArrowLeft, RefreshCw } from 'lucide-react';
5+
6+
interface ErrorProps {
7+
error: Error & { digest?: string };
8+
reset: () => void;
9+
}
10+
11+
export default function NetworkDetailError({ error, reset }: ErrorProps) {
12+
return (
13+
<div className="flex h-screen items-center justify-center bg-torea-bay-50 p-4">
14+
<div className="flex max-w-md flex-col items-center text-center">
15+
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
16+
<AlertCircle className="h-8 w-8 text-red-600" />
17+
</div>
18+
19+
<h1 className="mt-6 text-2xl font-bold text-torea-bay-800">Failed to load network</h1>
20+
21+
<p className="mt-2 text-muted-foreground">
22+
{error.message || "We couldn't load the network details. Please try again."}
23+
</p>
24+
25+
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
26+
<button
27+
onClick={reset}
28+
className="inline-flex items-center justify-center gap-2 rounded-full bg-torea-bay-600 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-torea-bay-700"
29+
>
30+
<RefreshCw className="h-4 w-4" />
31+
Try again
32+
</button>
33+
34+
<Link
35+
href="/"
36+
className="inline-flex items-center justify-center gap-2 rounded-full border border-torea-bay-200 bg-white px-6 py-3 text-sm font-medium text-torea-bay-600 transition-colors hover:bg-torea-bay-50"
37+
>
38+
<ArrowLeft className="h-4 w-4" />
39+
Back to networks
40+
</Link>
41+
</div>
42+
</div>
43+
</div>
44+
);
45+
}

‎src/app/network/[id]/loading.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Sidebar } from '@/components/layout/sidebar';
2+
import { StationsTableSkeleton } from '@/components/stations/stations-table-skeleton';
3+
4+
export default function NetworkDetailLoading() {
5+
return (
6+
<div className="flex h-screen flex-col lg:flex-row">
7+
<Sidebar>
8+
<StationsTableSkeleton />
9+
</Sidebar>
10+
11+
<main className="relative min-h-[300px] flex-1 lg:min-h-0">
12+
<div className="flex h-full items-center justify-center bg-torea-bay-50">
13+
<div className="animate-pulse text-torea-bay-400">Loading map...</div>
14+
</div>
15+
</main>
16+
</div>
17+
);
18+
}

‎src/app/network/[id]/not-found.tsx‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import Link from 'next/link';
2+
import { Bike, ArrowLeft } from 'lucide-react';
3+
4+
export default function NetworkNotFound() {
5+
return (
6+
<div className="flex h-screen items-center justify-center bg-torea-bay-50 p-4">
7+
<div className="flex max-w-md flex-col items-center text-center">
8+
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-torea-bay-100">
9+
<Bike className="h-8 w-8 text-torea-bay-400" />
10+
</div>
11+
12+
<h1 className="mt-6 text-2xl font-bold text-torea-bay-800">Network not found</h1>
13+
14+
<p className="mt-2 text-muted-foreground">
15+
The bike sharing network you&apos;re looking for doesn&apos;t exist or may have been
16+
removed.
17+
</p>
18+
19+
<Link
20+
href="/"
21+
className="mt-8 inline-flex items-center justify-center gap-2 rounded-full bg-torea-bay-600 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-torea-bay-700"
22+
>
23+
<ArrowLeft className="h-4 w-4" />
24+
Back to all networks
25+
</Link>
26+
</div>
27+
</div>
28+
);
29+
}

‎src/app/network/[id]/page.tsx‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Suspense } from 'react';
2+
import { notFound } from 'next/navigation';
3+
import { StationsHeader } from '@/components/stations/stations-header';
4+
import { StationsTable } from '@/components/stations/stations-table';
5+
import { StationsTableSkeleton } from '@/components/stations/stations-table-skeleton';
6+
import { fetchNetworkDetail } from '@/lib/api/network-detail';
7+
import { SidebarStation } from '@/components/layout/sidebar-station';
8+
9+
interface NetworkDetailPageProps {
10+
params: Promise<{ id: string }>;
11+
}
12+
13+
export async function generateMetadata({ params }: NetworkDetailPageProps) {
14+
const { id } = await params;
15+
16+
try {
17+
const network = await fetchNetworkDetail(id);
18+
return {
19+
title: `${network.name} | CycleMap`,
20+
description: `View stations for ${network.name} in ${network.location.city}, ${network.location.country}`,
21+
};
22+
} catch {
23+
return {
24+
title: 'Network Not Found | CycleMap',
25+
};
26+
}
27+
}
28+
29+
async function NetworkContent({ id }: { id: string }) {
30+
const network = await fetchNetworkDetail(id);
31+
32+
if (!network) {
33+
notFound();
34+
}
35+
36+
return (
37+
<>
38+
<StationsHeader network={network} />
39+
<StationsTable stations={network.stations} />
40+
</>
41+
);
42+
}
43+
44+
export default async function NetworkDetailPage({ params }: NetworkDetailPageProps) {
45+
const { id } = await params;
46+
47+
return (
48+
<div className="flex h-screen flex-col lg:flex-row">
49+
<SidebarStation>
50+
<Suspense fallback={<StationsTableSkeleton />}>
51+
<NetworkContent id={id} />
52+
</Suspense>
53+
</SidebarStation>
54+
55+
<main className="relative min-h-[300px] flex-1 lg:min-h-0">
56+
{/* Map will be added in PR #8 */}
57+
<div className="flex h-full items-center justify-center bg-torea-bay-50">
58+
<p className="text-torea-bay-400">Station map coming in next PR</p>
59+
</div>
60+
</main>
61+
</div>
62+
);
63+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
interface SidebarProps {
2+
children: React.ReactNode;
3+
}
4+
5+
export function SidebarStation({ children }: SidebarProps) {
6+
return (
7+
<aside className="flex h-full w-full flex-col bg-torea-bay-800 lg:w-(--sidebar-width)">
8+
<div className="-mr-1 scrollbar-hidden flex-1 overflow-y-auto pr-1">{children}</div>
9+
</aside>
10+
);
11+
}

‎src/components/stations/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { StationsHeader } from './stations-header';
2+
export { StationsTable } from './stations-table';
3+
export { StationRow } from './station-row';
4+
export { StationsTableSkeleton } from './stations-table-skeleton';
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { cn, cleanStationName } from '@/lib/utils';
2+
import type { Station } from '@/types';
3+
4+
interface StationRowProps {
5+
station: Station;
6+
}
7+
8+
export function StationRow({ station }: StationRowProps) {
9+
const { name, free_bikes, empty_slots } = station;
10+
11+
// Determine availability status for visual indicators
12+
const hasAvailableBikes = free_bikes > 0;
13+
const hasEmptySlots = empty_slots !== null && empty_slots > 0;
14+
const cleanName = cleanStationName(name);
15+
16+
return (
17+
<tr className="group border-b border-white/50 border-dashed text-sm hover:bg-white/10 duration-300 transition-colors">
18+
{/* Station name */}
19+
<td
20+
className="ps-2 py-[18px] font-normal text-base leading-7 text-white transition-[padding] duration-300 ease-out group-hover:ps-4"
21+
title={cleanName}
22+
>
23+
{cleanName}
24+
</td>
25+
26+
{/* Free bikes */}
27+
<td
28+
className={cn(
29+
'py-[18px] text-center font-bold text-base leading-7 w-[130.5px]',
30+
hasAvailableBikes ? 'text-white' : 'text-grenadier-600'
31+
)}
32+
>
33+
{free_bikes}
34+
</td>
35+
36+
{/* Empty slots */}
37+
<td
38+
className={cn(
39+
'py-[18px] pe-2 text-center font-bold text-base leading-7 w-[130.5px]',
40+
empty_slots === null ? 'text-gray-300' : hasEmptySlots ? 'text-white' : 'text-gray-400'
41+
)}
42+
>
43+
{empty_slots ?? '-'}
44+
</td>
45+
</tr>
46+
);
47+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
'use client';
2+
3+
import { useRouter } from 'next/navigation';
4+
import { ArrowLeft, MapPin, Building2 } from 'lucide-react';
5+
import { getCountryName } from '@/lib/utils';
6+
import { NETWORK_CARD } from '@/lib/constants';
7+
import type { NetworkWithStations } from '@/types';
8+
9+
interface StationsHeaderProps {
10+
network: NetworkWithStations;
11+
}
12+
13+
export function StationsHeader({ network }: StationsHeaderProps) {
14+
const router = useRouter();
15+
const { name, location, company } = network;
16+
17+
const handleBack = () => {
18+
if (window.history.length > 1) {
19+
router.back();
20+
} else {
21+
router.push('/');
22+
}
23+
};
24+
const visibleCompanies = company.slice(0, NETWORK_CARD.MAX_VISIBLE_COMPANIES);
25+
const remainingCount = company.length - NETWORK_CARD.MAX_VISIBLE_COMPANIES;
26+
const companiesText = visibleCompanies.join(', ');
27+
28+
return (
29+
<div className="relative flex flex-col gap-6 px-10 py-8 min-h-[252px]">
30+
{/* Background image with gradient fade */}
31+
<div className="absolute inset-0 bg-[linear-gradient(rgba(0,0,0,0.25),rgba(0,0,0,0.25)),url('/stations_header.jpg')] bg-cover bg-center">
32+
<div className="absolute inset-0 bg-gradient-to-t from-torea-bay-800 from-0% via-torea-bay-800/25 via-45% to-transparent to-100%" />
33+
</div>
34+
35+
{/* Back button */}
36+
<button
37+
onClick={handleBack}
38+
className="relative z-10 inline-flex w-fit items-center gap-2 text-sm font-medium text-grenadier-400 cursor-pointer"
39+
>
40+
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white">
41+
<ArrowLeft className="h-5 w-5" />
42+
</div>
43+
</button>
44+
45+
{/* Network info */}
46+
<div className="relative z-10 flex flex-col gap-3">
47+
<h1 className="text-2xl font-bold text-white">{name}</h1>
48+
49+
{/* Location */}
50+
<div className="flex items-center gap-2 text-sm">
51+
<div className="flex h-6 w-6 items-center justify-center">
52+
<MapPin className="h-4 w-4 text-white" />
53+
</div>
54+
<span className="text-white">
55+
{location.city}, {getCountryName(location.country)}
56+
</span>
57+
</div>
58+
59+
{/* Companies */}
60+
{company.length > 0 && (
61+
<div className="flex items-center gap-2 text-sm">
62+
<div className="flex h-6 w-6 items-center justify-center">
63+
<Building2 className="h-4 w-4 text-white" />
64+
</div>
65+
<span className="text-white">{companiesText}</span>
66+
{remainingCount > 0 && (
67+
<span className="shrink-0 rounded border border-white/50 px-1.5 py-0.5 text-xs text-white">
68+
+{remainingCount}
69+
</span>
70+
)}
71+
</div>
72+
)}
73+
</div>
74+
</div>
75+
);
76+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
function SkeletonRow() {
2+
return (
3+
<div className="grid grid-cols-[1fr_80px_80px] gap-4 border-b border-gray-100 px-[40px] pt-[40px]">
4+
<div className="h-5 w-3/4 animate-pulse rounded bg-gray-200" />
5+
<div className="mx-auto h-5 w-8 animate-pulse rounded bg-gray-200" />
6+
<div className="mx-auto h-5 w-8 animate-pulse rounded bg-gray-200" />
7+
</div>
8+
);
9+
}
10+
11+
function HeaderSkeleton() {
12+
return (
13+
<div className="flex flex-col gap-6 pb-6 border-b border-gray-100">
14+
{/* Back button skeleton */}
15+
<div className="flex items-center gap-2">
16+
<div className="h-8 w-8 animate-pulse rounded-full bg-gray-200" />
17+
<div className="h-4 w-28 animate-pulse rounded bg-gray-200" />
18+
</div>
19+
20+
{/* Network info skeleton */}
21+
<div className="flex flex-col gap-3">
22+
{/* Title */}
23+
<div className="h-8 w-48 animate-pulse rounded bg-gray-200" />
24+
25+
{/* Location */}
26+
<div className="flex items-center gap-2">
27+
<div className="h-6 w-6 animate-pulse rounded bg-gray-200" />
28+
<div className="h-4 w-32 animate-pulse rounded bg-gray-200" />
29+
</div>
30+
31+
{/* Companies */}
32+
<div className="flex items-center gap-2">
33+
<div className="h-6 w-6 animate-pulse rounded bg-gray-200" />
34+
<div className="h-4 w-40 animate-pulse rounded bg-gray-200" />
35+
</div>
36+
37+
{/* Stats */}
38+
<div className="mt-2">
39+
<div className="h-12 w-32 animate-pulse rounded-lg bg-gray-200" />
40+
</div>
41+
</div>
42+
</div>
43+
);
44+
}
45+
46+
interface StationsTableSkeletonProps {
47+
rows?: number;
48+
}
49+
50+
export function StationsTableSkeleton({ rows = 8 }: StationsTableSkeletonProps) {
51+
return (
52+
<div className="flex flex-col">
53+
<HeaderSkeleton />
54+
55+
{/* Table header */}
56+
<div className="mt-6 grid grid-cols-[1fr_80px_80px] gap-4 border-b border-gray-200 pb-3 text-sm font-medium text-torea-bay-600">
57+
<span>Station name</span>
58+
<span className="text-center">Free bikes</span>
59+
<span className="text-center">Empty slots</span>
60+
</div>
61+
62+
{/* Skeleton rows */}
63+
<div className="flex flex-col">
64+
{Array.from({ length: rows }).map((_, i) => (
65+
<SkeletonRow key={i} />
66+
))}
67+
</div>
68+
</div>
69+
);
70+
}

0 commit comments

Comments
 (0)