public UI updates
This commit is contained in:
parent
0160816b8e
commit
618d30aeef
|
|
@ -6,8 +6,9 @@ import { useRouter } from "next/navigation";
|
|||
import { useEffect } from "react";
|
||||
import { useBooking } from "@/context/BookingContext";
|
||||
import { useCurrency } from "@/context/CurrencyContext";
|
||||
import { formatEtb } from "@/lib/format-etb";
|
||||
import { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime";
|
||||
import { siteConfig } from "@/lib/mocks/site";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
export default function ConfirmationPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -21,6 +22,8 @@ export default function ConfirmationPage() {
|
|||
nights,
|
||||
total,
|
||||
resetBooking,
|
||||
holdReference,
|
||||
lastCreatedBooking,
|
||||
} = useBooking();
|
||||
|
||||
const { formatUsd } = useCurrency();
|
||||
|
|
@ -41,11 +44,16 @@ export default function ConfirmationPage() {
|
|||
</div>
|
||||
<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)]">
|
||||
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 className="mt-2 font-mono text-sm text-[var(--color-text)]">
|
||||
Confirmation: {confirmationId}
|
||||
</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 ? (
|
||||
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
||||
Paid at: {new Date(paidAt).toLocaleString()}
|
||||
|
|
@ -85,7 +93,12 @@ export default function ConfirmationPage() {
|
|||
</span>
|
||||
</p>
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import Link from "next/link";
|
|||
import { notFound } from "next/navigation";
|
||||
import { AmenityItem } from "@/components/AmenityItem";
|
||||
import { MeetingHalfDayRate } from "@/components/MeetingHalfDayRate";
|
||||
import { roomAmenities } from "@/lib/mocks/amenities";
|
||||
import { roomAmenities } from "@/lib/data/amenities";
|
||||
import {
|
||||
getAllMeetingSlugs,
|
||||
getMeetingSpaceBySlug,
|
||||
} from "@/lib/mocks/meetingSpaces";
|
||||
import { siteConfig } from "@/lib/mocks/site";
|
||||
} from "@/lib/data/meetingSpaces";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { useRouter } from "next/navigation";
|
|||
import { useEffect, useState } from "react";
|
||||
import { useBooking } from "@/context/BookingContext";
|
||||
import { useCurrency } from "@/context/CurrencyContext";
|
||||
import { siteConfig } from "@/lib/mocks/site";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime";
|
||||
import { processPayment } from "@/lib/mocks/api";
|
||||
import { formatEtb } from "@/lib/format-etb";
|
||||
|
||||
export function PaymentPageClient() {
|
||||
const router = useRouter();
|
||||
|
|
@ -27,6 +27,7 @@ export function PaymentPageClient() {
|
|||
holdReference,
|
||||
payLaterHold,
|
||||
setConfirmation,
|
||||
lastCreatedBooking,
|
||||
} = useBooking();
|
||||
|
||||
const { formatUsd } = useCurrency();
|
||||
|
|
@ -38,26 +39,31 @@ export function PaymentPageClient() {
|
|||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRoom || !guest.email) {
|
||||
if (!selectedRoom || !guest.email || !holdReference) {
|
||||
router.replace("/booking");
|
||||
}
|
||||
}, [selectedRoom, guest.email, router]);
|
||||
}, [selectedRoom, guest.email, holdReference, router]);
|
||||
|
||||
if (!selectedRoom) {
|
||||
if (!selectedRoom || !holdReference) {
|
||||
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() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const last4 = cardNumber.replace(/\D/g, "").slice(-4) || "0000";
|
||||
const result = await processPayment({
|
||||
totalCents: Math.round(total * 100),
|
||||
last4,
|
||||
});
|
||||
setConfirmation(result.confirmationId, result.paidAt);
|
||||
// Card UI is a placeholder; settlement is at the hotel until Stripe is wired.
|
||||
const id = lastCreatedBooking?.id ?? holdReference ?? "confirmed";
|
||||
setConfirmation(id, new Date().toISOString());
|
||||
router.push("/confirmation");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -68,7 +74,8 @@ export function PaymentPageClient() {
|
|||
<div className="mx-auto max-w-2xl px-4 py-12 md:py-16">
|
||||
<h1 className="font-heading text-3xl">Payment</h1>
|
||||
<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>
|
||||
|
||||
{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">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--color-muted)]">
|
||||
Card details (demo)
|
||||
Card details (optional placeholder)
|
||||
</h2>
|
||||
<label className="mt-4 block text-sm">
|
||||
<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">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[var(--color-muted)]">
|
||||
{formatUsd(selectedRoom.nightlyRate)} × {nights} nights
|
||||
{payIsEtb
|
||||
? `${formatEtb(selectedRoom.nightlyRate, 0)} × ${nights} nights`
|
||||
: `${formatUsd(selectedRoom.nightlyRate)} × ${nights} nights`}
|
||||
</dt>
|
||||
<dd>{formatUsd(subtotal)}</dd>
|
||||
<dd>{payIsEtb ? formatEtb(subtotal) : formatUsd(subtotal)}</dd>
|
||||
</div>
|
||||
{discountAmount > 0 ? (
|
||||
<div className="flex justify-between text-[var(--color-success)]">
|
||||
<dt>Discount</dt>
|
||||
<dd>-{formatUsd(discountAmount)}</dd>
|
||||
<dd>{payIsEtb ? `-${formatEtb(discountAmount)}` : `-${formatUsd(discountAmount)}`}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between">
|
||||
<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 className="flex justify-between border-t border-[var(--color-border)] pt-3 text-base font-semibold">
|
||||
<dt>Total</dt>
|
||||
<dd>{formatUsd(total)}</dd>
|
||||
<dd>{payIsEtb ? formatEtb(total) : formatUsd(total)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { siteConfig } from "@/lib/mocks/site";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
export function CallUsFab() {
|
||||
const tel = siteConfig.primaryPhone.replace(/\s/g, "");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* Maps “Share → Embed” without requiring an API key.
|
||||
*/
|
||||
export function GoogleMapEmbed({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import { createPortal } from "react-dom";
|
|||
import {
|
||||
bookingStyleReviews,
|
||||
overallRatingOutOfFive,
|
||||
} from "@/lib/mocks/bookingReviews";
|
||||
import { siteConfig } from "@/lib/mocks/site";
|
||||
} from "@/lib/data/bookingReviews";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
function useIsClient() {
|
||||
return useSyncExternalStore(
|
||||
|
|
|
|||
|
|
@ -1,14 +1,23 @@
|
|||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { FormattedUsd } from "@/components/FormattedUsd";
|
||||
import type { Room } from "@/lib/mocks/rooms";
|
||||
import { RoomPrice } from "@/components/RoomPrice";
|
||||
import type { Room } from "@/types/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) {
|
||||
const href = roomPrimaryHref(room);
|
||||
return (
|
||||
<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
|
||||
src={room.gallery[0]!}
|
||||
alt={room.name}
|
||||
|
|
@ -17,13 +26,13 @@ export function RoomCard({ room }: Props) {
|
|||
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">
|
||||
From <FormattedUsd amountUsd={room.nightlyRate} maximumFractionDigits={0} />
|
||||
From <RoomPrice room={room} maximumFractionDigits={0} />
|
||||
<span className="font-normal text-[var(--color-muted)]"> / night</span>
|
||||
</span>
|
||||
</Link>
|
||||
<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">
|
||||
<Link href={`/rooms/${room.slug}`} className="hover:text-[var(--color-primary)]">
|
||||
<Link href={href} className="hover:text-[var(--color-primary)]">
|
||||
{room.name}
|
||||
</Link>
|
||||
</h3>
|
||||
|
|
@ -32,10 +41,10 @@ export function RoomCard({ room }: Props) {
|
|||
</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<Link
|
||||
href={`/rooms/${room.slug}`}
|
||||
href={href}
|
||||
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
|
||||
href={`/booking?room=${room.id}`}
|
||||
|
|
|
|||
28
src/components/RoomPrice.tsx
Normal file
28
src/components/RoomPrice.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { siteConfig } from "@/lib/mocks/site";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import { Mock3DPlaceholder } from "./Mock3DPlaceholder";
|
||||
import { VirtualTourEmbed } from "./VirtualTourEmbed";
|
||||
|
||||
|
|
|
|||
21
src/lib/data/amenities.ts
Normal file
21
src/lib/data/amenities.ts
Normal 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: "Wi‑Fi / 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)" },
|
||||
];
|
||||
86
src/lib/data/meetingSpaces.ts
Normal file
86
src/lib/data/meetingSpaces.ts
Normal 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 hotel’s 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 Wi‑Fi / 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 25–30 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: "25–30 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 (2–4 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
78
src/lib/data/outlets.ts
Normal 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 25–30 guests.",
|
||||
bullets: [
|
||||
"25–30 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
108
src/lib/data/services.ts
Normal 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 you’re 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
48
src/lib/data/wellness.ts
Normal 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
16
src/types/room.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user