public UI updates

This commit is contained in:
brooktewabe 2026-04-14 15:44:34 +03:00
parent 0160816b8e
commit 618d30aeef
15 changed files with 453 additions and 38 deletions

View File

@ -6,8 +6,9 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { useBooking } from "@/context/BookingContext"; import { useBooking } from "@/context/BookingContext";
import { useCurrency } from "@/context/CurrencyContext"; import { useCurrency } from "@/context/CurrencyContext";
import { formatEtb } from "@/lib/format-etb";
import { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime"; import { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime";
import { siteConfig } from "@/lib/mocks/site"; import { siteConfig } from "@/lib/site-config";
export default function ConfirmationPage() { export default function ConfirmationPage() {
const router = useRouter(); const router = useRouter();
@ -21,6 +22,8 @@ export default function ConfirmationPage() {
nights, nights,
total, total,
resetBooking, resetBooking,
holdReference,
lastCreatedBooking,
} = useBooking(); } = useBooking();
const { formatUsd } = useCurrency(); const { formatUsd } = useCurrency();
@ -41,11 +44,16 @@ export default function ConfirmationPage() {
</div> </div>
<h1 className="mt-8 font-heading text-3xl md:text-4xl">Your booking is confirmed</h1> <h1 className="mt-8 font-heading text-3xl md:text-4xl">Your booking is confirmed</h1>
<p className="mt-3 text-sm text-[var(--color-muted)]"> <p className="mt-3 text-sm text-[var(--color-muted)]">
Thank you, {guest.firstName}. A mock itinerary email would be sent to {guest.email}. Thank you, {guest.firstName}. Confirmation details have been sent to {guest.email}.
</p> </p>
<p className="mt-2 font-mono text-sm text-[var(--color-text)]"> <p className="mt-2 font-mono text-sm text-[var(--color-text)]">
Confirmation: {confirmationId} Confirmation: {confirmationId}
</p> </p>
{holdReference ? (
<p className="mt-1 text-xs text-[var(--color-muted)]">
Booking code: <span className="font-mono text-[var(--color-text)]">{holdReference}</span>
</p>
) : null}
{paidAt ? ( {paidAt ? (
<p className="mt-1 text-xs text-[var(--color-muted)]"> <p className="mt-1 text-xs text-[var(--color-muted)]">
Paid at: {new Date(paidAt).toLocaleString()} Paid at: {new Date(paidAt).toLocaleString()}
@ -85,7 +93,12 @@ export default function ConfirmationPage() {
</span> </span>
</p> </p>
</div> </div>
<p className="font-semibold">Total paid: {formatUsd(total)}</p> <p className="font-semibold">
Total paid:{" "}
{lastCreatedBooking?.currency === "ETB" || selectedRoom.priceCurrency === "ETB"
? formatEtb(lastCreatedBooking?.totalPrice ?? total)
: formatUsd(lastCreatedBooking?.totalPrice ?? total)}
</p>
</div> </div>
</div> </div>

View File

@ -3,12 +3,12 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { AmenityItem } from "@/components/AmenityItem"; import { AmenityItem } from "@/components/AmenityItem";
import { MeetingHalfDayRate } from "@/components/MeetingHalfDayRate"; import { MeetingHalfDayRate } from "@/components/MeetingHalfDayRate";
import { roomAmenities } from "@/lib/mocks/amenities"; import { roomAmenities } from "@/lib/data/amenities";
import { import {
getAllMeetingSlugs, getAllMeetingSlugs,
getMeetingSpaceBySlug, getMeetingSpaceBySlug,
} from "@/lib/mocks/meetingSpaces"; } from "@/lib/data/meetingSpaces";
import { siteConfig } from "@/lib/mocks/site"; import { siteConfig } from "@/lib/site-config";
import type { Metadata } from "next"; import type { Metadata } from "next";
type Props = { params: Promise<{ slug: string }> }; type Props = { params: Promise<{ slug: string }> };

View File

@ -6,9 +6,9 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useBooking } from "@/context/BookingContext"; import { useBooking } from "@/context/BookingContext";
import { useCurrency } from "@/context/CurrencyContext"; import { useCurrency } from "@/context/CurrencyContext";
import { siteConfig } from "@/lib/mocks/site"; import { siteConfig } from "@/lib/site-config";
import { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime"; import { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime";
import { processPayment } from "@/lib/mocks/api"; import { formatEtb } from "@/lib/format-etb";
export function PaymentPageClient() { export function PaymentPageClient() {
const router = useRouter(); const router = useRouter();
@ -27,6 +27,7 @@ export function PaymentPageClient() {
holdReference, holdReference,
payLaterHold, payLaterHold,
setConfirmation, setConfirmation,
lastCreatedBooking,
} = useBooking(); } = useBooking();
const { formatUsd } = useCurrency(); const { formatUsd } = useCurrency();
@ -38,26 +39,31 @@ export function PaymentPageClient() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (!selectedRoom || !guest.email) { if (!selectedRoom || !guest.email || !holdReference) {
router.replace("/booking"); router.replace("/booking");
} }
}, [selectedRoom, guest.email, router]); }, [selectedRoom, guest.email, holdReference, router]);
if (!selectedRoom) { if (!selectedRoom || !holdReference) {
return null; return null;
} }
const payLabel = `Confirm & pay ${formatUsd(total)}`; const payTotal =
lastCreatedBooking?.totalPrice != null && Number.isFinite(lastCreatedBooking.totalPrice)
? lastCreatedBooking.totalPrice
: total;
const payIsEtb =
lastCreatedBooking?.currency === "ETB" || selectedRoom?.priceCurrency === "ETB";
const payLabel = payIsEtb
? `Confirm & pay ${formatEtb(payTotal, 2)}`
: `Confirm & pay ${formatUsd(payTotal)}`;
async function handlePay() { async function handlePay() {
setLoading(true); setLoading(true);
try { try {
const last4 = cardNumber.replace(/\D/g, "").slice(-4) || "0000"; // Card UI is a placeholder; settlement is at the hotel until Stripe is wired.
const result = await processPayment({ const id = lastCreatedBooking?.id ?? holdReference ?? "confirmed";
totalCents: Math.round(total * 100), setConfirmation(id, new Date().toISOString());
last4,
});
setConfirmation(result.confirmationId, result.paidAt);
router.push("/confirmation"); router.push("/confirmation");
} finally { } finally {
setLoading(false); setLoading(false);
@ -68,7 +74,8 @@ export function PaymentPageClient() {
<div className="mx-auto max-w-2xl px-4 py-12 md:py-16"> <div className="mx-auto max-w-2xl px-4 py-12 md:py-16">
<h1 className="font-heading text-3xl">Payment</h1> <h1 className="font-heading text-3xl">Payment</h1>
<p className="mt-2 text-sm text-[var(--color-muted)]"> <p className="mt-2 text-sm text-[var(--color-muted)]">
Mock form only read our privacy policy before a real launch. Payment gateway is not connected yet confirming here records intent; settle at the front desk
or add a card processor later.
</p> </p>
{payLaterHold ? ( {payLaterHold ? (
@ -87,7 +94,7 @@ export function PaymentPageClient() {
<div className="mt-6 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm"> <div className="mt-6 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--color-muted)]"> <h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--color-muted)]">
Card details (demo) Card details (optional placeholder)
</h2> </h2>
<label className="mt-4 block text-sm"> <label className="mt-4 block text-sm">
<span className="mb-1 block text-[var(--color-muted)]">Cardholder name</span> <span className="mb-1 block text-[var(--color-muted)]">Cardholder name</span>
@ -156,23 +163,25 @@ export function PaymentPageClient() {
<dl className="mt-6 space-y-2 text-sm"> <dl className="mt-6 space-y-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<dt className="text-[var(--color-muted)]"> <dt className="text-[var(--color-muted)]">
{formatUsd(selectedRoom.nightlyRate)} × {nights} nights {payIsEtb
? `${formatEtb(selectedRoom.nightlyRate, 0)} × ${nights} nights`
: `${formatUsd(selectedRoom.nightlyRate)} × ${nights} nights`}
</dt> </dt>
<dd>{formatUsd(subtotal)}</dd> <dd>{payIsEtb ? formatEtb(subtotal) : formatUsd(subtotal)}</dd>
</div> </div>
{discountAmount > 0 ? ( {discountAmount > 0 ? (
<div className="flex justify-between text-[var(--color-success)]"> <div className="flex justify-between text-[var(--color-success)]">
<dt>Discount</dt> <dt>Discount</dt>
<dd>-{formatUsd(discountAmount)}</dd> <dd>{payIsEtb ? `-${formatEtb(discountAmount)}` : `-${formatUsd(discountAmount)}`}</dd>
</div> </div>
) : null} ) : null}
<div className="flex justify-between"> <div className="flex justify-between">
<dt className="text-[var(--color-muted)]">Taxes & fees ({siteConfig.taxRate * 100}%)</dt> <dt className="text-[var(--color-muted)]">Taxes & fees ({siteConfig.taxRate * 100}%)</dt>
<dd>{formatUsd(taxAmount)}</dd> <dd>{payIsEtb ? formatEtb(taxAmount) : formatUsd(taxAmount)}</dd>
</div> </div>
<div className="flex justify-between border-t border-[var(--color-border)] pt-3 text-base font-semibold"> <div className="flex justify-between border-t border-[var(--color-border)] pt-3 text-base font-semibold">
<dt>Total</dt> <dt>Total</dt>
<dd>{formatUsd(total)}</dd> <dd>{payIsEtb ? formatEtb(total) : formatUsd(total)}</dd>
</div> </div>
</dl> </dl>
</div> </div>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { siteConfig } from "@/lib/mocks/site"; import { siteConfig } from "@/lib/site-config";
export function CallUsFab() { export function CallUsFab() {
const tel = siteConfig.primaryPhone.replace(/\s/g, ""); const tel = siteConfig.primaryPhone.replace(/\s/g, "");

View File

@ -1,8 +1,7 @@
import { siteConfig } from "@/lib/mocks/site"; import { siteConfig } from "@/lib/site-config";
/** /**
* Google Maps embed (search result for the hotel). Uses the same pattern as * Google Maps embed (search result for the hotel). Uses the same pattern as
* Maps Share Embed without requiring an API key.
*/ */
export function GoogleMapEmbed({ className = "" }: { className?: string }) { export function GoogleMapEmbed({ className = "" }: { className?: string }) {
return ( return (

View File

@ -13,8 +13,8 @@ import { createPortal } from "react-dom";
import { import {
bookingStyleReviews, bookingStyleReviews,
overallRatingOutOfFive, overallRatingOutOfFive,
} from "@/lib/mocks/bookingReviews"; } from "@/lib/data/bookingReviews";
import { siteConfig } from "@/lib/mocks/site"; import { siteConfig } from "@/lib/site-config";
function useIsClient() { function useIsClient() {
return useSyncExternalStore( return useSyncExternalStore(

View File

@ -1,14 +1,23 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { FormattedUsd } from "@/components/FormattedUsd"; import { RoomPrice } from "@/components/RoomPrice";
import type { Room } from "@/lib/mocks/rooms"; import type { Room } from "@/types/room";
type Props = { room: Room }; type Props = { room: Room };
/** API rooms use UUID ids — deep links go to booking; static marketing rooms keep /rooms/[slug]. */
function roomPrimaryHref(room: Room): string {
if (/^[0-9a-f-]{36}$/i.test(room.id)) {
return `/booking?room=${encodeURIComponent(room.id)}`;
}
return `/rooms/${room.slug}`;
}
export function RoomCard({ room }: Props) { export function RoomCard({ room }: Props) {
const href = roomPrimaryHref(room);
return ( return (
<article className="group card-lift flex flex-col overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm"> <article className="group card-lift flex flex-col overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm">
<Link href={`/rooms/${room.slug}`} className="relative aspect-[4/3] overflow-hidden"> <Link href={href} className="relative aspect-[4/3] overflow-hidden">
<Image <Image
src={room.gallery[0]!} src={room.gallery[0]!}
alt={room.name} alt={room.name}
@ -17,13 +26,13 @@ export function RoomCard({ room }: Props) {
sizes="(max-width:768px) 100vw, 33vw" sizes="(max-width:768px) 100vw, 33vw"
/> />
<span className="absolute right-3 top-3 rounded-full bg-[var(--color-surface)]/90 px-3 py-1 text-xs font-semibold text-[var(--color-primary)] shadow-sm backdrop-blur"> <span className="absolute right-3 top-3 rounded-full bg-[var(--color-surface)]/90 px-3 py-1 text-xs font-semibold text-[var(--color-primary)] shadow-sm backdrop-blur">
From <FormattedUsd amountUsd={room.nightlyRate} maximumFractionDigits={0} /> From <RoomPrice room={room} maximumFractionDigits={0} />
<span className="font-normal text-[var(--color-muted)]"> / night</span> <span className="font-normal text-[var(--color-muted)]"> / night</span>
</span> </span>
</Link> </Link>
<div className="flex flex-1 flex-col p-5 md:p-6"> <div className="flex flex-1 flex-col p-5 md:p-6">
<h3 className="font-heading text-xl text-[var(--color-text)] md:text-2xl"> <h3 className="font-heading text-xl text-[var(--color-text)] md:text-2xl">
<Link href={`/rooms/${room.slug}`} className="hover:text-[var(--color-primary)]"> <Link href={href} className="hover:text-[var(--color-primary)]">
{room.name} {room.name}
</Link> </Link>
</h3> </h3>
@ -32,10 +41,10 @@ export function RoomCard({ room }: Props) {
</p> </p>
<div className="mt-4 flex items-center justify-between gap-3"> <div className="mt-4 flex items-center justify-between gap-3">
<Link <Link
href={`/rooms/${room.slug}`} href={href}
className="text-sm font-semibold text-[var(--color-primary)] underline-offset-4 hover:underline" className="text-sm font-semibold text-[var(--color-primary)] underline-offset-4 hover:underline"
> >
View details {/^[0-9a-f-]{36}$/i.test(room.id) ? "Book this room" : "View details"}
</Link> </Link>
<Link <Link
href={`/booking?room=${room.id}`} href={`/booking?room=${room.id}`}

View File

@ -0,0 +1,28 @@
"use client";
import { FormattedUsd } from "@/components/FormattedUsd";
import type { Room } from "@/types/room";
type Props = {
room: Pick<Room, "nightlyRate" | "priceCurrency">;
maximumFractionDigits?: 0 | 1 | 2;
className?: string;
};
export function RoomPrice({ room, maximumFractionDigits = 0, className }: Props) {
const cur = room.priceCurrency ?? "USD";
if (cur === "ETB") {
return (
<span className={className}>
{new Intl.NumberFormat("en-GB", {
style: "currency",
currency: "ETB",
maximumFractionDigits,
}).format(room.nightlyRate)}
</span>
);
}
return (
<FormattedUsd amountUsd={room.nightlyRate} maximumFractionDigits={maximumFractionDigits} className={className} />
);
}

View File

@ -1,4 +1,4 @@
import { siteConfig } from "@/lib/mocks/site"; import { siteConfig } from "@/lib/site-config";
import { Mock3DPlaceholder } from "./Mock3DPlaceholder"; import { Mock3DPlaceholder } from "./Mock3DPlaceholder";
import { VirtualTourEmbed } from "./VirtualTourEmbed"; import { VirtualTourEmbed } from "./VirtualTourEmbed";

21
src/lib/data/amenities.ts Normal file
View File

@ -0,0 +1,21 @@
import type { AmenityIconId } from "@/components/icons/AmenityIcon";
export type AmenityWithIcon = {
icon: AmenityIconId;
label: string;
};
export const roomAmenities: AmenityWithIcon[] = [
{ icon: "breakfast", label: "B/B Fast" },
{ icon: "shuttle", label: "Shuttle" },
{ icon: "wifi", label: "WiFi / LAN" },
{ icon: "sparkle", label: "Premium amenities" },
{ icon: "tv", label: "IPTV" },
{ icon: "kitchen", label: "State of the art kitchenette" },
{ icon: "views", label: "Amazing views" },
{ icon: "minibar", label: "Mini bar" },
{ icon: "lock", label: "Safe boxes" },
{ icon: "iron", label: "Iron & board" },
{ icon: "router", label: "Private routers" },
{ icon: "laundry", label: "Laundry (paid services)" },
];

View File

@ -0,0 +1,86 @@
import type { AmenityWithIcon } from "./amenities";
export type MeetingSpace = {
slug: string;
name: string;
shortDescription: string;
longDescription: string;
capacity: string;
floor: string;
image: string;
gallery: string[];
amenities: AmenityWithIcon[];
layouts: string[];
catering: string[];
/** Mock half-day rate in USD for display (converted via currency switcher) */
halfDayRateUsd: number;
};
export const meetingSpaces: MeetingSpace[] = [
{
slug: "serenity",
name: "Serenity Meeting Room",
shortDescription: "Versatile event space for up to 100 guests on the 1st floor.",
longDescription:
"Serenity is designed for board sessions, cocktail receptions, and medium-scale corporate events. Natural light options, flexible seating, and dedicated support for AV and catering make it the hotels flagship meeting venue.",
capacity: "Up to 100 guests (theatre / cocktail configurations)",
floor: "1st floor",
image:
"https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200&q=80",
gallery: [
"https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200&q=80",
"https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=1200&q=80",
"https://images.unsplash.com/photo-1511578314322-379afb476865?w=1200&q=80",
],
amenities: [
{ icon: "wifi", label: "High-speed WiFi / LAN" },
{ icon: "projector", label: "Projector & screen" },
{ icon: "microphone", label: "Wireless microphones" },
{ icon: "clipboard", label: "Flip charts & stationery" },
{ icon: "thermometer", label: "Climate control" },
{ icon: "handshake", label: "Dedicated event coordinator (on request)" },
{ icon: "doorOpen", label: "Breakout foyer access" },
{ icon: "accessibility", label: "Accessible routes" },
],
layouts: ["Boardroom", "U-shape", "Theatre", "Classroom", "Cocktail / standing"],
catering: ["Buffet menus", "Tea & coffee breaks", "Working lunch packages", "Gala dinner (via FeastVille)"],
halfDayRateUsd: 850,
},
{
slug: "fasika",
name: "Fasika Board Room",
shortDescription: "Executive board room for 2530 guests — intimate and fully equipped.",
longDescription:
"Fasika offers privacy and polish for leadership offsites, signing ceremonies, and focused workshops. Sound-treated walls, ergonomic seating, and premium coffee service keep sessions productive.",
capacity: "2530 guests (boardroom style)",
floor: "1st floor",
image:
"https://images.unsplash.com/photo-1560179707-f14e90ef3623?w=1200&q=80",
gallery: [
"https://images.unsplash.com/photo-1560179707-f14e90ef3623?w=1200&q=80",
"https://images.unsplash.com/photo-1542744173-8e7e53415bb0?w=1200&q=80",
"https://images.unsplash.com/photo-1600880292203-757bb62b4baf?w=1200&q=80",
],
amenities: [
{ icon: "monitor", label: "4K display & HDMI / USB-C" },
{ icon: "video", label: "Video-conferencing ready" },
{ icon: "chair", label: "Executive leather seating" },
{ icon: "volumeMuted", label: "Sound dampening" },
{ icon: "restroom", label: "Private washroom adjacency" },
{ icon: "pen", label: "Notepads & pens" },
{ icon: "droplet", label: "Complimentary mineral water" },
{ icon: "phone", label: "Dedicated phone line (on request)" },
],
layouts: ["Boardroom", "Interview (24 pax)", "Small workshop"],
catering: ["Executive breakfast", "Coffee & pastries", "Light lunch boxes"],
halfDayRateUsd: 420,
},
];
export function getMeetingSpaceBySlug(slug: string): MeetingSpace | undefined {
return meetingSpaces.find((m) => m.slug === slug);
}
export function getAllMeetingSlugs(): string[] {
return meetingSpaces.map((m) => m.slug);
}

78
src/lib/data/outlets.ts Normal file
View File

@ -0,0 +1,78 @@
export type Outlet = {
slug: string;
name: string;
tagline: string;
bullets: string[];
image: string;
floor?: string;
/** Link to detail page when set (e.g. meeting rooms) */
detailHref?: string;
};
export const outlets: Outlet[] = [
{
slug: "feastville",
name: "FeastVille Restaurant",
tagline: "Full American breakfast to theme nights — savour every moment.",
bullets: [
"Full American breakfast",
"Traditional & international menu",
"Theme nights selection",
"Room service menu",
],
image:
"https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=1200&q=80",
},
{
slug: "central-cafe",
name: "Central Cafe",
tagline: "Purely urban vibes — coffee at the heart of the city.",
bullets: [
"Your perfect rendezvous in the city centre",
"Ideal to initiate, elevate & conclude your day",
],
image:
"https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?w=1200&q=80",
},
{
slug: "tabsia",
name: "TABSIA Bar",
tagline: "Cocktails, spirits, and a refined atmosphere.",
bullets: [
"Located on the 1st floor",
"Cocktails, spirits & more",
"Unwind after a long day",
],
floor: "1st floor",
image:
"https://images.unsplash.com/photo-1551024506-0bccd828d307?w=1200&q=80&auto=format&fit=crop",
},
{
slug: "serenity",
name: "Serenity Meeting Room",
tagline: "Board meetings, cocktails, and events up to 100 guests.",
bullets: [
"Up to 100 pax",
"Fully equipped with basics & stationeries",
"Buffet or tea break menus",
],
floor: "1st floor",
detailHref: "/meetings/serenity",
image:
"https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200&q=80",
},
{
slug: "fasika",
name: "Fasika Board Room",
tagline: "Intimate executive sessions for 2530 guests.",
bullets: [
"2530 pax",
"Board & cocktail setups",
"Equipment & catering options",
],
floor: "1st floor",
detailHref: "/meetings/fasika",
image:
"https://images.unsplash.com/photo-1560179707-f14e90ef3623?w=1200&q=80",
},
];

108
src/lib/data/services.ts Normal file
View File

@ -0,0 +1,108 @@
/**
* Bookable Spa & Gym offerings for the dedicated /services page (mock pricing).
*/
export type SpaGymKind = "spa" | "gym";
export type SpaGymService = {
id: string;
kind: SpaGymKind;
title: string;
description: string;
duration: string;
priceUsd: number;
/** Shown on card badge, e.g. "per session" */
priceNote: string;
image: string;
};
export const spaGymFilterIds = ["all", "spa", "gym"] as const;
export type SpaGymFilterId = (typeof spaGymFilterIds)[number];
export const spaGymFilters: { id: SpaGymFilterId; label: string }[] = [
{ id: "all", label: "All" },
{ id: "spa", label: "Spa" },
{ id: "gym", label: "Gym" },
];
export const spaGymServices: SpaGymService[] = [
{
id: "gym-day-pass",
kind: "gym",
title: "Fitness day pass",
description: "Full access to cardio, weights, and stretch zones for one calendar day.",
duration: "All day · 6:00 — 22:00",
priceUsd: 18,
priceNote: "per guest / day",
image: "https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=900&q=80",
},
{
id: "gym-pt",
kind: "gym",
title: "Personal training",
description: "One-on-one session tailored to your goals — form, intensity, and recovery.",
duration: "45 minutes",
priceUsd: 55,
priceNote: "per session",
image: "https://images.unsplash.com/photo-1571019614242-c5c5dee9f50b?w=900&q=80",
},
{
id: "gym-hiit",
kind: "gym",
title: "Small-group HIIT",
description: "High-energy class in our studio — limited spots, hotel guests priority.",
duration: "50 minutes",
priceUsd: 28,
priceNote: "per class",
image: "https://images.unsplash.com/photo-1517836357463-d25dfeac3438?w=900&q=80",
},
{
id: "spa-swedish",
kind: "spa",
title: "Signature Swedish massage",
description: "Long, flowing strokes to ease travel tension and improve circulation.",
duration: "60 minutes",
priceUsd: 85,
priceNote: "per treatment",
image: "https://images.unsplash.com/photo-1544161515-4ab6ce6db874?w=900&q=80",
},
{
id: "spa-deep",
kind: "spa",
title: "Deep tissue therapy",
description: "Targeted work for shoulders, back, and legs after long flights.",
duration: "90 minutes",
priceUsd: 125,
priceNote: "per treatment",
image: "https://images.unsplash.com/photo-1600334129128-0c9b275703e6?w=900&q=80",
},
{
id: "spa-express",
kind: "spa",
title: "Express back & neck",
description: "Focused relief when youre between meetings — clothes-on option.",
duration: "30 minutes",
priceUsd: 52,
priceNote: "per treatment",
image: "https://images.unsplash.com/photo-1519823551278-64ac92734fb1?w=900&q=80",
},
{
id: "spa-aroma",
kind: "spa",
title: "Aromatherapy ritual",
description: "Custom oil blend, warm compress, and full-body massage sequence.",
duration: "75 minutes",
priceUsd: 98,
priceNote: "per treatment",
image: "https://images.unsplash.com/photo-1540555700478-4be289fbecef?w=900&q=80",
},
{
id: "spa-couples",
kind: "spa",
title: "Couples suite ritual",
description: "Side-by-side massage in our private suite — sparkling water included.",
duration: "90 minutes",
priceUsd: 220,
priceNote: "per couple",
image: "https://images.unsplash.com/photo-1600334089648-b0d9d3028eb2?w=900&q=80",
},
];

48
src/lib/data/wellness.ts Normal file
View File

@ -0,0 +1,48 @@
import type { AmenityWithIcon } from "./amenities";
export type WellnessFacility = {
id: string;
title: string;
subtitle: string;
description: string;
image: string;
amenities: AmenityWithIcon[];
hours: string;
};
export const wellnessFacilities: WellnessFacility[] = [
{
id: "gym",
title: "Fitness centre",
subtitle: "Train on your schedule",
description:
"Cardio machines, free weights, and functional training space — maintained daily and stocked with fresh towels and chilled water. Perfect before meetings or after long flights.",
image:
"https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=1200&q=80",
amenities: [
{ icon: "treadmill", label: "Treadmills & ellipticals" },
{ icon: "dumbbell", label: "Dumbbells & kettlebells" },
{ icon: "stretch", label: "Stretching zone" },
{ icon: "towel", label: "Towel service" },
{ icon: "headphones", label: "Bluetooth audio (personal headsets)" },
],
hours: "6:00 — 22:00 daily",
},
{
id: "spa",
title: "Spa & wellness",
subtitle: "Restore and unwind",
description:
"Therapeutic massages, express treatments, and calming lounges inspired by Ethiopian botanicals. Book ahead for couples rituals or post-event recovery sessions.",
image:
"https://images.unsplash.com/photo-1540555700478-4be289fbecef?w=1200&q=80",
amenities: [
{ icon: "massage", label: "Signature massage menu" },
{ icon: "steam", label: "Steam experience (select days)" },
{ icon: "leaf", label: "Aromatherapy add-ons" },
{ icon: "lounge", label: "Private treatment suites" },
{ icon: "boutique", label: "Retail boutique" },
],
hours: "10:00 — 20:00 · appointments recommended",
},
];

16
src/types/room.ts Normal file
View File

@ -0,0 +1,16 @@
export type Room = {
id: string;
slug: string;
name: string;
shortDescription: string;
longDescription: string;
nightlyRate: number;
priceCurrency: "USD" | "ETB";
maxGuests: number;
beds: string;
sizeSqM: number;
view: string;
highlights: string[];
gallery: string[];
tourEmbedUrl: string | null;
};