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 { 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }> };
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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, "");
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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}`}
|
||||||
|
|
|
||||||
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 { Mock3DPlaceholder } from "./Mock3DPlaceholder";
|
||||||
import { VirtualTourEmbed } from "./VirtualTourEmbed";
|
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