"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 (
{days === 0 ? "Last day!" : `Closes in ${days}d`}
);
}
// ─── 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 (
{total - booked} of {total} seats
{pct}% available
);
}
// ─── 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 (
{/* ── Left: Image ── */}
{/* Gradient overlay */}
{/* Top-left badges */}
✦ Featured Package
{/* Bottom-left: round trip tag */}
{pkg.returnSchedule && (
)}
{/* ── Right: Content ── */}
{/* Top section */}
{/* Status */}
{pkg.status === "ACTIVE" && (
Booking Open
)}
{/* Name */}
{pkg.name?.trim()}
{/* Route */}
{origin && dest && (
{origin.name?.trim()}
{dest.name?.trim()}
{pkg.busTransferIncluded && (
<>
+
{pkg.busTransferRoute?.trim() ?? "Bus transfer"}
>
)}
)}
{/* Info grid */}
}
label="Validity"
>
{validityRange(pkg.validFrom, pkg.validUntil)}
}
label="Booking closes"
>
{fmtDate(pkg.validUntil, {
month: "short",
day: "numeric",
year: "numeric",
})}
{days <= 14 && ` (${days}d left)`}
{/* Service chips */}
{pkg.includedServices && pkg.includedServices.length > 0 && (
{pkg.includedServices.slice(0, 4).map((svc, i) => (
{stripBullet(svc).split(/\s+/).slice(0, 5).join(" ")}
))}
{pkg.includedServices.length > 4 && (
+{pkg.includedServices.length - 4} more included
)}
)}
{/* Bottom: price + CTA */}
{price ? (
Starting from
{price.currency}{" "}
{price.amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
{pkg.priceTiers.length} seat class
{pkg.priceTiers.length !== 1 ? "es" : ""} available
) : (
)}
View Package
);
}
// ─── 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 (
{/* Image / Gradient */}
🌍
{pkg.returnSchedule && (
↔ Round Trip
)}
{/* Content */}
{pkg.name?.trim()}
{/* Route */}
{origin && dest && (
{origin.name?.trim()}
{dest.name?.trim()}
)}
{/* Validity */}
{validityRange(pkg.validFrom, pkg.validUntil)}
{/* Departure */}
Departs{" "}
{fmtDate(pkg.departureTime, {
weekday: "short",
month: "short",
day: "numeric",
})}
{/* Availability bar */}
{/* Price + CTA */}
{price ? (
From
{fmtPrice(price.amount * 100, price.currency)}
) : (
)}
View
);
}
// ─── Skeletons ────────────────────────────────────────────────────────────────
function FeaturedSkeleton() {
return (
{[1, 2, 3, 4].map((i) => (
))}
{[1, 2, 3].map((i) => (
))}
);
}
function CardSkeleton() {
return (
);
}
// ─── Info Pill (for featured card) ────────────────────────────────────────────
function InfoPill({
icon,
label,
children,
}: {
icon: React.ReactNode;
label: string;
children: React.ReactNode;
}) {
return (
);
}
// ─── Section ──────────────────────────────────────────────────────────────────
export default function PackagesSection() {
const {
data: packages,
isLoading,
isError,
} = useQuery({
queryKey: ["packages"],
queryFn: async () =>
(await apiClient.get("/packages")) as HolidayPackage[],
});
if (isError) return null;
const [featured, ...rest] = packages ?? [];
return (
{/* Section header */}
{/* Featured card */}
{isLoading ? (
) : featured ? (
) : null}
{/* Rest grid — 3 col desktop / 2 col tablet / 1 col mobile */}
{isLoading ? (
{[1, 2, 3].map((i) => (
))}
) : rest.length > 0 ? (
) : null}
{/* Empty state */}
{!isLoading && !isError && !packages?.length && (
🏖️
No holiday packages available right now. Check back soon.
)}
);
}