mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
555 lines
21 KiB
TypeScript
555 lines
21 KiB
TypeScript
"use client";
|
||
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { apiClient } from "@/lib/api-client";
|
||
import Link from "next/link";
|
||
import Image from "next/image";
|
||
import {
|
||
MapPin,
|
||
ArrowRight,
|
||
Shield,
|
||
Train,
|
||
Bus,
|
||
CheckCircle2,
|
||
Calendar,
|
||
Clock,
|
||
} from "lucide-react";
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
interface PriceTier {
|
||
id: string;
|
||
priceMinor: number;
|
||
currency: string;
|
||
availableSeats: number;
|
||
}
|
||
|
||
interface Station {
|
||
name: string;
|
||
city: string;
|
||
code: string;
|
||
}
|
||
|
||
interface Schedule {
|
||
originStation: Station;
|
||
destinationStation: Station;
|
||
departureAt: string;
|
||
durationMinutes: number;
|
||
}
|
||
|
||
interface HolidayPackage {
|
||
id: string;
|
||
code: string;
|
||
name: string;
|
||
description?: string | null;
|
||
status: string;
|
||
departureTime: string;
|
||
validFrom: string;
|
||
validUntil: string;
|
||
totalCapacity: number;
|
||
bookedCount: number;
|
||
includedServices?: string[];
|
||
busTransferIncluded?: boolean;
|
||
busTransferRoute?: string | null;
|
||
journeyType?: "ONE_WAY" | "ROUND_TRIP";
|
||
returnSchedule?: Schedule | null;
|
||
outboundSchedule?: Schedule;
|
||
priceTiers: PriceTier[];
|
||
}
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
// Pinned to Ethiopian time (EAT - UTC+3) — the zone train schedules are stored/computed
|
||
// in server-side — so this matches every other booking page and the voucher PDF
|
||
// regardless of the viewing device's own timezone.
|
||
function fmtDate(iso: string, opts?: Intl.DateTimeFormatOptions): string {
|
||
try {
|
||
return new Date(iso).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
...opts,
|
||
timeZone: "Africa/Addis_Ababa",
|
||
});
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
function validityRange(from: string, until: string): string {
|
||
return `${fmtDate(from, { month: "short", day: "numeric" })} – ${fmtDate(until, { month: "short", day: "numeric", year: "numeric" })}`;
|
||
}
|
||
|
||
function daysUntil(iso: string): number {
|
||
return Math.max(
|
||
0,
|
||
Math.floor((new Date(iso).getTime() - Date.now()) / 86400000),
|
||
);
|
||
}
|
||
|
||
function minPrice(
|
||
tiers: PriceTier[],
|
||
multiplier = 1,
|
||
): { amount: number; currency: string } | null {
|
||
if (!tiers?.length) return null;
|
||
const min = tiers.reduce((a, b) => (a.priceMinor < b.priceMinor ? a : b));
|
||
return {
|
||
amount: (min.priceMinor * multiplier) / 100,
|
||
currency: min.currency,
|
||
};
|
||
}
|
||
|
||
function fmtPrice(minor: number, currency: string): string {
|
||
return `${currency} ${(minor / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||
}
|
||
|
||
function stripBullet(s: string): string {
|
||
return s.replace(/^[•\-\t\s]+/, "").trim();
|
||
}
|
||
|
||
// ─── Urgency badge ────────────────────────────────────────────────────────────
|
||
|
||
function UrgencyBadge({ until }: { until: string }) {
|
||
const days = daysUntil(until);
|
||
if (days > 14) return null;
|
||
return (
|
||
<span
|
||
className={`text-[10px] font-bold px-2.5 py-1 rounded-full ${
|
||
days <= 3
|
||
? "bg-red-500 text-white animate-pulse"
|
||
: "bg-orange-400 text-white"
|
||
}`}
|
||
>
|
||
{days === 0 ? "Last day!" : `Closes in ${days}d`}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// ─── Availability bar ─────────────────────────────────────────────────────────
|
||
|
||
function AvailBar({ booked, total }: { booked: number; total: number }) {
|
||
const pct = total > 0 ? Math.round(((total - booked) / total) * 100) : 100;
|
||
const low = pct <= 20;
|
||
return (
|
||
<div>
|
||
<div className="flex items-center justify-between text-[10px] text-gray-400 mb-1">
|
||
<span>
|
||
{total - booked} of {total} seats
|
||
</span>
|
||
<span className={low ? "text-orange-500 font-semibold" : ""}>
|
||
{pct}% available
|
||
</span>
|
||
</div>
|
||
<div className="h-1 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
||
<div
|
||
className={`h-full rounded-full transition-all ${low ? "bg-orange-400" : "bg-primary"}`}
|
||
style={{ width: `${pct}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Featured Card (first package — full-width, image left) ───────────────────
|
||
|
||
function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
|
||
const multiplier = pkg.journeyType === "ROUND_TRIP" ? 2 : 1;
|
||
const price = minPrice(pkg.priceTiers, multiplier);
|
||
const origin = pkg.outboundSchedule?.originStation;
|
||
const dest = pkg.outboundSchedule?.destinationStation;
|
||
const days = daysUntil(pkg.validUntil);
|
||
|
||
return (
|
||
<Link href={`/packages/${pkg.id}`} className="group block">
|
||
<div className="relative bg-white dark:bg-gray-900 rounded-3xl overflow-hidden border border-gray-100 dark:border-gray-800 shadow-sm hover:shadow-2xl transition-all duration-500">
|
||
<div className="flex flex-col md:flex-row min-h-[340px]">
|
||
{/* ── Left: Image ── */}
|
||
<div className="relative md:w-[46%] h-64 md:h-auto flex-shrink-0 overflow-hidden">
|
||
<Image
|
||
src="/packages/package.jpeg"
|
||
alt={pkg.name}
|
||
fill
|
||
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
|
||
sizes="(max-width: 768px) 100vw, 46vw"
|
||
priority
|
||
/>
|
||
{/* Gradient overlay */}
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-transparent md:bg-gradient-to-r md:from-transparent md:via-transparent md:to-black/30" />
|
||
|
||
{/* Top-left badges */}
|
||
<div className="absolute top-4 left-4 flex flex-wrap gap-2">
|
||
<span className="flex items-center gap-1 bg-primary text-white text-[11px] font-bold px-3 py-1 rounded-full shadow">
|
||
✦ Featured Package
|
||
</span>
|
||
<UrgencyBadge until={pkg.validUntil} />
|
||
</div>
|
||
|
||
{/* Bottom-left: round trip tag */}
|
||
{pkg.returnSchedule && (
|
||
<div className="absolute bottom-4 left-4">
|
||
<span className="flex items-center gap-1.5 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm text-gray-800 dark:text-white text-[11px] font-bold px-3 py-1.5 rounded-full shadow">
|
||
<ArrowRight className="w-3 h-3 rotate-0" />
|
||
<ArrowRight className="w-3 h-3 rotate-180 -ml-2" />
|
||
Round Trip
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Right: Content ── */}
|
||
<div className="flex-1 flex flex-col justify-between p-7 md:p-8">
|
||
{/* Top section */}
|
||
<div>
|
||
{/* Status */}
|
||
{pkg.status === "ACTIVE" && (
|
||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-400 px-2.5 py-1 rounded-full mb-3">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||
Booking Open
|
||
</span>
|
||
)}
|
||
|
||
{/* Name */}
|
||
<h3 className="text-xl md:text-2xl font-extrabold text-gray-900 dark:text-white leading-tight mb-1.5">
|
||
{pkg.name?.trim()}
|
||
</h3>
|
||
|
||
{/* Route */}
|
||
{origin && dest && (
|
||
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 mb-5">
|
||
<Train className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||
{origin.name?.trim()}
|
||
</span>
|
||
<ArrowRight className="w-3 h-3 flex-shrink-0" />
|
||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||
{dest.name?.trim()}
|
||
</span>
|
||
{pkg.busTransferIncluded && (
|
||
<>
|
||
<span className="text-gray-300 dark:text-gray-600">
|
||
+
|
||
</span>
|
||
<Bus className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
|
||
<span className="font-medium text-gray-600 dark:text-gray-400">
|
||
{pkg.busTransferRoute?.trim() ?? "Bus transfer"}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Info grid */}
|
||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mb-5">
|
||
<InfoPill
|
||
icon={<Shield className="w-3.5 h-3.5 text-primary" />}
|
||
label="Validity"
|
||
>
|
||
{validityRange(pkg.validFrom, pkg.validUntil)}
|
||
</InfoPill>
|
||
<InfoPill
|
||
icon={<Clock className="w-3.5 h-3.5 text-primary" />}
|
||
label="Booking closes"
|
||
>
|
||
<span
|
||
className={days <= 7 ? "text-orange-500 font-semibold" : ""}
|
||
>
|
||
{fmtDate(pkg.validUntil, {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
})}
|
||
{days <= 14 && ` (${days}d left)`}
|
||
</span>
|
||
</InfoPill>
|
||
</div>
|
||
|
||
{/* Service chips */}
|
||
{pkg.includedServices && pkg.includedServices.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5 mb-5">
|
||
{pkg.includedServices.slice(0, 4).map((svc, i) => (
|
||
<span
|
||
key={i}
|
||
className="inline-flex items-center gap-1 text-[10px] font-medium text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-2.5 py-1 rounded-full"
|
||
>
|
||
<CheckCircle2 className="w-3 h-3 text-green-500 flex-shrink-0" />
|
||
{stripBullet(svc).split(/\s+/).slice(0, 5).join(" ")}
|
||
</span>
|
||
))}
|
||
{pkg.includedServices.length > 4 && (
|
||
<span className="text-[10px] text-gray-400 flex items-center px-1">
|
||
+{pkg.includedServices.length - 4} more included
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Bottom: price + CTA */}
|
||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3 pt-5 border-t border-gray-100 dark:border-gray-800">
|
||
{price ? (
|
||
<div>
|
||
<p className="text-[10px] text-gray-400 uppercase tracking-wider font-medium">
|
||
Starting from
|
||
</p>
|
||
<p className="text-2xl font-extrabold text-primary leading-none mt-1">
|
||
{price.currency}{" "}
|
||
<span className="text-xl">
|
||
{price.amount.toLocaleString(undefined, {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
})}
|
||
</span>
|
||
</p>
|
||
<p className="text-[10px] text-gray-400 mt-1">
|
||
{pkg.priceTiers.length} seat class
|
||
{pkg.priceTiers.length !== 1 ? "es" : ""} available
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div />
|
||
)}
|
||
<span className="inline-flex items-center justify-center gap-2 bg-[rgb(20,113,76)] group-hover:bg-[rgb(16,89,60)] text-white text-sm font-bold px-5 py-3 rounded-xl shadow-lg group-hover:shadow-xl transition-all duration-200 group-hover:gap-3 w-full sm:w-auto">
|
||
View Package
|
||
<ArrowRight className="w-4 h-4" />
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
// ─── Regular Package Card ─────────────────────────────────────────────────────
|
||
|
||
function PackageCard({ pkg }: { pkg: HolidayPackage }) {
|
||
const multiplier = pkg.journeyType === "ROUND_TRIP" ? 2 : 1;
|
||
const price = minPrice(pkg.priceTiers, multiplier);
|
||
const origin = pkg.outboundSchedule?.originStation;
|
||
const dest = pkg.outboundSchedule?.destinationStation;
|
||
|
||
return (
|
||
<Link href={`/packages/${pkg.id}`} className="group block h-full">
|
||
<div className="bg-white dark:bg-gray-900 rounded-2xl overflow-hidden border border-gray-200 dark:border-gray-800 hover:border-primary/60 hover:shadow-xl transition-all duration-300 flex flex-col h-full">
|
||
{/* Image / Gradient */}
|
||
<div className="relative h-44 overflow-hidden bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] flex-shrink-0">
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<span className="text-6xl opacity-20">🌍</span>
|
||
</div>
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||
|
||
<div className="absolute top-3 left-3 flex items-center gap-2">
|
||
<UrgencyBadge until={pkg.validUntil} />
|
||
</div>
|
||
|
||
{pkg.returnSchedule && (
|
||
<div className="absolute bottom-3 left-3">
|
||
<span className="bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm text-gray-800 dark:text-white text-[10px] font-bold px-2.5 py-1 rounded-full">
|
||
↔ Round Trip
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex flex-col flex-1 p-4">
|
||
<h3 className="font-bold text-gray-900 dark:text-white text-sm leading-snug mb-2.5 line-clamp-2">
|
||
{pkg.name?.trim()}
|
||
</h3>
|
||
|
||
{/* Route */}
|
||
{origin && dest && (
|
||
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||
<MapPin className="w-3 h-3 text-primary flex-shrink-0" />
|
||
<span className="truncate">{origin.name?.trim()}</span>
|
||
<ArrowRight className="w-3 h-3 flex-shrink-0 text-gray-300" />
|
||
<span className="truncate">{dest.name?.trim()}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Validity */}
|
||
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||
<Shield className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
||
<span>{validityRange(pkg.validFrom, pkg.validUntil)}</span>
|
||
</div>
|
||
|
||
{/* Departure */}
|
||
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||
<Calendar className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
||
<span>
|
||
Departs{" "}
|
||
{fmtDate(pkg.departureTime, {
|
||
weekday: "short",
|
||
month: "short",
|
||
day: "numeric",
|
||
})}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Availability bar */}
|
||
<div className="mb-4">
|
||
<AvailBar booked={pkg.bookedCount} total={pkg.totalCapacity} />
|
||
</div>
|
||
|
||
{/* Price + CTA */}
|
||
<div className="mt-auto pt-3.5 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between gap-2">
|
||
{price ? (
|
||
<div className="min-w-0">
|
||
<p className="text-[10px] text-gray-400 uppercase tracking-wide">
|
||
From
|
||
</p>
|
||
<p className="text-sm font-extrabold text-primary truncate">
|
||
{fmtPrice(price.amount * 100, price.currency)}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div />
|
||
)}
|
||
<span className="flex-shrink-0 text-xs text-white bg-primary font-bold flex items-center gap-1 group-hover:gap-2 transition-all px-3 py-2 rounded-lg">
|
||
View <ArrowRight className="w-3.5 h-3.5" />
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
// ─── Skeletons ────────────────────────────────────────────────────────────────
|
||
|
||
function FeaturedSkeleton() {
|
||
return (
|
||
<div className="rounded-3xl overflow-hidden border border-gray-100 dark:border-gray-800 animate-pulse flex flex-col md:flex-row min-h-[340px]">
|
||
<div className="md:w-[46%] h-64 md:h-auto bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
|
||
<div className="flex-1 p-8 space-y-4">
|
||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/4" />
|
||
<div className="h-7 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
|
||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
|
||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||
{[1, 2, 3, 4].map((i) => (
|
||
<div
|
||
key={i}
|
||
className="h-10 bg-gray-200 dark:bg-gray-700 rounded"
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className="flex gap-2 pt-2">
|
||
{[1, 2, 3].map((i) => (
|
||
<div
|
||
key={i}
|
||
className="h-6 w-28 bg-gray-200 dark:bg-gray-700 rounded-full"
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CardSkeleton() {
|
||
return (
|
||
<div className="rounded-2xl overflow-hidden border border-gray-100 dark:border-gray-800 animate-pulse">
|
||
<div className="h-44 bg-gray-200 dark:bg-gray-700" />
|
||
<div className="p-4 space-y-3">
|
||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
|
||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
|
||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3" />
|
||
<div className="h-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Info Pill (for featured card) ────────────────────────────────────────────
|
||
|
||
function InfoPill({
|
||
icon,
|
||
label,
|
||
children,
|
||
}: {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="flex items-start gap-2">
|
||
<div className="flex-shrink-0 mt-0.5">{icon}</div>
|
||
<div className="min-w-0">
|
||
<p className="text-[10px] text-gray-400 uppercase tracking-wide font-semibold">
|
||
{label}
|
||
</p>
|
||
<p className="text-xs font-semibold text-gray-700 dark:text-gray-300 mt-0.5 leading-snug">
|
||
{children}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Section ──────────────────────────────────────────────────────────────────
|
||
|
||
export default function PackagesSection() {
|
||
const {
|
||
data: packages,
|
||
isLoading,
|
||
isError,
|
||
} = useQuery<HolidayPackage[]>({
|
||
queryKey: ["packages"],
|
||
queryFn: async () =>
|
||
(await apiClient.get<HolidayPackage[]>("/packages")) as HolidayPackage[],
|
||
});
|
||
|
||
if (isError) return null;
|
||
|
||
const [featured, ...rest] = packages ?? [];
|
||
|
||
return (
|
||
<section className="bg-gray-50 dark:bg-gray-950 py-12">
|
||
<div className="container mx-auto px-4">
|
||
<div className="max-w-6xl mx-auto space-y-6">
|
||
{/* Section header */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2.5">
|
||
<span className="text-xl">🏖️</span>
|
||
<h2 className="text-base font-bold text-gray-900 dark:text-white">
|
||
Holiday Packages
|
||
</h2>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Featured card */}
|
||
{isLoading ? (
|
||
<FeaturedSkeleton />
|
||
) : featured ? (
|
||
<FeaturedCard pkg={featured} />
|
||
) : null}
|
||
|
||
{/* Rest grid — 3 col desktop / 2 col tablet / 1 col mobile */}
|
||
{isLoading ? (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||
{[1, 2, 3].map((i) => (
|
||
<CardSkeleton key={i} />
|
||
))}
|
||
</div>
|
||
) : rest.length > 0 ? (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||
{rest.map((pkg) => (
|
||
<PackageCard key={pkg.id} pkg={pkg} />
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Empty state */}
|
||
{!isLoading && !isError && !packages?.length && (
|
||
<div className="py-16 text-center">
|
||
<span className="text-5xl block mb-3">🏖️</span>
|
||
<p className="text-sm text-gray-400">
|
||
No holiday packages available right now. Check back soon.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|