Added tour package booking option

This commit is contained in:
Roba Boru
2026-07-01 20:45:40 +03:00
parent 5bfcde0c40
commit 305e850073
5 changed files with 1459 additions and 101 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1532,107 +1532,6 @@ export default function SearchPage() {
</div>
</div>
{/* Promotions Section */}
<div className="bg-white dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="flex items-center gap-2 mb-6">
<span className="text-lg">🎁</span>
<h2 className="text-base font-bold text-gray-900 dark:text-white">
Offers &amp; Promotions
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{/* Wide promo card */}
<div className="md:col-span-2 relative rounded-2xl overflow-hidden min-h-[220px] group cursor-pointer">
<div className="absolute inset-0 bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)]" />
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage:
"repeating-linear-gradient(45deg, transparent, transparent 20px, rgba(255,255,255,0.3) 20px, rgba(255,255,255,0.3) 21px)",
}}
/>
<div className="absolute inset-0 bg-gradient-to-r from-black/30 to-transparent" />
<div className="relative z-10 p-7 flex flex-col justify-between h-full min-h-[220px]">
<div>
<span className="inline-block px-3 py-1 bg-white/20 text-white text-xs font-semibold rounded-full mb-3 backdrop-blur-sm">
Limited Time
</span>
<h3 className="text-2xl font-extrabold text-white leading-tight mb-2">
20% Off Weekend
<br />
Travel
</h3>
<p className="text-white/70 text-sm max-w-xs">
Book any weekend journey and save 20%. Valid for all seat
classes.
</p>
</div>
<div className="flex items-center justify-between mt-4">
<span className="text-white/60 text-xs">
Valid until 31 Dec 2024
</span>
<span className="flex items-center gap-1.5 text-white text-sm font-semibold group-hover:gap-3 transition-all">
Book now <ArrowRight className="w-4 h-4" />
</span>
</div>
</div>
</div>
{/* Narrow promo cards */}
<div className="flex flex-col gap-5">
<div className="relative rounded-2xl overflow-hidden min-h-[100px] group cursor-pointer">
<div className="absolute inset-0 bg-gradient-to-br from-amber-500 to-orange-600" />
<div className="absolute inset-0 bg-gradient-to-r from-black/20 to-transparent" />
<div className="relative z-10 p-5 flex flex-col justify-between h-full min-h-[100px]">
<div>
<span className="inline-block px-2.5 py-0.5 bg-white/25 text-white text-xs font-semibold rounded-full mb-2 backdrop-blur-sm">
New
</span>
<h3 className="text-lg font-bold text-white leading-tight">
Family Package
</h3>
<p className="text-white/75 text-xs mt-1">
4 tickets for the price of 3
</p>
</div>
<div className="flex items-center justify-end mt-3">
<span className="flex items-center gap-1 text-white text-xs font-semibold group-hover:gap-2 transition-all">
Learn more <ArrowRight className="w-3.5 h-3.5" />
</span>
</div>
</div>
</div>
<div className="relative rounded-2xl overflow-hidden min-h-[100px] group cursor-pointer">
<div className="absolute inset-0 bg-gradient-to-br from-blue-600 to-indigo-700" />
<div className="absolute inset-0 bg-gradient-to-r from-black/20 to-transparent" />
<div className="relative z-10 p-5 flex flex-col justify-between h-full min-h-[100px]">
<div>
<span className="inline-block px-2.5 py-0.5 bg-white/25 text-white text-xs font-semibold rounded-full mb-2 backdrop-blur-sm">
Student
</span>
<h3 className="text-lg font-bold text-white leading-tight">
Student Discount
</h3>
<p className="text-white/75 text-xs mt-1">
15% off with valid student ID
</p>
</div>
<div className="flex items-center justify-end mt-3">
<span className="flex items-center gap-1 text-white text-xs font-semibold group-hover:gap-2 transition-all">
Learn more <ArrowRight className="w-3.5 h-3.5" />
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style jsx>{`
@keyframes slide-up {
from {

View File

@@ -0,0 +1,913 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
import { useParams, useRouter } from "next/navigation";
import Image from "next/image";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
ChevronLeft,
Calendar,
MapPin,
Users,
Clock,
Train,
Bus,
Check,
AlertCircle,
Loader2,
ArrowRight,
Tag,
Shield,
X,
CheckCircle2,
Phone,
Mail,
User,
FileText,
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────────
interface Station {
id: string;
code: string;
name: string;
city: string;
countryCode: string;
}
interface TrainInfo {
id: string;
number: string;
name: string;
operatorName: string;
}
interface Schedule {
id: string;
departureAt: string;
arrivalAt: string;
durationMinutes: number;
status: string;
stopsCount: number;
originStation: Station;
destinationStation: Station;
train: TrainInfo;
}
interface PriceTier {
id: string;
packageId: string;
seatType: string;
label: string;
priceMinor: number;
currency: string;
availableSeats: number;
bookedSeats: number;
}
interface PackageDetail {
id: string;
code: string;
name: string;
description: string | null;
status: string;
boardingTime: string;
departureTime: string;
arrivalTime: string;
totalCapacity: number;
bookedCount: number;
includedServices: string[];
coachConfiguration: string;
busTransferIncluded: boolean;
busTransferRoute: string | null;
validFrom: string;
validUntil: string;
priceTiers: PriceTier[];
outboundSchedule: Schedule;
returnSchedule: Schedule | null;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmt(iso: string, opts?: Intl.DateTimeFormatOptions): string {
try {
return new Date(iso).toLocaleDateString("en-US", opts ?? {
weekday: "short", month: "short", day: "numeric",
});
} catch { return iso; }
}
function fmtTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
} catch { return iso; }
}
function durationLabel(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m ? `${h}h ${m}m` : `${h}h`;
}
function stripBullet(s: string): string {
return s.replace(/^[••\-\t\s]+/, "").trim();
}
function formatPrice(minor: number, currency: string): string {
return `${currency} ${(minor / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
// ─── Inquiry form ────────────────────────────────────────────────────────────
const inquirySchema = z.object({
travelerCount: z
.number({ invalid_type_error: "Enter a valid number" })
.int("Must be a whole number")
.min(1, "At least 1 traveler required")
.max(50, "Maximum 50 travelers per booking"),
contactName: z
.string()
.min(2, "Name must be at least 2 characters")
.max(100),
contactEmail: z
.string()
.min(1, "Email is required")
.email("Enter a valid email address"),
contactPhone: z
.string()
.min(7, "Enter a valid phone number")
.max(20, "Phone number too long")
.regex(/^[+\d\s\-()\\.]+$/, "Invalid phone number format"),
notes: z.string().max(500, "Notes must be under 500 characters").optional(),
});
type InquiryForm = z.infer<typeof inquirySchema>;
interface InquiryModalProps {
packageId: string;
packageName: string;
tier: PriceTier;
onClose: () => void;
}
function InquiryModal({ packageId, packageName, tier, onClose }: InquiryModalProps) {
const [submitted, setSubmitted] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<InquiryForm>({
resolver: zodResolver(inquirySchema as any),
defaultValues: { travelerCount: 1 },
});
const onSubmit = async (data: InquiryForm) => {
setSubmitError(null);
try {
await apiClient.post("/packages/inquiries", {
packageId,
priceTierId: tier.id,
travelerCount: data.travelerCount,
contactName: data.contactName,
contactEmail: data.contactEmail,
contactPhone: data.contactPhone,
notes: data.notes ?? "",
});
setSubmitted(true);
} catch (err: any) {
setSubmitError(
err?.response?.data?.message ||
"Something went wrong. Please try again.",
);
}
};
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-[90] bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4">
<div className="w-full sm:max-w-lg bg-white dark:bg-gray-900 rounded-t-3xl sm:rounded-2xl shadow-2xl overflow-hidden max-h-[92vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div>
<h2 className="text-base font-bold text-gray-900 dark:text-white">
Book Package
</h2>
<p className="text-xs text-gray-400 mt-0.5 line-clamp-1">
{packageName.trim()}
</p>
</div>
<button
type="button"
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
{/* Success state */}
{submitted ? (
<div className="flex-1 flex flex-col items-center justify-center px-6 py-12 text-center">
<div className="w-16 h-16 bg-green-50 dark:bg-green-900/20 rounded-full flex items-center justify-center mb-4">
<CheckCircle2 className="w-8 h-8 text-green-500" />
</div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
Inquiry Submitted!
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs">
We&apos;ve received your booking inquiry. Our team will contact you
shortly to confirm your reservation.
</p>
<button
type="button"
onClick={onClose}
className="mt-6 px-6 py-2.5 bg-primary text-white text-sm font-semibold rounded-xl hover:bg-[rgb(16,89,60)] transition-colors"
>
Done
</button>
</div>
) : (
<>
{/* Selected tier summary */}
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10 flex-shrink-0">
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] text-gray-400 uppercase tracking-wide font-semibold">
Selected seat type
</p>
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">
{tier.label.trim()}{" "}
<span className="text-[10px] font-normal text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded ml-1">
{tier.seatType.trim()}
</span>
</p>
</div>
<div className="text-right">
<p className="text-[10px] text-gray-400 uppercase tracking-wide font-semibold">
Per person
</p>
<p className="text-base font-extrabold text-primary mt-0.5">
{formatPrice(tier.priceMinor, tier.currency)}
</p>
</div>
</div>
</div>
{/* Form */}
<form
onSubmit={handleSubmit(onSubmit)}
className="flex-1 overflow-y-auto scrollbar-hide px-6 py-5 space-y-4"
>
{/* Traveler count */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
Number of Travelers <span className="text-red-500">*</span>
</label>
<div className="relative">
<Users className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="number"
min={1}
max={50}
{...register("travelerCount", { valueAsNumber: true })}
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
errors.travelerCount
? "border-red-400 focus:border-red-400"
: "border-gray-200 dark:border-gray-700 focus:border-primary"
}`}
/>
</div>
{errors.travelerCount && (
<p className="text-xs text-red-500 mt-1">{errors.travelerCount.message}</p>
)}
</div>
{/* Contact name */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
Full Name <span className="text-red-500">*</span>
</label>
<div className="relative">
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Your full name"
{...register("contactName")}
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
errors.contactName
? "border-red-400 focus:border-red-400"
: "border-gray-200 dark:border-gray-700 focus:border-primary"
}`}
/>
</div>
{errors.contactName && (
<p className="text-xs text-red-500 mt-1">{errors.contactName.message}</p>
)}
</div>
{/* Email */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
Email Address <span className="text-red-500">*</span>
</label>
<div className="relative">
<Mail className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="email"
placeholder="you@example.com"
{...register("contactEmail")}
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
errors.contactEmail
? "border-red-400 focus:border-red-400"
: "border-gray-200 dark:border-gray-700 focus:border-primary"
}`}
/>
</div>
{errors.contactEmail && (
<p className="text-xs text-red-500 mt-1">{errors.contactEmail.message}</p>
)}
</div>
{/* Phone */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
Phone Number <span className="text-red-500">*</span>
</label>
<div className="relative">
<Phone className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="tel"
placeholder="+251 912 345 678"
{...register("contactPhone")}
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
errors.contactPhone
? "border-red-400 focus:border-red-400"
: "border-gray-200 dark:border-gray-700 focus:border-primary"
}`}
/>
</div>
{errors.contactPhone && (
<p className="text-xs text-red-500 mt-1">{errors.contactPhone.message}</p>
)}
</div>
{/* Notes */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
Additional Notes{" "}
<span className="text-gray-400 font-normal">(optional)</span>
</label>
<div className="relative">
<FileText className="absolute left-3.5 top-3.5 w-4 h-4 text-gray-400" />
<textarea
rows={3}
placeholder="Any special requirements or questions..."
{...register("notes")}
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none transition-colors ${
errors.notes
? "border-red-400 focus:border-red-400"
: "border-gray-200 dark:border-gray-700 focus:border-primary"
}`}
/>
</div>
{errors.notes && (
<p className="text-xs text-red-500 mt-1">{errors.notes.message}</p>
)}
</div>
{/* API error */}
{submitError && (
<div className="flex items-start gap-2.5 p-3.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-xs text-red-600 dark:text-red-400">{submitError}</p>
</div>
)}
{/* Submit */}
<div className="pt-1 pb-2">
<button
type="submit"
disabled={isSubmitting}
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Submitting...
</>
) : (
<>
Submit Inquiry <ArrowRight className="w-4 h-4" />
</>
)}
</button>
<p className="text-[10px] text-gray-400 text-center mt-2">
Our team will contact you shortly to confirm.
</p>
</div>
</form>
</>
)}
</div>
</div>
</>
);
}
// ─── Journey Card ─────────────────────────────────────────────────────────────
function JourneyCard({ schedule, label }: { schedule: Schedule; label: string }) {
const dep = new Date(schedule.departureAt);
const arr = new Date(schedule.arrivalAt);
const isSameDay = dep.toDateString() === arr.toDateString();
return (
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-100 dark:border-gray-800 overflow-hidden">
<div className="flex items-center gap-2 px-5 py-3 bg-gray-50 dark:bg-gray-800/60 border-b border-gray-100 dark:border-gray-800">
<Train className="w-4 h-4 text-primary" />
<span className="text-sm font-bold text-gray-800 dark:text-white">{label}</span>
<span className="ml-auto text-xs text-gray-400">{schedule.train.number}</span>
</div>
<div className="p-5">
{/* Route row */}
<div className="flex items-center gap-3">
{/* Origin */}
<div className="flex-1 min-w-0">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">From</p>
<p className="text-xl font-extrabold text-gray-900 dark:text-white truncate">
{schedule.originStation.code}
</p>
<p className="text-xs text-gray-500 truncate">{schedule.originStation.name.trim()}</p>
</div>
{/* Center: duration + arrow */}
<div className="flex flex-col items-center gap-1 flex-shrink-0">
<span className="text-[10px] text-gray-400 font-medium">
{durationLabel(schedule.durationMinutes)}
</span>
<div className="relative w-16 flex items-center">
<div className="h-px w-full bg-gray-200 dark:bg-gray-700" />
<ArrowRight className="w-3 h-3 text-primary absolute -right-1" />
</div>
<span className="text-[10px] text-gray-400">{schedule.stopsCount} stops</span>
</div>
{/* Destination */}
<div className="flex-1 min-w-0 text-right">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">To</p>
<p className="text-xl font-extrabold text-gray-900 dark:text-white truncate">
{schedule.destinationStation.code}
</p>
<p className="text-xs text-gray-500 truncate">{schedule.destinationStation.name.trim()}</p>
</div>
</div>
{/* Times */}
<div className="flex items-end justify-between mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
<div>
<p className="text-lg font-bold text-primary">{fmtTime(schedule.departureAt)}</p>
<p className="text-xs text-gray-400">{fmt(schedule.departureAt, { weekday: "short", month: "short", day: "numeric" })}</p>
</div>
<div className="text-right">
<p className="text-lg font-bold text-primary">
{fmtTime(schedule.arrivalAt)}
{!isSameDay && <sup className="text-[10px] text-orange-400 ml-0.5">+1</sup>}
</p>
<p className="text-xs text-gray-400">{fmt(schedule.arrivalAt, { weekday: "short", month: "short", day: "numeric" })}</p>
</div>
</div>
<p className="mt-2 text-xs text-gray-400">{schedule.train.name}</p>
</div>
</div>
);
}
// ─── Price Tiers Panel ────────────────────────────────────────────────────────
function PriceTiersPanel({
tiers,
selectedTierId,
onSelect,
selectedTier,
onBookNow,
}: {
tiers: PriceTier[];
selectedTierId: string | null;
onSelect: (id: string) => void;
selectedTier?: PriceTier;
onBookNow: () => void;
}) {
return (
<div className="space-y-4">
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">
Select Seat Type
</h2>
{!tiers?.length ? (
<p className="text-sm text-gray-400 text-center py-4">
No price tiers available
</p>
) : (
<div className="space-y-2.5">
{tiers.map((tier) => {
const soldOut = tier.availableSeats === 0;
const selected = tier.id === selectedTierId;
return (
<button
key={tier.id}
type="button"
disabled={soldOut}
onClick={() => onSelect(tier.id)}
className={`w-full text-left rounded-xl border-2 p-3.5 transition-all ${
soldOut
? "border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed"
: selected
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm"
}`}
>
{/* Row 1: radio + full label */}
<div className="flex items-start gap-2.5">
<div
className={`w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center mt-0.5 transition-colors ${
selected
? "border-primary bg-primary"
: "border-gray-300 dark:border-gray-600"
}`}
>
{selected && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
</div>
<p className="text-sm font-semibold text-gray-900 dark:text-white leading-snug">
{tier.label.trim()}
</p>
</div>
{/* Row 2: seatType badge + seats + price */}
<div className="flex items-center justify-between mt-2 pl-[26px]">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">
{tier.seatType.trim()}
</span>
{soldOut ? (
<span className="text-[10px] font-bold text-red-500 bg-red-50 dark:bg-red-900/20 px-1.5 py-0.5 rounded">
SOLD OUT
</span>
) : (
<span className="text-[10px] text-gray-400">
{tier.availableSeats} left
</span>
)}
</div>
<p className="text-sm font-extrabold text-primary">
{formatPrice(tier.priceMinor, tier.currency)}
</p>
</div>
</button>
);
})}
</div>
)}
</div>
{/* Summary + CTA */}
{selectedTier && (
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-primary/30 shadow-sm">
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500 dark:text-gray-400">Seat type</p>
<p className="text-xs font-semibold text-gray-900 dark:text-white">
{selectedTier.label.trim()}
</p>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500 dark:text-gray-400">Price per person</p>
<p className="text-base font-extrabold text-primary">
{formatPrice(selectedTier.priceMinor, selectedTier.currency)}
</p>
</div>
</div>
<button
type="button"
onClick={onBookNow}
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2"
>
Book Now <ArrowRight className="w-4 h-4" />
</button>
</div>
)}
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function PackageDetailPage() {
const params = useParams();
const router = useRouter();
const id = params?.id as string;
const [selectedTierId, setSelectedTierId] = useState<string | null>(null);
const [inquiryOpen, setInquiryOpen] = useState(false);
const { data: pkg, isLoading, isError } = useQuery<PackageDetail>({
queryKey: ["package", id],
queryFn: async () =>
(await apiClient.get<PackageDetail>(`/packages/${id}`)) as PackageDetail,
enabled: !!id,
});
const selectedTier = pkg?.priceTiers?.find((t) => t.id === selectedTierId);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-3" />
<p className="text-sm text-gray-500">Loading package details...</p>
</div>
</div>
);
}
if (isError || !pkg) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<AlertCircle className="w-8 h-8 text-red-400 mx-auto mb-3" />
<p className="text-sm text-gray-500 mb-4">Unable to load package details.</p>
<button
onClick={() => router.back()}
className="text-sm text-primary font-medium flex items-center gap-1 mx-auto hover:underline"
>
<ChevronLeft className="w-4 h-4" /> Go back
</button>
</div>
</div>
);
}
const origin = pkg.outboundSchedule?.originStation;
const destination = pkg.outboundSchedule?.destinationStation;
const availableSeats = pkg.totalCapacity - pkg.bookedCount;
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Inquiry modal */}
{inquiryOpen && selectedTier && (
<InquiryModal
packageId={pkg.id}
packageName={pkg.name}
tier={selectedTier}
onClose={() => setInquiryOpen(false)}
/>
)}
{/* Hero */}
<div className="relative h-56 md:h-80 bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] overflow-hidden">
<Image
src="/packages/kulubi.jpeg"
alt={pkg.name}
fill
className="object-cover"
sizes="100vw"
priority
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/20 to-transparent" />
<button
onClick={() => router.back()}
className="absolute top-4 left-4 flex items-center gap-1.5 text-white/90 hover:text-white bg-black/25 backdrop-blur-sm px-3 py-2 rounded-full text-sm font-medium transition-colors"
>
<ChevronLeft className="w-4 h-4" /> Back
</button>
<div className="absolute bottom-0 left-0 right-0 p-6">
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-2 mb-2">
<span className="inline-block bg-white/20 text-white text-xs font-semibold px-2.5 py-1 rounded-full backdrop-blur-sm">
{pkg.code}
</span>
{pkg.status === "ACTIVE" && (
<span className="inline-block bg-green-500/90 text-white text-xs font-bold px-2.5 py-1 rounded-full">
Active
</span>
)}
</div>
<h1 className="text-2xl md:text-3xl font-extrabold text-white leading-tight">
{pkg.name.trim()}
</h1>
{origin && destination && (
<div className="flex items-center gap-1.5 text-white/75 text-sm mt-2">
<MapPin className="w-4 h-4 flex-shrink-0" />
{origin.name.trim()}
<ArrowRight className="w-3.5 h-3.5" />
{destination.name.trim()}
{pkg.returnSchedule && (
<span className="text-white/50 text-xs ml-1">· Round Trip</span>
)}
</div>
)}
</div>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* ── Main content ── */}
<div className="lg:col-span-2 space-y-5">
{/* Quick info strip */}
<div className="grid grid-cols-3 gap-3">
<div className="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800 text-center">
<Calendar className="w-5 h-5 text-primary mx-auto mb-1" />
<p className="text-[10px] text-gray-400 uppercase tracking-wide">Departure</p>
<p className="text-xs font-bold text-gray-800 dark:text-white mt-0.5">
{fmt(pkg.departureTime, { month: "short", day: "numeric" })}
</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800 text-center">
<Users className="w-5 h-5 text-primary mx-auto mb-1" />
<p className="text-[10px] text-gray-400 uppercase tracking-wide">Available</p>
<p className={`text-xs font-bold mt-0.5 ${availableSeats <= 20 ? "text-orange-500" : "text-gray-800 dark:text-white"}`}>
{availableSeats} seats
</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800 text-center">
<Tag className="w-5 h-5 text-primary mx-auto mb-1" />
<p className="text-[10px] text-gray-400 uppercase tracking-wide">From</p>
<p className="text-xs font-bold text-gray-800 dark:text-white mt-0.5">
{pkg.priceTiers.length
? formatPrice(
Math.min(...pkg.priceTiers.map((t) => t.priceMinor)),
pkg.priceTiers[0].currency,
)
: "—"}
</p>
</div>
</div>
{/* Description */}
{pkg.description && (
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 border border-gray-100 dark:border-gray-800">
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-3">
About this Package
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed">
{pkg.description}
</p>
</div>
)}
{/* Outbound journey */}
{pkg.outboundSchedule && (
<JourneyCard schedule={pkg.outboundSchedule} label="Outbound Journey" />
)}
{/* Return journey */}
{pkg.returnSchedule && (
<JourneyCard schedule={pkg.returnSchedule} label="Return Journey" />
)}
{/* Bus transfer */}
{pkg.busTransferIncluded && (
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-50 dark:bg-amber-900/20 rounded-xl flex items-center justify-center flex-shrink-0">
<Bus className="w-5 h-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-sm font-bold text-gray-900 dark:text-white">
Bus Transfer Included
</p>
{pkg.busTransferRoute && (
<p className="text-xs text-gray-500 mt-0.5">
{pkg.busTransferRoute.trim()}
</p>
)}
</div>
<Check className="w-5 h-5 text-green-500 ml-auto flex-shrink-0" />
</div>
</div>
)}
{/* Travel info */}
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 border border-gray-100 dark:border-gray-800">
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">
Travel Information
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InfoRow icon={<Clock className="w-4 h-4 text-primary" />} label="Boarding Time">
{fmtTime(pkg.boardingTime)} · {fmt(pkg.boardingTime)}
</InfoRow>
<InfoRow icon={<Calendar className="w-4 h-4 text-primary" />} label="Departure Time">
{fmtTime(pkg.departureTime)} · {fmt(pkg.departureTime)}
</InfoRow>
<InfoRow icon={<MapPin className="w-4 h-4 text-primary" />} label="Arrival">
{fmtTime(pkg.arrivalTime)} · {fmt(pkg.arrivalTime)}
</InfoRow>
<InfoRow icon={<Users className="w-4 h-4 text-primary" />} label="Total Capacity">
{pkg.totalCapacity} seats ({pkg.bookedCount} booked)
</InfoRow>
{pkg.coachConfiguration && (
<InfoRow icon={<Train className="w-4 h-4 text-primary" />} label="Coach Config">
{pkg.coachConfiguration.trim()}
</InfoRow>
)}
<InfoRow icon={<Shield className="w-4 h-4 text-primary" />} label="Valid Period">
{fmt(pkg.validFrom, { month: "short", day: "numeric" })} {" "}
{fmt(pkg.validUntil, { month: "short", day: "numeric", year: "numeric" })}
</InfoRow>
</div>
</div>
{/* Included services */}
{pkg.includedServices?.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 border border-gray-100 dark:border-gray-800">
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">
Included Services
</h2>
<ul className="space-y-2.5">
{pkg.includedServices.map((svc, i) => (
<li
key={i}
className="flex items-start gap-2.5 text-sm text-gray-600 dark:text-gray-400"
>
<Check className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
{stripBullet(svc)}
</li>
))}
</ul>
</div>
)}
{/* Price tiers — mobile */}
<div className="lg:hidden">
<PriceTiersPanel
tiers={pkg.priceTiers}
selectedTierId={selectedTierId}
onSelect={setSelectedTierId}
selectedTier={selectedTier}
onBookNow={() => setInquiryOpen(true)}
/>
</div>
</div>
{/* ── Sidebar — desktop ── */}
<div className="hidden lg:block">
<div className="sticky top-20">
<PriceTiersPanel
tiers={pkg.priceTiers}
selectedTierId={selectedTierId}
onSelect={setSelectedTierId}
selectedTier={selectedTier}
onBookNow={() => setInquiryOpen(true)}
/>
</div>
</div>
</div>
</div>
</div>
);
}
function InfoRow({
icon,
label,
children,
}: {
icon: React.ReactNode;
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">{icon}</div>
<div>
<p className="text-[10px] text-gray-400 uppercase tracking-wide font-semibold">
{label}
</p>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mt-0.5">
{children}
</p>
</div>
</div>
);
}

View File

@@ -1,10 +1,12 @@
import { Suspense } from 'react';
import SearchPage from '@/app/booking/search/page';
import PackagesSection from '@/components/PackagesSection';
export default function Home() {
return (
<Suspense>
<SearchPage />
<PackagesSection />
</Suspense>
);
}

View File

@@ -0,0 +1,544 @@
"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;
returnSchedule?: Schedule | null;
outboundSchedule?: Schedule;
priceTiers: PriceTier[];
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmtDate(iso: string, opts?: Intl.DateTimeFormatOptions): string {
try {
return new Date(iso).toLocaleDateString(
"en-US",
opts ?? { month: "short", day: "numeric" },
);
} 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[],
): { 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 / 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 price = minPrice(pkg.priceTiers);
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/kulubi.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 items-end justify-between 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-3xl font-extrabold text-primary leading-none mt-1">
{price.currency}{" "}
<span className="text-2xl">
{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 gap-2 bg-[rgb(20,113,76)] group-hover:bg-[rgb(16,89,60)] text-white text-sm font-bold px-6 py-3.5 rounded-xl shadow-lg group-hover:shadow-xl transition-all duration-200 group-hover:gap-3">
View Package
<ArrowRight className="w-4 h-4" />
</span>
</div>
</div>
</div>
</div>
</Link>
);
}
// ─── Regular Package Card ─────────────────────────────────────────────────────
function PackageCard({ pkg }: { pkg: HolidayPackage }) {
const price = minPrice(pkg.priceTiers);
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">
{price ? (
<div>
<p className="text-[10px] text-gray-400 uppercase tracking-wide">
From
</p>
<p className="text-base font-extrabold text-primary">
{fmtPrice(price.amount * 100, price.currency)}
</p>
</div>
) : (
<div />
)}
<span className="text-xs text-primary font-bold flex items-center gap-1 group-hover:gap-2 transition-all">
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[],
staleTime: 5 * 60 * 1000,
});
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>
);
}