From 618d30aeef1c6ecbd89b499ff926383a78341b33 Mon Sep 17 00:00:00 2001 From: brooktewabe Date: Tue, 14 Apr 2026 15:44:34 +0300 Subject: [PATCH] public UI updates --- src/app/confirmation/page.tsx | 19 ++++- src/app/meetings/[slug]/page.tsx | 6 +- src/app/payment/PaymentPageClient.tsx | 47 ++++++----- src/components/CallUsFab.tsx | 2 +- src/components/GoogleMapEmbed.tsx | 3 +- src/components/ReviewsMenu.tsx | 4 +- src/components/RoomCard.tsx | 23 ++++-- src/components/RoomPrice.tsx | 28 +++++++ src/components/VirtualTourBlock.tsx | 2 +- src/lib/data/amenities.ts | 21 +++++ src/lib/data/meetingSpaces.ts | 86 ++++++++++++++++++++ src/lib/data/outlets.ts | 78 +++++++++++++++++++ src/lib/data/services.ts | 108 ++++++++++++++++++++++++++ src/lib/data/wellness.ts | 48 ++++++++++++ src/types/room.ts | 16 ++++ 15 files changed, 453 insertions(+), 38 deletions(-) create mode 100644 src/components/RoomPrice.tsx create mode 100644 src/lib/data/amenities.ts create mode 100644 src/lib/data/meetingSpaces.ts create mode 100644 src/lib/data/outlets.ts create mode 100644 src/lib/data/services.ts create mode 100644 src/lib/data/wellness.ts create mode 100644 src/types/room.ts diff --git a/src/app/confirmation/page.tsx b/src/app/confirmation/page.tsx index 6e33908..c40b781 100644 --- a/src/app/confirmation/page.tsx +++ b/src/app/confirmation/page.tsx @@ -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() {

Your booking is confirmed

- 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}.

Confirmation: {confirmationId}

+ {holdReference ? ( +

+ Booking code: {holdReference} +

+ ) : null} {paidAt ? (

Paid at: {new Date(paidAt).toLocaleString()} @@ -85,7 +93,12 @@ export default function ConfirmationPage() {

-

Total paid: {formatUsd(total)}

+

+ Total paid:{" "} + {lastCreatedBooking?.currency === "ETB" || selectedRoom.priceCurrency === "ETB" + ? formatEtb(lastCreatedBooking?.totalPrice ?? total) + : formatUsd(lastCreatedBooking?.totalPrice ?? total)} +

diff --git a/src/app/meetings/[slug]/page.tsx b/src/app/meetings/[slug]/page.tsx index 94c64e9..dfe523b 100644 --- a/src/app/meetings/[slug]/page.tsx +++ b/src/app/meetings/[slug]/page.tsx @@ -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 }> }; diff --git a/src/app/payment/PaymentPageClient.tsx b/src/app/payment/PaymentPageClient.tsx index 0a0e411..45be41f 100644 --- a/src/app/payment/PaymentPageClient.tsx +++ b/src/app/payment/PaymentPageClient.tsx @@ -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() {

Payment

- 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.

{payLaterHold ? ( @@ -87,7 +94,7 @@ export function PaymentPageClient() {

- Card details (demo) + Card details (optional placeholder)

diff --git a/src/components/CallUsFab.tsx b/src/components/CallUsFab.tsx index f0d53f4..655549b 100644 --- a/src/components/CallUsFab.tsx +++ b/src/components/CallUsFab.tsx @@ -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, ""); diff --git a/src/components/GoogleMapEmbed.tsx b/src/components/GoogleMapEmbed.tsx index 2504627..ac3640a 100644 --- a/src/components/GoogleMapEmbed.tsx +++ b/src/components/GoogleMapEmbed.tsx @@ -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 ( diff --git a/src/components/ReviewsMenu.tsx b/src/components/ReviewsMenu.tsx index bffa456..c8c3b53 100644 --- a/src/components/ReviewsMenu.tsx +++ b/src/components/ReviewsMenu.tsx @@ -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( diff --git a/src/components/RoomCard.tsx b/src/components/RoomCard.tsx index 7fb3e3b..f0ff029 100644 --- a/src/components/RoomCard.tsx +++ b/src/components/RoomCard.tsx @@ -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 (
- + {room.name} - From + From / night

- + {room.name}

@@ -32,10 +41,10 @@ export function RoomCard({ room }: Props) {

- View details + {/^[0-9a-f-]{36}$/i.test(room.id) ? "Book this room" : "View details"} ; + maximumFractionDigits?: 0 | 1 | 2; + className?: string; +}; + +export function RoomPrice({ room, maximumFractionDigits = 0, className }: Props) { + const cur = room.priceCurrency ?? "USD"; + if (cur === "ETB") { + return ( + + {new Intl.NumberFormat("en-GB", { + style: "currency", + currency: "ETB", + maximumFractionDigits, + }).format(room.nightlyRate)} + + ); + } + return ( + + ); +} diff --git a/src/components/VirtualTourBlock.tsx b/src/components/VirtualTourBlock.tsx index 2eea01b..e5b5ed7 100644 --- a/src/components/VirtualTourBlock.tsx +++ b/src/components/VirtualTourBlock.tsx @@ -1,4 +1,4 @@ -import { siteConfig } from "@/lib/mocks/site"; +import { siteConfig } from "@/lib/site-config"; import { Mock3DPlaceholder } from "./Mock3DPlaceholder"; import { VirtualTourEmbed } from "./VirtualTourEmbed"; diff --git a/src/lib/data/amenities.ts b/src/lib/data/amenities.ts new file mode 100644 index 0000000..91ed432 --- /dev/null +++ b/src/lib/data/amenities.ts @@ -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)" }, +]; diff --git a/src/lib/data/meetingSpaces.ts b/src/lib/data/meetingSpaces.ts new file mode 100644 index 0000000..b245f0a --- /dev/null +++ b/src/lib/data/meetingSpaces.ts @@ -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); +} diff --git a/src/lib/data/outlets.ts b/src/lib/data/outlets.ts new file mode 100644 index 0000000..3ad4914 --- /dev/null +++ b/src/lib/data/outlets.ts @@ -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", + }, +]; diff --git a/src/lib/data/services.ts b/src/lib/data/services.ts new file mode 100644 index 0000000..4044c5b --- /dev/null +++ b/src/lib/data/services.ts @@ -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", + }, +]; diff --git a/src/lib/data/wellness.ts b/src/lib/data/wellness.ts new file mode 100644 index 0000000..0d31caa --- /dev/null +++ b/src/lib/data/wellness.ts @@ -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", + }, +]; diff --git a/src/types/room.ts b/src/types/room.ts new file mode 100644 index 0000000..a6e0f95 --- /dev/null +++ b/src/types/room.ts @@ -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; +};