Files
edr-platform/apps/edr-passenger-web/portal/src/components/PackagesSection.tsx

555 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}