Add Shitaye Suite Hotel Next.js frontend — booking flow, rooms, meetings, wellness, currency, and UI

Made-with: Cursor
This commit is contained in:
“kirukib” 2026-03-22 03:20:29 +03:00
parent 4fe65354b0
commit 0b7c0fcd2b
57 changed files with 10757 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

18
eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

13
next.config.ts Normal file
View File

@ -0,0 +1,13 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com", pathname: "/**" },
{ protocol: "https", hostname: "images.pexels.com", pathname: "/**" },
{ protocol: "https", hostname: "cf.bstatic.com", pathname: "/**" },
],
},
};
export default nextConfig;

6701
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "shitaye-hotel",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint ."
},
"dependencies": {
"next": "16.2.1",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,236 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { RoomSelectBooking } from "@/components/RoomSelectBooking";
import { useBooking } from "@/context/BookingContext";
import { rooms } from "@/lib/mocks/rooms";
import { siteConfig } from "@/lib/mocks/site";
import { submitBookingHold } from "@/lib/mocks/api";
export function BookingPageClient() {
const searchParams = useSearchParams();
const router = useRouter();
const {
checkIn,
checkOut,
guests,
guest,
setRoomId,
setGuest,
setHoldReference,
setPayLaterHold,
selectedRoom,
nights,
} = useBooking();
const [pending, setPending] = useState<null | "payment" | "reserve">(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const r = searchParams.get("room");
if (r && rooms.some((x) => x.id === r)) setRoomId(r);
}, [searchParams, setRoomId]);
const canContinue =
selectedRoom &&
guest.firstName.trim() &&
guest.lastName.trim() &&
guest.email.trim() &&
guest.phone.trim() &&
guest.flightBookingNumber.trim() &&
guest.arrivalTime.trim();
async function placeHold(mode: "payment" | "reserve") {
if (!canContinue || !selectedRoom) return;
setError(null);
setPending(mode);
try {
const { reference } = await submitBookingHold({
roomId: selectedRoom.id,
email: guest.email,
flightBookingNumber: guest.flightBookingNumber.trim(),
arrivalTime: guest.arrivalTime.trim(),
});
setHoldReference(reference);
setPayLaterHold(mode === "reserve");
router.push(mode === "payment" ? "/payment" : "/reserve-held");
} catch {
setError("Something went wrong. Please try again.");
} finally {
setPending(null);
}
}
return (
<div className="mx-auto max-w-2xl px-4 py-12 md:py-16">
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--color-primary)]">
Book your stay
</p>
<h1 className="mt-2 font-display text-3xl md:text-4xl">
It only takes a moment
</h1>
<p className="mt-2 text-sm text-[var(--color-muted)]">
Pay now, or reserve first and complete payment later in this session mock only.
</p>
<div className="mt-8 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm">
<RoomSelectBooking selected={selectedRoom} onSelect={setRoomId} />
</div>
{selectedRoom ? (
<div className="mt-8 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-muted)]">
<div className="relative aspect-[2/1] w-full">
<Image
src={selectedRoom.gallery[0]!}
alt={selectedRoom.name}
fill
className="object-cover"
sizes="(max-width:768px) 100vw, 672px"
/>
</div>
<div className="space-y-3 p-6 text-sm">
<Row label="Hotel" value={siteConfig.name} />
<Row label="Room" value={selectedRoom.name} />
<Row label="Guests" value={`${guests} guest${guests !== 1 ? "s" : ""}`} />
<Row label="Check-in" value={formatDate(checkIn)} />
<Row label="Check-out" value={formatDate(checkOut)} />
<Row label="Nights" value={String(nights)} />
</div>
</div>
) : (
<div className="mt-8 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface)] p-8 text-center">
<p className="text-[var(--color-muted)]">Select a room to continue.</p>
<Link
href="/#rooms"
className="mt-4 inline-block text-sm font-semibold text-[var(--color-primary)] hover:underline"
>
Browse rooms
</Link>
</div>
)}
<div className="mt-10 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<h2 className="text-lg font-semibold">Who&apos;s checking in?</h2>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="block text-sm">
<span className="mb-1 block text-[var(--color-muted)]">First name</span>
<input
value={guest.firstName}
onChange={(e) => setGuest({ firstName: e.target.value })}
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
autoComplete="given-name"
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-[var(--color-muted)]">Last name</span>
<input
value={guest.lastName}
onChange={(e) => setGuest({ lastName: e.target.value })}
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
autoComplete="family-name"
/>
</label>
<label className="block text-sm sm:col-span-2">
<span className="mb-1 block text-[var(--color-muted)]">Email</span>
<input
type="email"
value={guest.email}
onChange={(e) => setGuest({ email: e.target.value })}
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
autoComplete="email"
/>
</label>
<label className="block text-sm sm:col-span-2">
<span className="mb-1 block text-[var(--color-muted)]">Phone</span>
<input
type="tel"
value={guest.phone}
onChange={(e) => setGuest({ phone: e.target.value })}
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
autoComplete="tel"
/>
</label>
</div>
</div>
<div className="mt-8 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<h2 className="text-lg font-semibold">Flight arrival</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
So we can coordinate your airport shuttle and room readiness.
</p>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="block text-sm sm:col-span-2">
<span className="mb-1 block text-[var(--color-muted)]">Flight booking / PNR / ticket number</span>
<input
value={guest.flightBookingNumber}
onChange={(e) => setGuest({ flightBookingNumber: e.target.value })}
placeholder="e.g. ABC123 or airline record locator"
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
autoComplete="off"
/>
</label>
<label className="block text-sm sm:col-span-2">
<span className="mb-1 block text-[var(--color-muted)]">Arrival time (local)</span>
<input
type="time"
value={guest.arrivalTime}
onChange={(e) => setGuest({ arrivalTime: e.target.value })}
className="w-full max-w-[12rem] rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
/>
</label>
</div>
</div>
{error ? <p className="mt-4 text-sm text-red-700">{error}</p> : null}
<div className="mt-8 flex flex-col gap-3">
<button
type="button"
disabled={!canContinue || pending !== null}
aria-busy={pending === "payment"}
onClick={() => placeHold("payment")}
className="w-full rounded-full bg-[var(--color-text)] py-4 text-sm font-semibold text-white transition hover:bg-[var(--color-primary)] disabled:cursor-not-allowed disabled:opacity-50"
>
{pending === "payment" ? "Please wait…" : "Continue to payment"}
</button>
<button
type="button"
disabled={!canContinue || pending !== null}
aria-busy={pending === "reserve"}
onClick={() => placeHold("reserve")}
className="w-full rounded-full border-2 border-[var(--color-primary)] bg-transparent py-3.5 text-sm font-semibold text-[var(--color-primary)] transition hover:bg-[var(--color-primary)] hover:text-[var(--color-on-primary)] disabled:cursor-not-allowed disabled:opacity-50"
>
{pending === "reserve" ? "Saving your hold…" : "Reserve now — pay later"}
</button>
<p className="text-center text-xs text-[var(--color-muted)]">
Pay later keeps your details and hold reference; finish checkout from the next screen
whenever you&apos;re ready.
</p>
</div>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-4 border-b border-[var(--color-border)]/80 pb-2 last:border-0">
<span className="text-[var(--color-muted)]">{label}</span>
<span className="text-right font-medium text-[var(--color-text)]">{value}</span>
</div>
);
}
function formatDate(iso: string) {
try {
return new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
}).format(new Date(iso + "T12:00:00"));
} catch {
return iso;
}
}

18
src/app/booking/page.tsx Normal file
View File

@ -0,0 +1,18 @@
import { Suspense } from "react";
import { BookingPageClient } from "./BookingPageClient";
export default function BookingPage() {
return (
<Suspense fallback={<BookingFallback />}>
<BookingPageClient />
</Suspense>
);
}
function BookingFallback() {
return (
<div className="mx-auto max-w-lg px-4 py-24 text-center text-[var(--color-muted)]">
Loading booking
</div>
);
}

View File

@ -0,0 +1,101 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useBooking } from "@/context/BookingContext";
import { useCurrency } from "@/context/CurrencyContext";
import { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime";
import { siteConfig } from "@/lib/mocks/site";
export default function ConfirmationPage() {
const router = useRouter();
const {
confirmationId,
paidAt,
selectedRoom,
guest,
checkIn,
checkOut,
nights,
total,
resetBooking,
} = useBooking();
const { formatUsd } = useCurrency();
useEffect(() => {
if (!confirmationId) router.replace("/");
}, [confirmationId, router]);
if (!confirmationId || !selectedRoom) return null;
return (
<div className="mx-auto max-w-lg px-4 py-16 text-center md:py-24">
<div
className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-[var(--color-success)] text-3xl text-white shadow-lg"
aria-hidden
>
</div>
<h1 className="mt-8 font-display 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}.
</p>
<p className="mt-2 font-mono text-sm text-[var(--color-text)]">
Confirmation: {confirmationId}
</p>
{paidAt ? (
<p className="mt-1 text-xs text-[var(--color-muted)]">
Paid at: {new Date(paidAt).toLocaleString()}
</p>
) : null}
<div className="mt-10 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] text-left shadow-sm">
<div className="relative aspect-[2/1] w-full">
<Image
src={selectedRoom.gallery[0]!}
alt={selectedRoom.name}
fill
className="object-cover"
sizes="(max-width:768px) 100vw, 512px"
/>
</div>
<div className="space-y-2 p-5 text-sm">
<p className="font-semibold">{siteConfig.name}</p>
<p>{selectedRoom.name}</p>
<p className="text-[var(--color-muted)]">
{checkIn} {checkOut} · {nights} night{nights !== 1 ? "s" : ""}
</p>
<div className="border-t border-[var(--color-border)] pt-3 text-[var(--color-muted)]">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-text)]">
Flight arrival
</p>
<p className="mt-1">
Booking / PNR:{" "}
<span className="font-medium text-[var(--color-text)]">
{guest.flightBookingNumber.trim() || "—"}
</span>
</p>
<p className="mt-0.5">
Arrival (local):{" "}
<span className="font-medium text-[var(--color-text)]">
{formatArrivalTimeDisplay(guest.arrivalTime)}
</span>
</p>
</div>
<p className="font-semibold">Total paid: {formatUsd(total)}</p>
</div>
</div>
<Link
href="/"
onClick={() => resetBooking()}
className="mt-10 inline-flex rounded-full bg-[var(--color-text)] px-10 py-3.5 text-sm font-semibold text-white hover:bg-[var(--color-primary)]"
>
Back to home
</Link>
</div>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

72
src/app/globals.css Normal file
View File

@ -0,0 +1,72 @@
@import "tailwindcss";
:root {
--color-bg: #faf7f2;
--color-surface: #ffffff;
--color-surface-muted: #f3ede6;
--color-text: #1c1917;
--color-muted: #57534e;
--color-border: #e7e0d6;
--color-primary: #7c1d2b;
--color-primary-hover: #5c1520;
--color-on-primary: #fffaf7;
--color-accent: #b8860b;
--color-accent-soft: #f5e6c8;
--color-success: #0d9488;
--font-display: var(--font-cormorant), "Georgia", serif;
--font-ui: var(--font-dm-sans), system-ui, sans-serif;
}
@theme inline {
--color-background: var(--color-bg);
--color-foreground: var(--color-text);
--font-sans: var(--font-ui);
}
html {
scroll-behavior: smooth;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-ui);
}
.font-display {
font-family: var(--font-display);
}
.grain::before {
content: "";
pointer-events: none;
position: fixed;
inset: 0;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
z-index: 50;
}
.card-lift {
transition:
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.35s ease;
}
.card-lift:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px rgba(28, 25, 23, 0.08);
}
@keyframes mock3d-rotate {
from {
transform: rotateY(-12deg) rotateX(4deg);
}
to {
transform: rotateY(12deg) rotateX(4deg);
}
}
.mock3d-plane {
animation: mock3d-rotate 8s ease-in-out infinite alternate;
transform-style: preserve-3d;
}

47
src/app/layout.tsx Normal file
View File

@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { Cormorant_Garamond, DM_Sans } from "next/font/google";
import { Providers } from "./providers";
import { Shell } from "@/components/Shell";
import "./globals.css";
const cormorant = Cormorant_Garamond({
variable: "--font-cormorant",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
display: "swap",
});
const dmSans = DM_Sans({
variable: "--font-dm-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
display: "swap",
});
export const metadata: Metadata = {
title: {
default: "Shitaye Suite Hotel | Addis Ababa",
template: "%s | Shitaye Suite Hotel",
},
description:
"The Unwinding Choice — luxury suites, dining, and meetings in Addis Ababa. Book your stay at Shitaye Suite Hotel.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${cormorant.variable} ${dmSans.variable} h-full antialiased`}
>
<body className="min-h-full">
<Providers>
<Shell>{children}</Shell>
</Providers>
</body>
</html>
);
}

View File

@ -0,0 +1,15 @@
import Link from "next/link";
export default function MeetingNotFound() {
return (
<div className="mx-auto max-w-lg px-4 py-24 text-center">
<h1 className="font-display text-3xl">Meeting space not found</h1>
<Link
href="/#meetings"
className="mt-8 inline-flex rounded-full bg-[var(--color-primary)] px-8 py-3 text-sm font-semibold text-white"
>
View venues
</Link>
</div>
);
}

View File

@ -0,0 +1,153 @@
import Image from "next/image";
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 {
getAllMeetingSlugs,
getMeetingSpaceBySlug,
} from "@/lib/mocks/meetingSpaces";
import { siteConfig } from "@/lib/mocks/site";
import type { Metadata } from "next";
type Props = { params: Promise<{ slug: string }> };
export function generateStaticParams() {
return getAllMeetingSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const m = getMeetingSpaceBySlug(slug);
if (!m) return { title: "Meeting space" };
return {
title: m.name,
description: m.shortDescription,
};
}
export default async function MeetingSpacePage({ params }: Props) {
const { slug } = await params;
const space = getMeetingSpaceBySlug(slug);
if (!space) notFound();
return (
<article className="bg-[var(--color-bg)]">
<div className="relative aspect-[21/9] min-h-[220px] w-full md:min-h-[320px]">
<Image
src={space.image}
alt={space.name}
fill
priority
className="object-cover"
sizes="100vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/75 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 mx-auto max-w-7xl px-4 pb-8 md:px-8">
<Link
href="/#dining"
className="text-xs font-medium text-white/80 hover:text-white"
>
Dining & venues
</Link>
<h1 className="mt-2 font-display text-4xl text-white md:text-5xl">{space.name}</h1>
<p className="mt-2 max-w-2xl text-sm text-white/90">{space.shortDescription}</p>
</div>
</div>
<div className="mx-auto max-w-7xl px-4 py-12 md:px-8 md:py-16">
<div className="grid gap-12 lg:grid-cols-[1fr_360px]">
<div>
<h2 className="font-display text-2xl">Overview</h2>
<p className="mt-4 leading-relaxed text-[var(--color-muted)]">{space.longDescription}</p>
<div className="mt-10 grid gap-4 sm:grid-cols-2">
{space.gallery.slice(1).map((src) => (
<div key={src} className="relative aspect-[4/3] overflow-hidden rounded-2xl">
<Image
src={src}
alt={`${space.name} gallery`}
fill
className="object-cover"
sizes="(max-width:1024px) 100vw, 40vw"
/>
</div>
))}
</div>
<section className="mt-14">
<h2 className="font-display text-2xl">Amenities & equipment</h2>
<ul className="mt-4 grid gap-2 sm:grid-cols-2">
{space.amenities.map((a) => (
<AmenityItem key={a.label} item={a} variant="card" />
))}
</ul>
</section>
<section className="mt-12">
<h2 className="font-display text-2xl">Layouts</h2>
<ul className="mt-3 flex flex-wrap gap-2">
{space.layouts.map((l) => (
<li
key={l}
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-1.5 text-sm"
>
{l}
</li>
))}
</ul>
</section>
<section className="mt-12">
<h2 className="font-display text-2xl">Catering</h2>
<ul className="mt-3 space-y-2 text-sm text-[var(--color-muted)]">
{space.catering.map((c) => (
<li key={c}>· {c}</li>
))}
</ul>
</section>
</div>
<aside className="lg:sticky lg:top-28 lg:self-start">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--color-muted)]">
Capacity
</p>
<p className="mt-2 text-lg font-semibold text-[var(--color-text)]">{space.capacity}</p>
<p className="mt-4 text-xs font-semibold uppercase tracking-widest text-[var(--color-muted)]">
Floor
</p>
<p className="mt-1 font-medium">{space.floor}</p>
<MeetingHalfDayRate usdAmount={space.halfDayRateUsd} />
<a
href={`mailto:${siteConfig.email}?subject=${encodeURIComponent(`Event inquiry — ${space.name}`)}`}
className="mt-6 block w-full rounded-full bg-[var(--color-text)] py-3 text-center text-sm font-semibold text-white transition hover:bg-[var(--color-primary)]"
>
Request a proposal
</a>
<a
href={`tel:${siteConfig.primaryPhone.replace(/\s/g, "")}`}
className="mt-3 block text-center text-sm font-semibold text-[var(--color-primary)] hover:underline"
>
Call {siteConfig.primaryPhone}
</a>
</div>
<div className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] p-6">
<h3 className="text-sm font-semibold">Hotel guest amenities</h3>
<p className="mt-2 text-xs text-[var(--color-muted)]">
Delegates staying overnight enjoy the same in-room benefits:
</p>
<ul className="mt-3 grid gap-1">
{roomAmenities.slice(0, 6).map((a) => (
<AmenityItem key={a.label} item={a} variant="inline" />
))}
</ul>
</div>
</aside>
</div>
</div>
</article>
);
}

270
src/app/page.tsx Normal file
View File

@ -0,0 +1,270 @@
import Image from "next/image";
import Link from "next/link";
import { AmenityItem } from "@/components/AmenityItem";
import { BookingSearchWidget } from "@/components/BookingSearchWidget";
import { OutletCard } from "@/components/OutletCard";
import { RoomCard } from "@/components/RoomCard";
import { VirtualTourBlock } from "@/components/VirtualTourBlock";
import { roomAmenities } from "@/lib/mocks/amenities";
import { outlets } from "@/lib/mocks/outlets";
import { rooms } from "@/lib/mocks/rooms";
import { siteConfig } from "@/lib/mocks/site";
import { wellnessFacilities } from "@/lib/mocks/wellness";
const heroImage =
"https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1920&q=80";
export default function HomePage() {
return (
<>
<section className="relative min-h-[78vh]">
<Image
src={heroImage}
alt="Luxury hotel exterior at dusk"
fill
priority
className="object-cover"
sizes="100vw"
/>
<div className="absolute inset-0 bg-gradient-to-r from-black/75 via-black/45 to-black/25" />
<div className="relative mx-auto flex min-h-[78vh] max-w-7xl flex-col justify-end px-4 pb-10 pt-32 md:px-8 md:pb-14">
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-white/80">
Official website
</p>
<h1 className="mt-4 max-w-3xl font-display text-4xl font-semibold leading-tight text-white md:text-6xl">
{siteConfig.tagline}
</h1>
<p className="mt-4 max-w-xl text-lg text-white/90">
Discover refined stays in Addis Ababa exceptional rooms, celebrated dining, and
spaces designed for connection.
</p>
<div className="mt-10 w-full max-w-4xl">
<BookingSearchWidget />
</div>
</div>
</section>
<section className="mx-auto max-w-7xl px-4 py-20 md:px-8">
<div className="grid gap-12 lg:grid-cols-2 lg:items-center">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--color-primary)]">
About us
</p>
<h2 className="mt-3 font-display text-3xl text-[var(--color-text)] md:text-4xl">
Geo-convenient. Unmistakably Shitaye.
</h2>
<p className="mt-4 leading-relaxed text-[var(--color-muted)]">
Close to key places of attraction and major businesses or institutions your base
for work, culture, and rest. Begin your journey with us.
</p>
<Link
href="/#rooms"
className="mt-6 inline-flex rounded-full border-2 border-[var(--color-primary)] px-6 py-3 text-sm font-semibold text-[var(--color-primary)] transition hover:bg-[var(--color-primary)] hover:text-[var(--color-on-primary)]"
>
View rooms
</Link>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl">
<Image
src={siteConfig.lobbyImageUrl}
alt="Shitaye Suite Hotel lobby and lounge"
fill
className="object-cover"
sizes="(max-width: 640px) 100vw, 50vw"
/>
</div>
<div className="relative mt-0 aspect-[4/5] overflow-hidden rounded-2xl sm:mt-12">
<Image
src="https://images.unsplash.com/photo-1631049307264-da0ec9d70304?w=800&q=80"
alt="Luxury suite interior"
fill
className="object-cover"
/>
</div>
</div>
</div>
</section>
<section id="rooms" className="bg-[var(--color-surface)] py-20">
<div className="mx-auto max-w-7xl px-4 md:px-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--color-primary)]">
Stay with us
</p>
<h2 className="mt-2 font-display text-3xl md:text-4xl">Rooms & suites</h2>
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">
From junior studios to our four-bedroom penthouse every category includes premium
amenities and attentive service.
</p>
</div>
<Link
href="/booking"
className="text-sm font-semibold text-[var(--color-primary)] hover:underline"
>
Book a room
</Link>
</div>
<div className="mt-12 grid gap-8 md:grid-cols-2 lg:grid-cols-4">
{rooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
</div>
</section>
<section
id="wellness"
className="border-y border-[var(--color-border)] bg-[var(--color-surface-muted)] py-24 md:py-32"
>
<div className="mx-auto max-w-7xl px-4 md:px-8 lg:px-10">
<header className="max-w-3xl">
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-[var(--color-primary)]">
Wellness
</p>
<h2 className="mt-4 font-display text-3xl font-semibold tracking-tight text-[var(--color-text)] md:text-5xl">
Gym & Spa
</h2>
<p className="mt-5 text-base leading-relaxed text-[var(--color-muted)] md:mt-6 md:text-lg md:leading-relaxed">
Stay active and restore balance our fitness centre and spa are tailored for travellers
who expect more than a standard hotel gym.
</p>
</header>
<div className="mt-16 flex flex-col gap-20 md:mt-20 md:gap-24 lg:gap-28">
{wellnessFacilities.map((w, i) => (
<div
key={w.id}
className={`grid gap-10 md:grid-cols-2 md:items-center md:gap-12 lg:gap-16 ${i % 2 === 1 ? "md:[&>div:first-child]:order-2" : ""}`}
>
<div className="relative aspect-[4/3] min-h-[220px] overflow-hidden rounded-3xl shadow-[0_20px_50px_-12px_rgba(28,25,23,0.18)] ring-1 ring-[var(--color-border)] md:min-h-[300px]">
<Image
src={w.image}
alt={w.title}
fill
className="object-cover"
sizes="(max-width:1024px) 100vw, 50vw"
/>
</div>
<div className="flex flex-col md:px-1 lg:px-4">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
{w.subtitle}
</p>
<h3 className="mt-3 font-display text-3xl text-[var(--color-text)] md:mt-4 md:text-4xl">
{w.title}
</h3>
<p className="mt-5 text-sm leading-relaxed text-[var(--color-muted)] md:mt-6 md:text-base md:leading-relaxed">
{w.description}
</p>
<p className="mt-5 text-sm font-semibold text-[var(--color-accent)] md:mt-6">
{w.hours}
</p>
<ul className="mt-8 grid gap-3 sm:grid-cols-2 md:mt-10">
{w.amenities.map((a) => (
<AmenityItem key={a.label} item={a} variant="card" />
))}
</ul>
</div>
</div>
))}
</div>
</div>
</section>
<section id="tour" className="mx-auto max-w-7xl px-4 py-20 md:px-8">
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--color-primary)]">
Explore in 3D
</p>
<h2 className="mt-2 font-display text-3xl md:text-4xl">Virtual experience</h2>
<p className="mt-2 max-w-2xl text-[var(--color-muted)]">
Walk the property before you arrive demo preview below; add a Matterport link in
config when ready.
</p>
<div className="mt-8">
<VirtualTourBlock
embedUrl={siteConfig.hotelTourEmbedUrl}
title="Shitaye Suite Hotel virtual tour"
/>
</div>
</section>
<section id="dining" className="bg-[var(--color-surface-muted)] py-20">
<div className="mx-auto max-w-7xl px-4 md:px-8">
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--color-primary)]">
Our outlets & services
</p>
<h2 className="mt-2 font-display text-3xl md:text-4xl">Dining & venues</h2>
<p className="mt-2 max-w-2xl text-[var(--color-muted)]">
From FeastVille to TABSIA savour flavour, host memorable events, and unwind in
spaces crafted for the city.
</p>
<div className="mt-12 grid gap-8 lg:grid-cols-2">
{outlets.map((o) => (
<OutletCard key={o.slug} outlet={o} />
))}
</div>
</div>
</section>
<section id="meetings" className="mx-auto max-w-7xl px-4 py-16 md:px-8">
<div className="rounded-3xl bg-[var(--color-primary)] px-8 py-14 text-center text-[var(--color-on-primary)] md:px-16">
<h2 className="font-display text-3xl md:text-4xl">Meetings & celebrations</h2>
<p className="mx-auto mt-4 max-w-2xl text-sm text-white/85">
Serenity Meeting Room and Fasika Board Room fully equipped for board sessions,
cocktails, and curated catering.
</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-3 text-sm">
<Link
href="/meetings/serenity"
className="rounded-full border border-white/40 px-5 py-2 font-semibold text-white transition hover:bg-white/10"
>
Serenity details
</Link>
<Link
href="/meetings/fasika"
className="rounded-full border border-white/40 px-5 py-2 font-semibold text-white transition hover:bg-white/10"
>
Fasika details
</Link>
</div>
<a
href={`mailto:${siteConfig.email}?subject=Event%20inquiry`}
className="mt-8 inline-flex rounded-full bg-white px-8 py-3 text-sm font-semibold text-[var(--color-primary)] transition hover:bg-[var(--color-accent-soft)]"
>
Plan an event
</a>
</div>
</section>
<section className="border-y border-[var(--color-border)] bg-[var(--color-surface)] py-16">
<div className="mx-auto max-w-7xl px-4 md:px-8">
<h2 className="font-display text-2xl md:text-3xl">All rooms include</h2>
<ul className="mt-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{roomAmenities.map((a) => (
<AmenityItem
key={a.label}
item={a}
variant="card"
cardTone="embedded"
/>
))}
</ul>
</div>
</section>
<section className="mx-auto max-w-7xl px-4 py-20 text-center md:px-8">
<h2 className="font-display text-3xl md:text-4xl">Trusted stays. Seamless booking.</h2>
<p className="mx-auto mt-3 max-w-lg text-sm text-[var(--color-muted)]">
Reserve in minutes mock checkout demonstrates the full journey.
</p>
<Link
href="/booking"
className="mt-8 inline-flex rounded-full bg-[var(--color-text)] px-10 py-4 text-sm font-semibold text-white transition hover:bg-[var(--color-primary)]"
>
Get started
</Link>
</section>
</>
);
}

View File

@ -0,0 +1,236 @@
"use client";
import Image from "next/image";
import Link from "next/link";
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 { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime";
import { processPayment } from "@/lib/mocks/api";
export function PaymentPageClient() {
const router = useRouter();
const {
selectedRoom,
guest,
nights,
subtotal,
taxAmount,
discountAmount,
total,
couponCode,
setCouponCode,
applyCoupon,
couponPercentOff,
holdReference,
payLaterHold,
setConfirmation,
} = useBooking();
const { formatUsd } = useCurrency();
const [cardName, setCardName] = useState("");
const [cardNumber, setCardNumber] = useState("");
const [expiry, setExpiry] = useState("");
const [cvv, setCvv] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!selectedRoom || !guest.email) {
router.replace("/booking");
}
}, [selectedRoom, guest.email, router]);
if (!selectedRoom) {
return null;
}
const payLabel = `Confirm & pay ${formatUsd(total)}`;
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);
router.push("/confirmation");
} finally {
setLoading(false);
}
}
return (
<div className="mx-auto max-w-2xl px-4 py-12 md:py-16">
<h1 className="font-display 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.
</p>
{payLaterHold ? (
<p className="mt-4 rounded-xl border border-[var(--color-primary)]/35 bg-[var(--color-primary)]/10 px-4 py-3 text-sm text-[var(--color-text)]">
You&apos;re completing a <strong>pay-later hold</strong>. Reference{" "}
<span className="font-mono">{holdReference ?? "—"}</span>.
</p>
) : null}
<div className="mt-8 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] p-5 text-sm text-[var(--color-muted)]">
Booking reference:{" "}
<span className="font-mono text-[var(--color-text)]">
{holdReference ?? "—"}
</span>
</div>
<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)
</h2>
<label className="mt-4 block text-sm">
<span className="mb-1 block text-[var(--color-muted)]">Cardholder name</span>
<input
value={cardName}
onChange={(e) => setCardName(e.target.value)}
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
autoComplete="cc-name"
/>
</label>
<label className="mt-3 block text-sm">
<span className="mb-1 block text-[var(--color-muted)]">Card number</span>
<input
value={cardNumber}
onChange={(e) => setCardNumber(e.target.value)}
placeholder="4242 4242 4242 4242"
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 font-mono focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
autoComplete="cc-number"
/>
</label>
<div className="mt-3 grid grid-cols-2 gap-3">
<label className="block text-sm">
<span className="mb-1 block text-[var(--color-muted)]">Expiry (MM/YY)</span>
<input
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
autoComplete="cc-exp"
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-[var(--color-muted)]">CVV</span>
<input
value={cvv}
onChange={(e) => setCvv(e.target.value)}
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
autoComplete="cc-csc"
/>
</label>
</div>
</div>
<div className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<h2 className="text-sm font-semibold">Price details</h2>
<div className="mt-4 flex gap-2">
<input
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
placeholder="Coupon code"
className="flex-1 rounded-xl border border-[var(--color-border)] px-3 py-2 text-sm"
/>
<button
type="button"
onClick={applyCoupon}
className="rounded-full border border-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white"
>
Apply
</button>
</div>
{couponPercentOff > 0 ? (
<p className="mt-2 text-xs text-[var(--color-success)]">
{couponPercentOff}% discount applied (try SHITAYE10 or WELCOME5)
</p>
) : null}
<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
</dt>
<dd>{formatUsd(subtotal)}</dd>
</div>
{discountAmount > 0 ? (
<div className="flex justify-between text-[var(--color-success)]">
<dt>Discount</dt>
<dd>-{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>
</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>
</div>
</dl>
</div>
<div className="mt-6 flex items-center gap-4 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] p-4">
<div className="relative h-14 w-20 shrink-0 overflow-hidden rounded-lg">
<Image
src={selectedRoom.gallery[0]!}
alt={selectedRoom.name}
fill
className="object-cover"
sizes="80px"
/>
</div>
<div className="min-w-0 text-sm">
<p className="font-semibold">{selectedRoom.name}</p>
<p className="text-[var(--color-muted)]">
{guest.firstName} {guest.lastName}
</p>
</div>
</div>
<div className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 text-sm shadow-sm">
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--color-muted)]">
Flight arrival
</h2>
<dl className="mt-3 space-y-2">
<div className="flex justify-between gap-4">
<dt className="text-[var(--color-muted)]">Booking / PNR</dt>
<dd className="text-right font-medium text-[var(--color-text)]">
{guest.flightBookingNumber.trim() || "—"}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-[var(--color-muted)]">Arrival time (local)</dt>
<dd className="text-right font-medium text-[var(--color-text)]">
{formatArrivalTimeDisplay(guest.arrivalTime)}
</dd>
</div>
</dl>
</div>
<button
type="button"
disabled={loading}
aria-busy={loading}
onClick={handlePay}
className="mt-8 w-full rounded-full bg-[var(--color-text)] py-4 text-sm font-semibold tracking-wide text-white transition hover:bg-[var(--color-primary)] disabled:opacity-60"
>
{loading ? "Processing…" : payLabel}
</button>
<Link
href="/booking"
className="mt-4 block text-center text-sm font-medium text-[var(--color-primary)] hover:underline"
>
Back to guest details
</Link>
</div>
);
}

10
src/app/payment/page.tsx Normal file
View File

@ -0,0 +1,10 @@
import { Suspense } from "react";
import { PaymentPageClient } from "./PaymentPageClient";
export default function PaymentPage() {
return (
<Suspense fallback={<p className="py-24 text-center text-[var(--color-muted)]">Loading</p>}>
<PaymentPageClient />
</Suspense>
);
}

13
src/app/providers.tsx Normal file
View File

@ -0,0 +1,13 @@
"use client";
import { BookingProvider } from "@/context/BookingContext";
import { CurrencyProvider } from "@/context/CurrencyContext";
import type { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
return (
<CurrencyProvider>
<BookingProvider>{children}</BookingProvider>
</CurrencyProvider>
);
}

View File

@ -0,0 +1,112 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useBooking } from "@/context/BookingContext";
import { useCurrency } from "@/context/CurrencyContext";
import { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime";
import { siteConfig } from "@/lib/mocks/site";
export default function ReserveHeldPage() {
const router = useRouter();
const {
holdReference,
selectedRoom,
guest,
checkIn,
checkOut,
nights,
total,
payLaterHold,
resetBooking,
} = useBooking();
const { formatUsd } = useCurrency();
useEffect(() => {
if (!holdReference || !selectedRoom) {
router.replace("/booking");
return;
}
if (!payLaterHold) {
router.replace("/payment");
}
}, [holdReference, selectedRoom, payLaterHold, router]);
if (!holdReference || !selectedRoom || !payLaterHold) {
return null;
}
return (
<div className="mx-auto max-w-lg px-4 py-16 md:py-24">
<div
className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-[var(--color-primary)] text-[var(--color-on-primary)] shadow-lg"
aria-hidden
>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.75" />
<path
d="M12 7v5l3 2"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<h1 className="mt-8 text-center font-display text-3xl md:text-4xl">
Reservation on hold
</h1>
<p className="mt-3 text-center text-sm text-[var(--color-muted)]">
{guest.firstName}, your room is saved finish payment whenever you&apos;re ready in this
browser session. (Demo: no real hold or email.)
</p>
<p className="mt-2 text-center font-mono text-sm text-[var(--color-text)]">
Hold ref: {holdReference}
</p>
<p className="mt-2 text-center text-xs text-[var(--color-muted)]">
Indicative total when you pay:{" "}
<span className="font-semibold text-[var(--color-text)]">{formatUsd(total)}</span>
</p>
<div className="mt-10 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] text-left shadow-sm">
<div className="relative aspect-[2/1] w-full">
<Image
src={selectedRoom.gallery[0]!}
alt={selectedRoom.name}
fill
className="object-cover"
sizes="(max-width:768px) 100vw, 512px"
/>
</div>
<div className="space-y-2 p-5 text-sm">
<p className="font-semibold">{siteConfig.name}</p>
<p>{selectedRoom.name}</p>
<p className="text-[var(--color-muted)]">
{checkIn} {checkOut} · {nights} night{nights !== 1 ? "s" : ""}
</p>
<p className="text-[var(--color-muted)]">
Flight {guest.flightBookingNumber.trim()} · arrival{" "}
{formatArrivalTimeDisplay(guest.arrivalTime)}
</p>
</div>
</div>
<Link
href="/payment"
className="mt-10 flex w-full items-center justify-center rounded-full bg-[var(--color-text)] py-4 text-sm font-semibold text-white transition hover:bg-[var(--color-primary)]"
>
Complete payment
</Link>
<Link
href="/"
onClick={() => resetBooking()}
className="mt-4 block text-center text-sm font-medium text-[var(--color-primary)] hover:underline"
>
Back to home discard this hold
</Link>
</div>
);
}

View File

@ -0,0 +1,18 @@
import Link from "next/link";
export default function RoomNotFound() {
return (
<div className="mx-auto max-w-lg px-4 py-24 text-center">
<h1 className="font-display text-3xl">Room not found</h1>
<p className="mt-3 text-[var(--color-muted)]">
We couldn&apos;t find that room category.
</p>
<Link
href="/#rooms"
className="mt-8 inline-flex rounded-full bg-[var(--color-primary)] px-8 py-3 text-sm font-semibold text-white"
>
View all rooms
</Link>
</div>
);
}

View File

@ -0,0 +1,159 @@
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { AmenityItem } from "@/components/AmenityItem";
import { FormattedUsd } from "@/components/FormattedUsd";
import { BookRoomButton } from "@/components/BookRoomButton";
import { VirtualTourBlock } from "@/components/VirtualTourBlock";
import { roomAmenities } from "@/lib/mocks/amenities";
import { getAllRoomSlugs, getRoomBySlug } from "@/lib/mocks/rooms";
import { siteConfig } from "@/lib/mocks/site";
import type { Metadata } from "next";
type Props = { params: Promise<{ slug: string }> };
export function generateStaticParams() {
return getAllRoomSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const room = getRoomBySlug(slug);
if (!room) return { title: "Room" };
return {
title: room.name,
description: room.shortDescription,
};
}
export default async function RoomPage({ params }: Props) {
const { slug } = await params;
const room = getRoomBySlug(slug);
if (!room) notFound();
return (
<article className="bg-[var(--color-bg)]">
<div className="relative aspect-[21/9] min-h-[220px] w-full md:min-h-[360px]">
<Image
src={room.gallery[0]!}
alt={room.name}
fill
priority
className="object-cover"
sizes="100vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 mx-auto max-w-7xl px-4 pb-8 md:px-8">
<Link
href="/#rooms"
className="text-xs font-medium text-white/80 hover:text-white"
>
All rooms
</Link>
<h1 className="mt-2 font-display text-4xl text-white md:text-5xl">{room.name}</h1>
<p className="mt-2 max-w-2xl text-sm text-white/90">{room.shortDescription}</p>
</div>
</div>
<div className="mx-auto max-w-7xl px-4 py-12 md:px-8 md:py-16">
<div className="grid gap-12 lg:grid-cols-[1fr_380px]">
<div>
<h2 className="font-display text-2xl">Overview</h2>
<p className="mt-4 leading-relaxed text-[var(--color-muted)]">{room.longDescription}</p>
<div className="mt-10 grid gap-4 sm:grid-cols-2">
{room.gallery.slice(1).map((src) => (
<div key={src} className="relative aspect-[4/3] overflow-hidden rounded-2xl">
<Image
src={src}
alt={`${room.name} gallery`}
fill
className="object-cover"
sizes="(max-width:1024px) 100vw, 40vw"
/>
</div>
))}
</div>
<section className="mt-14">
<h2 className="font-display text-2xl">Virtual tour</h2>
<p className="mt-2 text-sm text-[var(--color-muted)]">
Explore this category in 3D demo placeholder until a room-specific embed is
added.
</p>
<div className="mt-6">
<VirtualTourBlock
embedUrl={room.tourEmbedUrl}
title={`${room.name} virtual tour`}
/>
</div>
</section>
</div>
<aside className="lg:sticky lg:top-28 lg:self-start">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--color-muted)]">
From
</p>
<p className="mt-1 font-display text-3xl text-[var(--color-primary)]">
<FormattedUsd amountUsd={room.nightlyRate} />
<span className="text-base font-sans font-normal text-[var(--color-muted)]">
{" "}
/ night
</span>
</p>
<dl className="mt-6 space-y-3 text-sm">
<div className="flex justify-between gap-4 border-b border-[var(--color-border)] pb-3">
<dt className="text-[var(--color-muted)]">Max guests</dt>
<dd className="font-medium">{room.maxGuests}</dd>
</div>
<div className="flex justify-between gap-4 border-b border-[var(--color-border)] pb-3">
<dt className="text-[var(--color-muted)]">Beds</dt>
<dd className="max-w-[60%] text-right font-medium">{room.beds}</dd>
</div>
<div className="flex justify-between gap-4 border-b border-[var(--color-border)] pb-3">
<dt className="text-[var(--color-muted)]">Size</dt>
<dd className="font-medium">{room.sizeSqM} m²</dd>
</div>
<div className="flex justify-between gap-4 pb-1">
<dt className="text-[var(--color-muted)]">View</dt>
<dd className="font-medium">{room.view}</dd>
</div>
</dl>
<div className="mt-8 flex flex-col gap-3">
<BookRoomButton roomId={room.id} />
<a
href={`tel:${siteConfig.primaryPhone.replace(/\s/g, "")}`}
className="text-center text-sm font-semibold text-[var(--color-primary)] hover:underline"
>
Call to reserve
</a>
</div>
</div>
<div className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] p-6">
<h3 className="text-sm font-semibold">Room highlights</h3>
<ul className="mt-3 grid gap-2 text-xs text-[var(--color-muted)]">
{room.highlights.map((h) => (
<li key={h}> {h}</li>
))}
</ul>
</div>
<div className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<h3 className="text-sm font-semibold">All-room amenities</h3>
<p className="mt-1 text-xs text-[var(--color-muted)]">
Included across the hotel see also your category highlights above.
</p>
<ul className="mt-4 grid gap-2 sm:grid-cols-2">
{roomAmenities.map((a) => (
<AmenityItem key={a.label} item={a} variant="inline" />
))}
</ul>
</div>
</aside>
</div>
</div>
</article>
);
}

View File

@ -0,0 +1,48 @@
import { AmenityIcon } from "@/components/icons/AmenityIcon";
import type { AmenityWithIcon } from "@/lib/mocks/amenities";
type Props = {
item: AmenityWithIcon;
variant?: "card" | "inline";
/** Use on tinted bands (e.g. “All rooms include”) so cards stay readable */
cardTone?: "elevated" | "embedded";
};
export function AmenityItem({
item,
variant = "card",
cardTone = "elevated",
}: Props) {
if (variant === "inline") {
return (
<li className="flex items-start gap-2.5 text-xs text-[var(--color-muted)]">
<span
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-[var(--color-primary)]/[0.08] text-[var(--color-primary)]"
aria-hidden
>
<AmenityIcon id={item.icon} className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 pt-0.5 leading-snug">{item.label}</span>
</li>
);
}
const cardBg =
cardTone === "embedded"
? "bg-[var(--color-bg)]"
: "bg-[var(--color-surface)]";
return (
<li
className={`flex items-start gap-3 rounded-xl border border-[var(--color-border)] px-4 py-3.5 text-sm text-[var(--color-text)] ${cardBg}`}
>
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--color-primary)]/[0.1] text-[var(--color-primary)] ring-1 ring-[var(--color-primary)]/15"
aria-hidden
>
<AmenityIcon id={item.icon} className="h-5 w-5" />
</span>
<span className="min-w-0 pt-1 leading-snug">{item.label}</span>
</li>
);
}

View File

@ -0,0 +1,27 @@
"use client";
import { useRouter } from "next/navigation";
import { useBooking } from "@/context/BookingContext";
type Props = { roomId: string; className?: string };
export function BookRoomButton({ roomId, className = "" }: Props) {
const { setRoomId } = useBooking();
const router = useRouter();
return (
<button
type="button"
onClick={() => {
setRoomId(roomId);
router.push("/booking");
}}
className={
className ||
"rounded-full bg-[var(--color-text)] px-8 py-3.5 text-sm font-semibold text-white transition hover:bg-[var(--color-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
}
>
Book this room
</button>
);
}

View File

@ -0,0 +1,82 @@
"use client";
import { useRouter } from "next/navigation";
import { useBooking } from "@/context/BookingContext";
import { useCallback } from "react";
export function BookingSearchWidget() {
const { checkIn, checkOut, guests, setDates, setGuests } = useBooking();
const router = useRouter();
const onSearch = useCallback(() => {
router.push("/booking");
}, [router]);
return (
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-xl md:p-6">
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--color-muted)]">
Begin your journey
</p>
<div className="mt-4 grid gap-4 md:grid-cols-4 md:items-end">
<label className="block text-sm">
<span className="mb-1.5 block font-medium text-[var(--color-text)]">Location</span>
<select
disabled
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-3 text-sm"
aria-label="Location"
>
<option>Addis Ababa</option>
</select>
</label>
<label className="block text-sm">
<span className="mb-1.5 block font-medium text-[var(--color-text)]">Check-in</span>
<input
type="date"
value={checkIn}
onChange={(e) => setDates(e.target.value, checkOut)}
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-3 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
/>
</label>
<label className="block text-sm">
<span className="mb-1.5 block font-medium text-[var(--color-text)]">Check-out</span>
<input
type="date"
value={checkOut}
onChange={(e) => setDates(checkIn, e.target.value)}
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-3 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
/>
</label>
<div className="flex flex-col gap-2 md:flex-row md:items-end">
<label className="block flex-1 text-sm">
<span className="mb-1.5 block font-medium text-[var(--color-text)]">Guests</span>
<input
type="number"
min={1}
max={12}
value={guests}
onChange={(e) => setGuests(Number.parseInt(e.target.value, 10) || 1)}
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-3 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
/>
</label>
<button
type="button"
onClick={onSearch}
className="h-[46px] shrink-0 rounded-full bg-[var(--color-text)] px-6 text-sm font-semibold text-white transition hover:bg-[var(--color-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)] md:ml-2"
>
Search stays
</button>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{["Suites", "Studios", "Penthouse", "Meetings"].map((chip) => (
<span
key={chip}
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-1 text-xs font-medium text-[var(--color-muted)]"
>
{chip}
</span>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,68 @@
"use client";
import { siteConfig } from "@/lib/mocks/site";
export function CallUsFab() {
const tel = siteConfig.primaryPhone.replace(/\s/g, "");
return (
<div className="pointer-events-none fixed bottom-5 right-5 z-[60] flex flex-col items-end gap-2 md:bottom-8 md:right-8">
<a
href={`mailto:${siteConfig.email}`}
className="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-primary)] shadow-lg transition hover:bg-[var(--color-primary)] hover:text-[var(--color-on-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)] md:h-14 md:w-14"
aria-label={`Email ${siteConfig.email}`}
>
<MailIcon />
</a>
<a
href={`tel:${tel}`}
className="group pointer-events-auto flex items-center gap-3 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] py-3 pl-4 pr-5 text-sm font-semibold text-[var(--color-text)] shadow-lg transition hover:bg-[var(--color-primary)] hover:text-[var(--color-on-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
aria-label={`Call us at ${siteConfig.primaryPhone}`}
>
<span
className="flex h-10 w-10 items-center justify-center rounded-full bg-[var(--color-primary)] text-white"
aria-hidden
>
<PhoneIcon />
</span>
<span className="flex flex-col leading-tight">
<span>Call us</span>
<span className="text-xs font-normal text-[var(--color-muted)] transition group-hover:text-white/90">
{siteConfig.primaryPhone}
</span>
</span>
</a>
</div>
);
}
function MailIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M4 6h16a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V8a2 2 0 012-2z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinejoin="round"
/>
<path
d="M22 8l-10 6L2 8"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function PhoneIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M6.6 10.8c1.8 3.6 4.8 6.6 8.4 8.4l2.8-2.8c.4-.4 1-.5 1.5-.3 1.6.6 3.4.9 5.2.9.9 0 1.5.6 1.5 1.5V22c0 .9-.6 1.5-1.5 1.5C9.9 23.5 0 13.6 0 1.5 0 .6.6 0 1.5 0h4.5c.9 0 1.5.6 1.5 1.5 0 1.8.3 3.6.9 5.2.2.5.1 1.1-.3 1.5l-2.8 2.8z"
fill="currentColor"
/>
</svg>
);
}

View File

@ -0,0 +1,26 @@
"use client";
import { useCurrency } from "@/context/CurrencyContext";
import { CURRENCY_OPTIONS, type CurrencyCode } from "@/lib/currency";
export function CurrencySwitcher() {
const { currency, setCurrency } = useCurrency();
return (
<label className="flex items-center gap-2 text-xs text-white/90 sm:text-sm">
<span className="hidden text-white/70 sm:inline">Prices in</span>
<select
value={currency}
aria-label="Display currency"
onChange={(e) => setCurrency(e.target.value as CurrencyCode)}
className="max-w-[5.5rem] cursor-pointer rounded-md border border-white/25 bg-white/10 py-1 pl-2 pr-7 text-xs font-medium text-white shadow-sm backdrop-blur-sm focus:border-white/45 focus:outline-none focus:ring-2 focus:ring-white/30 sm:max-w-none sm:py-1.5 sm:pl-2.5 sm:text-sm"
>
{CURRENCY_OPTIONS.map(({ code, shortLabel }) => (
<option key={code} value={code} className="text-[var(--color-text)]">
{shortLabel}
</option>
))}
</select>
</label>
);
}

185
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,185 @@
import Link from "next/link";
import { siteConfig } from "@/lib/mocks/site";
export function Footer() {
return (
<footer
id="contact"
className="border-t border-[var(--color-border)] bg-[var(--color-text)] text-[var(--color-surface-muted)]"
>
<div className="mx-auto max-w-7xl px-4 py-16 md:px-8">
<div className="grid gap-12 md:grid-cols-2 lg:grid-cols-12">
<div className="lg:col-span-4">
<p className="font-display text-2xl text-white">{siteConfig.name}</p>
<p className="mt-2 text-sm text-stone-400">{siteConfig.address}</p>
<p className="mt-4 text-sm text-stone-400">{siteConfig.city}, Ethiopia</p>
</div>
<div className="lg:col-span-3">
<h3 className="text-xs font-semibold uppercase tracking-widest text-stone-500">
Reservations & email
</h3>
<ul className="mt-4 space-y-2 text-sm">
<li>
<a
href={`mailto:${siteConfig.email}`}
className="break-all text-white/90 hover:text-white"
>
{siteConfig.email}
</a>
</li>
{siteConfig.phones.map((p) => (
<li key={p}>
<a href={`tel:${p.replace(/\s/g, "")}`} className="hover:text-white">
{p}
</a>
</li>
))}
</ul>
</div>
<div className="lg:col-span-3">
<h3 className="text-xs font-semibold uppercase tracking-widest text-stone-500">
Departments
</h3>
<ul className="mt-4 space-y-4 text-sm">
{siteConfig.departments.map((d) => (
<li key={d.label}>
<p className="font-medium text-white">{d.label}</p>
{d.phones.map((p) => (
<a
key={p}
href={`tel:${p.replace(/\s/g, "")}`}
className="block text-stone-400 hover:text-white"
>
{p}
</a>
))}
</li>
))}
</ul>
</div>
<div className="lg:col-span-2">
<h3 className="text-xs font-semibold uppercase tracking-widest text-stone-500">
Follow us
</h3>
<ul className="mt-4 flex flex-wrap gap-3">
<li>
<a
href={siteConfig.social.facebook}
target="_blank"
rel="noopener noreferrer"
className="flex h-10 w-10 items-center justify-center rounded-full border border-stone-600 text-white transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent-soft)]"
aria-label="Facebook"
>
<IconFacebook />
</a>
</li>
<li>
<a
href={siteConfig.social.instagram}
target="_blank"
rel="noopener noreferrer"
className="flex h-10 w-10 items-center justify-center rounded-full border border-stone-600 text-white transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent-soft)]"
aria-label="Instagram"
>
<IconInstagram />
</a>
</li>
<li>
<a
href={siteConfig.social.twitter}
target="_blank"
rel="noopener noreferrer"
className="flex h-10 w-10 items-center justify-center rounded-full border border-stone-600 text-white transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent-soft)]"
aria-label="Twitter / X"
>
<IconTwitter />
</a>
</li>
<li>
<a
href={siteConfig.social.whatsapp}
target="_blank"
rel="noopener noreferrer"
className="flex h-10 w-10 items-center justify-center rounded-full border border-stone-600 text-white transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent-soft)]"
aria-label="WhatsApp"
>
<IconWhatsApp />
</a>
</li>
</ul>
<h3 className="mt-8 text-xs font-semibold uppercase tracking-widest text-stone-500">
Quick links
</h3>
<ul className="mt-3 space-y-2 text-sm">
<li>
<Link href="/booking" className="hover:text-white">
Room booking
</Link>
</li>
<li>
<Link href="/#rooms" className="hover:text-white">
Rooms
</Link>
</li>
<li>
<Link href="/#wellness" className="hover:text-white">
Gym & Spa
</Link>
</li>
<li>
<Link href="/#dining" className="hover:text-white">
Dining
</Link>
</li>
<li>
<Link href="/meetings/serenity" className="hover:text-white">
Serenity meeting room
</Link>
</li>
</ul>
</div>
</div>
<p className="mt-12 border-t border-stone-700 pt-8 text-center text-xs text-stone-500">
© {new Date().getFullYear()} Shitaye Suite Hotel. All rights reserved.
</p>
</div>
</footer>
);
}
function IconFacebook() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M22 12a10 10 0 10-11.5 9.95v-7.05h-2.5V12h2.5V9.5c0-2.48 1.5-3.85 3.73-3.85 1.07 0 2.19.19 2.19.19v2.4h-1.23c-1.22 0-1.6.76-1.6 1.53V12h2.72l-.43 2.9h-2.29v7.05A10 10 0 0022 12z" />
</svg>
);
}
function IconInstagram() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M7 2h10a5 5 0 015 5v10a5 5 0 01-5 5H7a5 5 0 01-5-5V7a5 5 0 015-5zm0 2a3 3 0 00-3 3v10a3 3 0 003 3h10a3 3 0 003-3V7a3 3 0 00-3-3H7zm5 3.5A5.5 5.5 0 1112 17.5 5.5 5.5 0 0112 7.5zm0 2a3.5 3.5 0 100 7 3.5 3.5 0 000-7zM17.8 6.3a1.2 1.2 0 11-2.4 0 1.2 1.2 0 012.4 0z" />
</svg>
);
}
function IconTwitter() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M18.244 3H21l-7.5 8.57L22 21h-6.9l-5.38-6.32L5.1 21H3l8-9.15L3 3h6.9l4.86 5.71L18.244 3z" />
</svg>
);
}
function IconWhatsApp() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.435 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
</svg>
);
}

View File

@ -0,0 +1,21 @@
"use client";
import { useCurrency } from "@/context/CurrencyContext";
type Props = {
amountUsd: number;
/** @default 2 */
maximumFractionDigits?: 0 | 1 | 2;
className?: string;
};
export function FormattedUsd({
amountUsd,
maximumFractionDigits = 2,
className,
}: Props) {
const { formatUsd } = useCurrency();
return (
<span className={className}>{formatUsd(amountUsd, maximumFractionDigits)}</span>
);
}

67
src/components/Header.tsx Normal file
View File

@ -0,0 +1,67 @@
import Link from "next/link";
import { CurrencySwitcher } from "@/components/CurrencySwitcher";
import { ReviewsMenu } from "@/components/ReviewsMenu";
import { siteConfig } from "@/lib/mocks/site";
const nav = [
{ href: "/#rooms", label: "Rooms" },
{ href: "/#wellness", label: "Gym & Spa" },
{ href: "/#dining", label: "Dining & venues" },
{ href: "/#meetings", label: "Meetings" },
];
export function Header() {
return (
<header className="sticky top-0 z-40">
<div className="border-b border-white/10 bg-[var(--color-text)] text-white">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-2 sm:gap-4 md:px-8">
<CurrencySwitcher />
<div className="flex flex-wrap items-center justify-end gap-1 sm:gap-3">
<Link
href="/#tour"
className="rounded-md px-2.5 py-1 text-xs font-medium text-white/90 transition hover:bg-white/10 hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white sm:px-3 sm:text-sm"
>
3D tour
</Link>
<span className="hidden h-4 w-px bg-white/25 sm:block" aria-hidden />
<ReviewsMenu variant="topBar" />
</div>
</div>
</div>
<div className="border-b border-[var(--color-border)] bg-[var(--color-surface)]/95 backdrop-blur-md">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4 md:px-8">
<Link href="/" className="group min-w-0 shrink flex flex-col leading-tight">
<span className="font-display text-lg tracking-tight text-[var(--color-primary)] sm:text-xl md:text-2xl">
{siteConfig.name}
</span>
<span className="text-[10px] font-medium uppercase tracking-[0.2em] text-[var(--color-muted)] sm:text-[11px]">
{siteConfig.city}
</span>
</Link>
<nav
className="hidden items-center gap-6 text-sm font-medium text-[var(--color-text)] lg:flex"
aria-label="Main"
>
{nav.map((item) => (
<Link
key={item.href}
href={item.href}
className="transition-colors hover:text-[var(--color-primary)]"
>
{item.label}
</Link>
))}
</nav>
<div className="flex shrink-0 items-center">
<Link
href="/booking"
className="rounded-full bg-[var(--color-primary)] px-4 py-2.5 text-sm font-semibold text-[var(--color-on-primary)] shadow-sm transition hover:bg-[var(--color-primary-hover)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)] md:px-5"
>
Book
</Link>
</div>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,14 @@
"use client";
import { useCurrency } from "@/context/CurrencyContext";
type Props = { usdAmount: number };
export function MeetingHalfDayRate({ usdAmount }: Props) {
const { formatUsd } = useCurrency();
return (
<p className="mt-6 text-sm text-[var(--color-muted)]">
From {formatUsd(usdAmount)} / half day (indicative enquire)
</p>
);
}

View File

@ -0,0 +1,104 @@
"use client";
import { useCallback, useRef, useState } from "react";
type Props = {
label?: string;
videoTourUrl?: string | null;
className?: string;
};
export function Mock3DPlaceholder({
label = "3D preview (demo)",
videoTourUrl,
className = "",
}: Props) {
const [tilt, setTilt] = useState({ x: 4, y: 0 });
const dragging = useRef(false);
const last = useRef({ x: 0, y: 0 });
const onPointerDown = (e: React.PointerEvent) => {
dragging.current = true;
last.current = { x: e.clientX, y: e.clientY };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
};
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragging.current) return;
const dx = e.clientX - last.current.x;
const dy = e.clientY - last.current.y;
last.current = { x: e.clientX, y: e.clientY };
setTilt((t) => ({
x: Math.max(-8, Math.min(14, t.x - dy * 0.08)),
y: Math.max(-18, Math.min(18, t.y + dx * 0.12)),
}));
}, []);
const onPointerUp = (e: React.PointerEvent) => {
dragging.current = false;
try {
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
};
return (
<div
className={`relative aspect-video w-full overflow-hidden rounded-2xl border border-[var(--color-border)] bg-gradient-to-br from-[var(--color-surface-muted)] via-[var(--color-surface)] to-[var(--color-accent-soft)] shadow-lg ${className}`}
role="img"
aria-label="Demonstration 3D preview placeholder — drag to tilt the mock room view"
>
<div
className="absolute inset-0 opacity-[0.07]"
style={{
backgroundImage:
"linear-gradient(90deg, var(--color-primary) 1px, transparent 1px), linear-gradient(var(--color-primary) 1px, transparent 1px)",
backgroundSize: "32px 32px",
}}
/>
<div
className="absolute inset-0 flex items-center justify-center"
style={{ perspective: "900px" }}
>
<div
className="mock3d-plane relative h-[48%] w-[62%] cursor-grab rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-2xl active:cursor-grabbing"
style={{
transform: `rotateX(${tilt.x}deg) rotateY(${tilt.y}deg)`,
transformStyle: "preserve-3d",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<div
className="absolute inset-2 rounded-lg bg-gradient-to-br from-stone-200 to-stone-400 opacity-90"
style={{ transform: "translateZ(-12px)" }}
/>
<div className="relative flex h-full flex-col justify-end p-4 md:p-6">
<p className="font-display text-lg text-[var(--color-primary)] md:text-2xl">
Shitaye
</p>
<p className="text-xs text-[var(--color-muted)]">Virtual walkthrough</p>
</div>
</div>
</div>
<div className="pointer-events-none absolute bottom-4 left-4 right-4 flex flex-wrap items-center justify-between gap-2 md:pointer-events-auto">
<span className="rounded-full bg-[var(--color-text)]/85 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
{label}
</span>
{videoTourUrl ? (
<a
href={videoTourUrl}
target="_blank"
rel="noopener noreferrer"
className="pointer-events-auto rounded-full border border-[var(--color-primary)] bg-[var(--color-surface)] px-4 py-1.5 text-xs font-semibold text-[var(--color-primary)] shadow-sm transition hover:bg-[var(--color-primary)] hover:text-[var(--color-on-primary)]"
>
Watch video tour
</a>
) : null}
</div>
</div>
);
}

View File

@ -0,0 +1,63 @@
import Image from "next/image";
import Link from "next/link";
import type { Outlet } from "@/lib/mocks/outlets";
type Props = { outlet: Outlet };
export function OutletCard({ outlet }: Props) {
const inner = (
<>
<div className="relative aspect-[16/10] overflow-hidden">
<Image
src={outlet.image}
alt={outlet.name}
fill
className="object-cover transition duration-500 group-hover:scale-105"
sizes="(max-width:768px) 100vw, 40vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/20 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-5 md:p-8">
{outlet.floor ? (
<p className="mb-1 text-xs font-medium uppercase tracking-widest text-white/80">
{outlet.floor}
</p>
) : null}
<h3 className="font-display text-2xl text-white md:text-3xl">{outlet.name}</h3>
<p className="mt-2 max-w-xl text-sm text-white/90">{outlet.tagline}</p>
{outlet.detailHref ? (
<span className="mt-3 inline-block text-xs font-semibold uppercase tracking-wider text-white/90 underline decoration-white/40 underline-offset-4">
View details
</span>
) : null}
</div>
</div>
<ul className="space-y-2 border-t border-[var(--color-border)] p-5 text-sm text-[var(--color-muted)] md:p-6">
{outlet.bullets.map((b) => (
<li key={b} className="flex gap-2">
<span className="text-[var(--color-accent)]" aria-hidden>
·
</span>
{b}
</li>
))}
</ul>
</>
);
if (outlet.detailHref) {
return (
<Link
href={outlet.detailHref}
className="group card-lift block overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm"
>
{inner}
</Link>
);
}
return (
<article className="card-lift group overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm">
{inner}
</article>
);
}

View File

@ -0,0 +1,325 @@
"use client";
import Link from "next/link";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { createPortal } from "react-dom";
import {
bookingStyleReviews,
overallRatingOutOfFive,
} from "@/lib/mocks/bookingReviews";
import { siteConfig } from "@/lib/mocks/site";
function useIsClient() {
return useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
}
type ReviewsMenuProps = { variant?: "default" | "topBar" };
export function ReviewsMenu({ variant = "default" }: ReviewsMenuProps) {
const [open, setOpen] = useState(false);
const isTopBar = variant === "topBar";
const mounted = useIsClient();
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => setOpen(false), []);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const triggerEl = triggerRef.current;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKey);
queueMicrotask(() => {
panelRef.current
?.querySelector<HTMLElement>("button[aria-label='Close']")
?.focus();
});
return () => {
document.body.style.overflow = prev;
document.removeEventListener("keydown", onKey);
triggerEl?.focus();
};
}, [open, close]);
const dialog =
open && mounted ? (
<div
className="fixed inset-0 z-[100] overflow-x-hidden overflow-y-auto overscroll-contain"
role="presentation"
>
<button
type="button"
className="fixed inset-0 bg-black/50 backdrop-blur-[2px]"
aria-label="Close reviews dialog"
onClick={close}
/>
{/* min-h-full = full viewport inside fixed inset-0; items-center centers the card */}
<div
className="flex min-h-full w-full items-center justify-center px-4 py-6 sm:px-6 sm:py-10"
style={{
paddingTop: "max(1.5rem, env(safe-area-inset-top, 0px))",
paddingBottom: "max(1.5rem, env(safe-area-inset-bottom, 0px))",
paddingLeft: "max(1rem, env(safe-area-inset-left, 0px))",
paddingRight: "max(1rem, env(safe-area-inset-right, 0px))",
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="reviews-dialog-title"
className="relative z-10 flex min-h-0 w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-2xl"
style={{ maxHeight: "min(calc(100dvh - 3rem), 640px)" }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-[var(--color-border)] px-4 py-4 sm:px-5 sm:py-4">
<div className="min-w-0 flex-1 pr-2">
<BookingDotLogo />
<h2
id="reviews-dialog-title"
className="mt-3 font-display text-lg text-[var(--color-text)] sm:text-xl"
>
Guest reviews
</h2>
<p className="mt-1 hidden text-xs text-[var(--color-muted)] sm:block sm:text-sm">
Sample scores for layout connect your live Booking.com page when ready.
</p>
<div className="mt-3 flex flex-wrap items-center gap-2 sm:mt-4 sm:gap-3">
<CircleRatingRow rating={overallRatingOutOfFive} />
<span className="text-base font-semibold tabular-nums text-[var(--color-text)] sm:text-lg">
{overallRatingOutOfFive}
<span className="text-xs font-normal text-[var(--color-muted)] sm:text-sm">
{" "}
/ 5
</span>
</span>
</div>
</div>
<button
type="button"
onClick={close}
className="shrink-0 rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]"
aria-label="Close"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M6 6l12 12M18 6L6 18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</button>
</div>
<ul className="min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-4 py-3 sm:px-5 sm:py-4">
{bookingStyleReviews.map((r) => (
<li
key={r.id}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-3 text-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-semibold text-[var(--color-text)]">{r.author}</p>
<p className="text-xs text-[var(--color-muted)]">
{r.country} · {r.stayDate}
</p>
</div>
<span className="shrink-0 rounded-md bg-[#003580] px-2 py-0.5 text-xs font-bold text-white">
{r.rating}
</span>
</div>
<p className="mt-2 font-medium text-[var(--color-text)]">{r.title}</p>
<p className="mt-1 line-clamp-3 leading-relaxed text-[var(--color-muted)] sm:line-clamp-none">
{r.text}
</p>
<p className="mt-2 text-xs text-[var(--color-muted)]">Stayed in: {r.roomType}</p>
</li>
))}
</ul>
<div className="shrink-0 border-t border-[var(--color-border)] p-4 sm:p-5">
<Link
href={siteConfig.bookingComReviewsUrl}
target="_blank"
rel="noopener noreferrer"
className="flex w-full flex-col items-center justify-center gap-2 rounded-full bg-[#003580] px-4 py-3 text-center text-sm font-semibold text-white transition hover:bg-[#00224f] sm:flex-row sm:gap-3"
onClick={close}
>
<BookingDotLogo compact />
<span>Read all reviews</span>
</Link>
</div>
</div>
</div>
</div>
) : null;
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
className={
isTopBar
? "flex items-center gap-2 rounded-md border border-white/20 px-2.5 py-1 text-xs font-medium text-white/95 transition hover:border-white/35 hover:bg-white/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white sm:gap-2 sm:px-3 sm:text-sm"
: "flex items-center gap-1.5 rounded-full border border-transparent px-2 py-2 text-sm font-medium text-[var(--color-text)] transition hover:border-[var(--color-border)] hover:bg-[var(--color-surface-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)] sm:gap-2 sm:px-3"
}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={`Guest reviews, ${overallRatingOutOfFive} out of 5`}
>
<span className={isTopBar ? "inline" : "hidden sm:inline"}>Reviews</span>
<span
className={
isTopBar
? "flex items-center gap-1 rounded-md bg-[#003580] px-2 py-0.5 text-xs font-bold text-white"
: "flex items-center gap-1 rounded-md bg-[#003580] px-2 py-1 text-xs font-bold text-white sm:py-0.5"
}
>
<span>{overallRatingOutOfFive}</span>
<span className={isTopBar ? "inline" : "hidden sm:inline"}>
<CircleRatingMini rating={overallRatingOutOfFive} />
</span>
</span>
</button>
{mounted && dialog ? createPortal(dialog, document.body) : null}
</>
);
}
/** Booking.comstyle wordmark: “Booking” + yellow dot + “.com” */
function BookingDotLogo({
className = "",
compact = false,
}: {
className?: string;
compact?: boolean;
}) {
if (compact) {
return (
<span
className={`inline-flex items-center gap-0.5 font-bold tracking-tight text-white ${className}`}
>
<span className="text-[15px]">Booking</span>
<span
className="mx-px inline-block h-2 w-2 shrink-0 rounded-full bg-[#febb02]"
aria-hidden
/>
<span className="text-[15px]">.com</span>
</span>
);
}
return (
<div className={`inline-flex items-baseline gap-0 ${className}`}>
<span className="text-xl font-bold tracking-tight text-[#003580] sm:text-2xl">Booking</span>
<span
className="mx-0.5 inline-block h-2 w-2 shrink-0 translate-y-0.5 rounded-full bg-[#febb02] sm:h-2.5 sm:w-2.5"
aria-hidden
/>
<span className="text-xl font-bold tracking-tight text-[#003580] sm:text-2xl">.com</span>
</div>
);
}
/** Five circles: filled amount per position = min(1, max(0, rating - i)) */
function CircleRatingRow({ rating, max = 5 }: { rating: number; max?: number }) {
const uid = useId().replace(/:/g, "");
const size = 26;
const vb = 24;
const cx = 12;
const cy = 12;
const r = 8;
const stroke = "#d6d3d1";
return (
<div className="flex flex-wrap items-center gap-1 sm:gap-1.5" aria-label={`${rating} out of ${max}`}>
{Array.from({ length: max }, (_, i) => {
const fill = Math.min(1, Math.max(0, rating - i));
const clipW = vb * fill;
const clipId = `${uid}-clip-${i}`;
return (
<svg
key={i}
width={size}
height={size}
viewBox={`0 0 ${vb} ${vb}`}
className="shrink-0"
aria-hidden
>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={stroke} strokeWidth="1.75" />
{fill > 0 ? (
<>
<defs>
<clipPath id={clipId}>
<rect x="0" y="0" width={clipW} height={vb} />
</clipPath>
</defs>
<circle
cx={cx}
cy={cy}
r={r}
fill="#febb02"
clipPath={`url(#${clipId})`}
/>
</>
) : null}
</svg>
);
})}
</div>
);
}
function CircleRatingMini({ rating }: { rating: number }) {
const uid = useId().replace(/:/g, "");
const max = 5;
const vb = 24;
const cx = 12;
const cy = 12;
const r = 7;
return (
<span className="inline-flex items-center gap-px" aria-hidden>
{Array.from({ length: max }, (_, i) => {
const fill = Math.min(1, Math.max(0, rating - i));
const clipW = vb * fill;
const clipId = `${uid}-m-${i}`;
return (
<svg key={i} width="10" height="10" viewBox={`0 0 ${vb} ${vb}`} className="shrink-0">
<circle cx={cx} cy={cy} r={r} fill="none" stroke="rgba(255,255,255,0.45)" strokeWidth="2" />
{fill > 0 ? (
<>
<defs>
<clipPath id={clipId}>
<rect x="0" y="0" width={clipW} height={vb} />
</clipPath>
</defs>
<circle cx={cx} cy={cy} r={r} fill="#febb02" clipPath={`url(#${clipId})`} />
</>
) : null}
</svg>
);
})}
</span>
);
}

View File

@ -0,0 +1,53 @@
import Image from "next/image";
import Link from "next/link";
import { FormattedUsd } from "@/components/FormattedUsd";
import type { Room } from "@/lib/mocks/rooms";
type Props = { room: Room };
export function RoomCard({ room }: Props) {
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">
<Image
src={room.gallery[0]!}
alt={room.name}
fill
className="object-cover transition duration-500 group-hover:scale-105"
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} />
<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-display text-xl text-[var(--color-text)] md:text-2xl">
<Link href={`/rooms/${room.slug}`} className="hover:text-[var(--color-primary)]">
{room.name}
</Link>
</h3>
<p className="mt-2 line-clamp-2 flex-1 text-sm leading-relaxed text-[var(--color-muted)]">
{room.shortDescription}
</p>
<div className="mt-4 flex items-center justify-between gap-3">
<Link
href={`/rooms/${room.slug}`}
className="text-sm font-semibold text-[var(--color-primary)] underline-offset-4 hover:underline"
>
View details
</Link>
<Link
href={`/booking?room=${room.id}`}
className="flex h-11 w-11 items-center justify-center rounded-full bg-[var(--color-text)] text-white transition hover:bg-[var(--color-primary)]"
aria-label={`Book ${room.name}`}
>
<span aria-hidden className="text-lg">
</span>
</Link>
</div>
</div>
</article>
);
}

View File

@ -0,0 +1,112 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { FormattedUsd } from "@/components/FormattedUsd";
import type { Room } from "@/lib/mocks/rooms";
import { rooms } from "@/lib/mocks/rooms";
type Props = {
selected: Room | null;
onSelect: (roomId: string) => void;
};
export function RoomSelectBooking({ selected, onSelect }: Props) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
}
if (open) document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
return (
<div className="relative" ref={ref}>
<span className="mb-2 block text-sm font-medium text-[var(--color-text)]">
Select room
</span>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-left shadow-sm transition hover:border-[var(--color-primary)]/40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
aria-expanded={open}
aria-haspopup="listbox"
>
{selected ? (
<>
<div className="relative h-14 w-20 shrink-0 overflow-hidden rounded-lg">
<Image
src={selected.gallery[0]!}
alt={selected.name}
fill
className="object-cover"
sizes="80px"
/>
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-[var(--color-text)]">{selected.name}</p>
<p className="text-xs text-[var(--color-muted)]">
From ${selected.nightlyRate} / night
</p>
</div>
</>
) : (
<span className="text-[var(--color-muted)]">Choose a room category</span>
)}
<span className="shrink-0 text-[var(--color-muted)]" aria-hidden>
{open ? "▴" : "▾"}
</span>
</button>
{open ? (
<ul
className="absolute left-0 right-0 top-full z-40 mt-2 max-h-[min(70vh,400px)] overflow-y-auto rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] py-2 shadow-xl"
role="listbox"
>
{rooms.map((room) => (
<li key={room.id} role="option" aria-selected={selected?.id === room.id}>
<button
type="button"
onClick={() => {
onSelect(room.id);
setOpen(false);
}}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition hover:bg-[var(--color-surface-muted)]"
>
<div className="relative h-12 w-[4.5rem] shrink-0 overflow-hidden rounded-lg">
<Image
src={room.gallery[0]!}
alt={room.name}
fill
className="object-cover"
sizes="72px"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[var(--color-text)]">{room.name}</p>
<p className="text-xs text-[var(--color-muted)]">
<FormattedUsd amountUsd={room.nightlyRate} maximumFractionDigits={0} />
/night · max {room.maxGuests} guests
</p>
</div>
</button>
</li>
))}
</ul>
) : null}
{selected ? (
<Link
href={`/rooms/${selected.slug}`}
className="mt-2 inline-block text-sm font-semibold text-[var(--color-primary)] hover:underline"
>
View full room details & amenities
</Link>
) : null}
</div>
);
}

14
src/components/Shell.tsx Normal file
View File

@ -0,0 +1,14 @@
import { CallUsFab } from "./CallUsFab";
import { Footer } from "./Footer";
import { Header } from "./Header";
export function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="grain flex min-h-full flex-col">
<Header />
<div className="flex-1">{children}</div>
<Footer />
<CallUsFab />
</div>
);
}

View File

@ -0,0 +1,28 @@
import { siteConfig } from "@/lib/mocks/site";
import { Mock3DPlaceholder } from "./Mock3DPlaceholder";
import { VirtualTourEmbed } from "./VirtualTourEmbed";
type Props = {
embedUrl: string | null | undefined;
title: string;
videoTourUrl?: string | null;
className?: string;
};
export function VirtualTourBlock({
embedUrl,
title,
videoTourUrl = siteConfig.videoTourUrl,
className,
}: Props) {
if (embedUrl) {
return <VirtualTourEmbed src={embedUrl} title={title} className={className} />;
}
return (
<Mock3DPlaceholder
videoTourUrl={videoTourUrl}
className={className}
label="Virtual tour — demo preview"
/>
);
}

View File

@ -0,0 +1,24 @@
"use client";
type Props = {
src: string;
title: string;
className?: string;
};
export function VirtualTourEmbed({ src, title, className = "" }: Props) {
return (
<div
className={`relative aspect-video w-full overflow-hidden rounded-2xl border border-[var(--color-border)] bg-black shadow-lg ${className}`}
>
<iframe
src={src}
title={title}
className="h-full w-full"
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
);
}

View File

@ -0,0 +1,363 @@
import type { ReactNode, SVGProps } from "react";
export type AmenityIconId =
| "breakfast"
| "shuttle"
| "wifi"
| "sparkle"
| "tv"
| "kitchen"
| "views"
| "minibar"
| "lock"
| "iron"
| "router"
| "laundry"
| "projector"
| "microphone"
| "clipboard"
| "thermometer"
| "handshake"
| "doorOpen"
| "accessibility"
| "monitor"
| "video"
| "chair"
| "volumeMuted"
| "restroom"
| "pen"
| "droplet"
| "phone"
| "treadmill"
| "dumbbell"
| "stretch"
| "towel"
| "headphones"
| "massage"
| "steam"
| "leaf"
| "lounge"
| "boutique";
type IconProps = SVGProps<SVGSVGElement>;
function Shell({ className, children, ...rest }: IconProps & { children: ReactNode }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden
{...rest}
>
{children}
</svg>
);
}
const stroke = {
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
/**
* Brand-colored amenity glyphs (use with `className="text-[var(--color-primary)]"` or accent).
*/
export function AmenityIcon({ id, className }: { id: AmenityIconId; className?: string }) {
switch (id) {
case "breakfast":
return (
<Shell className={className}>
<path d="M8 3v4M8 11v10M16 7v14M12 3v3" {...stroke} />
<path d="M4 11h16" {...stroke} />
<circle cx="8" cy="7" r="2" {...stroke} />
</Shell>
);
case "shuttle":
return (
<Shell className={className}>
<path d="M3 17h2l1-6h11l2 6h2" {...stroke} />
<path d="M5 17v2M19 17v2" {...stroke} />
<circle cx="7.5" cy="19.5" r="1.5" {...stroke} />
<circle cx="16.5" cy="19.5" r="1.5" {...stroke} />
<path d="M6 11V8a2 2 0 012-2h8a2 2 0 012 2v3" {...stroke} />
</Shell>
);
case "wifi":
return (
<Shell className={className}>
<path d="M5 12.55a11 11 0 0114.08 0" {...stroke} />
<path d="M8.53 16.11a6 6 0 016.95 0" {...stroke} />
<circle cx="12" cy="20" r="1" fill="currentColor" stroke="none" />
</Shell>
);
case "sparkle":
return (
<Shell className={className}>
<path d="M12 3l1.2 4.2L17 8.5l-3.8 1.3L12 14l-1.2-4.2L7 8.5l3.8-1.3L12 3z" {...stroke} />
<path d="M19 15l.5 1.8L21 17.5l-1.5.5L19 20l-.5-1.8L17 17.5l1.5-.5L19 15z" {...stroke} />
</Shell>
);
case "tv":
return (
<Shell className={className}>
<rect x="3" y="5" width="18" height="12" rx="2" {...stroke} />
<path d="M8 21h8M12 17v4" {...stroke} />
</Shell>
);
case "kitchen":
return (
<Shell className={className}>
<path d="M4 10h16v10a1 1 0 01-1 1H5a1 1 0 01-1-1V10z" {...stroke} />
<path d="M8 10V7a4 4 0 018 0v3" {...stroke} />
<path d="M9 14h6" {...stroke} />
</Shell>
);
case "views":
return (
<Shell className={className}>
<path d="M3 18l6.5-6.5a2.12 2.12 0 013 0L21 18" {...stroke} />
<path d="M3 12l4-4a2.12 2.12 0 013 0l10 10" {...stroke} />
<circle cx="18" cy="6" r="2" {...stroke} />
</Shell>
);
case "minibar":
return (
<Shell className={className}>
<path d="M8 3h8v18H8V3z" {...stroke} />
<path d="M8 9h8M10 14h4" {...stroke} />
<path d="M12 3v3" {...stroke} />
</Shell>
);
case "lock":
return (
<Shell className={className}>
<rect x="5" y="11" width="14" height="10" rx="2" {...stroke} />
<path d="M8 11V7a4 4 0 018 0v4" {...stroke} />
</Shell>
);
case "iron":
return (
<Shell className={className}>
<path d="M6 14h12l-1 6H7l-1-6z" {...stroke} />
<path d="M6 14V9a3 3 0 013-3h6a3 3 0 013 3v5" {...stroke} />
<path d="M9 18h6" {...stroke} />
</Shell>
);
case "router":
return (
<Shell className={className}>
<path d="M5 12h14" {...stroke} />
<path d="M8 8c2-2 6-2 8 0M6 10c2.5-2.5 7.5-2.5 10 0" {...stroke} />
<circle cx="12" cy="15" r="2" {...stroke} />
<path d="M12 17v2" {...stroke} />
</Shell>
);
case "laundry":
return (
<Shell className={className}>
<path d="M6 4h12a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2z" {...stroke} />
<circle cx="12" cy="13" r="4" {...stroke} />
<path d="M8 4v4h8V4" {...stroke} />
</Shell>
);
case "projector":
return (
<Shell className={className}>
<rect x="3" y="7" width="14" height="10" rx="1" {...stroke} />
<path d="M17 11h3l2 2v2l-2 2h-3" {...stroke} />
<circle cx="10" cy="12" r="2" {...stroke} />
</Shell>
);
case "microphone":
return (
<Shell className={className}>
<path d="M12 14a3 3 0 003-3V6a3 3 0 10-6 0v5a3 3 0 003 3z" {...stroke} />
<path d="M8 12v1a4 4 0 008 0v-1M12 19v2M9 22h6" {...stroke} />
</Shell>
);
case "clipboard":
return (
<Shell className={className}>
<path d="M9 4h6l1 2h2a1 1 0 011 1v13a1 1 0 01-1 1H6a1 1 0 01-1-1V7a1 1 0 011-1h2l1-2z" {...stroke} />
<path d="M9 12h6M9 16h4" {...stroke} />
</Shell>
);
case "thermometer":
return (
<Shell className={className}>
<path d="M14 4v10.2a4 4 0 11-4 0V4a2 2 0 114 0z" {...stroke} />
<circle cx="12" cy="17" r="1.25" fill="currentColor" stroke="none" />
</Shell>
);
case "handshake":
return (
<Shell className={className}>
<path d="M4 14l3-3 2 2 4-4 3 3-5 5H6l-2-3z" {...stroke} />
<path d="M14 8l2-2 4 4-2 2" {...stroke} />
</Shell>
);
case "doorOpen":
return (
<Shell className={className}>
<path d="M13 3h5v18h-5" {...stroke} />
<path d="M13 3L7 5v14l6 2" {...stroke} />
<circle cx="15" cy="12" r="1" fill="currentColor" stroke="none" />
</Shell>
);
case "accessibility":
return (
<Shell className={className}>
<circle cx="12" cy="5" r="2" {...stroke} />
<path d="M12 7v5M8 21h8M9 12h6M8 16l2 5M16 16l-2 5" {...stroke} />
</Shell>
);
case "monitor":
return (
<Shell className={className}>
<rect x="2" y="4" width="20" height="13" rx="2" {...stroke} />
<path d="M8 21h8M12 17v4" {...stroke} />
</Shell>
);
case "video":
return (
<Shell className={className}>
<rect x="3" y="6" width="12" height="12" rx="2" {...stroke} />
<path d="M15 10l6-3v10l-6-3v-4z" {...stroke} />
</Shell>
);
case "chair":
return (
<Shell className={className}>
<path d="M6 19v-4a2 2 0 012-2h8a2 2 0 012 2v4" {...stroke} />
<path d="M8 13V8a4 4 0 018 0v5" {...stroke} />
<path d="M5 19h14" {...stroke} />
</Shell>
);
case "volumeMuted":
return (
<Shell className={className}>
<path d="M11 5L6 9H3v6h3l5 4V5z" {...stroke} />
<path d="M22 9l-6 6M16 9l6 6" {...stroke} />
</Shell>
);
case "restroom":
return (
<Shell className={className}>
<circle cx="8" cy="7" r="2" {...stroke} />
<path d="M6 11h4v10M8 11v10" {...stroke} />
<circle cx="17" cy="7" r="2" {...stroke} />
<path d="M15 11h4v10M17 11v10" {...stroke} />
</Shell>
);
case "pen":
return (
<Shell className={className}>
<path d="M13 3l8 8-9 9H4v-8l9-9z" {...stroke} />
<path d="M15 5l4 4" {...stroke} />
</Shell>
);
case "droplet":
return (
<Shell className={className}>
<path
d="M12 3s5 5.5 5 10a5 5 0 11-10 0c0-4.5 5-10 5-10z"
{...stroke}
/>
</Shell>
);
case "phone":
return (
<Shell className={className}>
<path
d="M6.6 10.8c1.8 3.6 4.8 6.6 8.4 8.4l2.8-2.8c.4-.4 1-.5 1.5-.3 1.6.6 3.4.9 5.2.9.9 0 1.5.6 1.5 1.5V22c0 .9-.6 1.5-1.5 1.5C9.9 23.5 0 13.6 0 1.5 0 .6.6 0 1.5 0h4.5c.9 0 1.5.6 1.5 1.5 0 1.8.3 3.6.9 5.2.2.5.1 1.1-.3 1.5l-2.8 2.8z"
fill="currentColor"
stroke="none"
/>
</Shell>
);
case "treadmill":
return (
<Shell className={className}>
<path d="M4 18h16M6 18l3-8h6l3 8" {...stroke} />
<path d="M8 10l2-4h4l2 4" {...stroke} />
<circle cx="9" cy="19" r="1.5" {...stroke} />
<circle cx="15" cy="19" r="1.5" {...stroke} />
</Shell>
);
case "dumbbell":
return (
<Shell className={className}>
<path d="M6 10h12M6 14h12" {...stroke} />
<path d="M4 8v8M20 8v8" {...stroke} />
<path d="M6 8v8M18 8v8" {...stroke} />
</Shell>
);
case "stretch":
return (
<Shell className={className}>
<circle cx="8" cy="6" r="2" {...stroke} />
<path d="M8 8v4l-3 8M8 12l4 2 2 6M14 14l4-2 2-6" {...stroke} />
<circle cx="18" cy="18" r="2" {...stroke} />
</Shell>
);
case "towel":
return (
<Shell className={className}>
<path d="M6 4h12v4H6V4zM6 10h12v4H6v-4zM6 16h12v4H6v-4z" {...stroke} />
</Shell>
);
case "headphones":
return (
<Shell className={className}>
<path d="M4 14v4a2 2 0 002 2h1v-8H6a2 2 0 00-2 2zM20 14v4a2 2 0 01-2 2h-1v-8h1a2 2 0 012 2z" {...stroke} />
<path d="M4 14a8 8 0 0116 0" {...stroke} />
</Shell>
);
case "massage":
return (
<Shell className={className}>
<path d="M8 12c2-4 6-4 8 0s-2 6-4 6-6-2-4-6z" {...stroke} />
<path d="M5 9c1-2 3-3 5-3M19 9c-1-2-3-3-5-3" {...stroke} />
</Shell>
);
case "steam":
return (
<Shell className={className}>
<path d="M8 16c2 0 2-2 2-3s0-3 2-3M12 16c2 0 2-2 2-3s0-3 2-3M16 16c2 0 2-2 2-3s0-3 2-3" {...stroke} />
<path d="M6 20h12" {...stroke} />
</Shell>
);
case "leaf":
return (
<Shell className={className}>
<path d="M12 21c-4-4-6-8-6-12a8 8 0 0116 0c0 4-2 8-6 12z" {...stroke} />
<path d="M12 21V9" {...stroke} />
</Shell>
);
case "lounge":
return (
<Shell className={className}>
<path d="M4 18h16M5 18v-3a3 3 0 013-3h8a3 3 0 013 3v3" {...stroke} />
<path d="M7 12V9a2 2 0 012-2h6a2 2 0 012 2v3" {...stroke} />
</Shell>
);
case "boutique":
return (
<Shell className={className}>
<path d="M6 8h12l-1 12H7L6 8z" {...stroke} />
<path d="M9 8V6a3 3 0 016 0v2" {...stroke} />
<path d="M6 8V7a2 2 0 012-2h8a2 2 0 012 2v1" {...stroke} />
</Shell>
);
default:
return (
<Shell className={className}>
<circle cx="12" cy="12" r="4" {...stroke} />
</Shell>
);
}
}

View File

@ -0,0 +1,218 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Room } from "@/lib/mocks/rooms";
import { rooms } from "@/lib/mocks/rooms";
import { siteConfig } from "@/lib/mocks/site";
export type GuestDetails = {
firstName: string;
lastName: string;
email: string;
phone: string;
/** Airline / PNR / booking reference */
flightBookingNumber: string;
/** Local arrival time (24h from time input) */
arrivalTime: string;
};
const defaultDates = () => {
const inD = new Date();
inD.setDate(inD.getDate() + 7);
const outD = new Date(inD);
outD.setDate(outD.getDate() + 3);
return {
checkIn: inD.toISOString().slice(0, 10),
checkOut: outD.toISOString().slice(0, 10),
};
};
function nightsBetween(checkIn: string, checkOut: string): number {
const a = new Date(checkIn).getTime();
const b = new Date(checkOut).getTime();
const n = Math.ceil((b - a) / (1000 * 60 * 60 * 24));
return Math.max(1, n);
}
type BookingContextValue = {
checkIn: string;
checkOut: string;
guests: number;
roomId: string | null;
guest: GuestDetails;
couponCode: string;
couponPercentOff: number;
holdReference: string | null;
/** True when guest chose “reserve now, pay later” (hold without payment yet) */
payLaterHold: boolean;
confirmationId: string | null;
paidAt: string | null;
setDates: (checkIn: string, checkOut: string) => void;
setGuests: (n: number) => void;
setRoomId: (id: string | null) => void;
setGuest: (g: Partial<GuestDetails>) => void;
setCouponCode: (code: string) => void;
applyCoupon: () => void;
setHoldReference: (ref: string | null) => void;
setPayLaterHold: (value: boolean) => void;
setConfirmation: (id: string | null, paidAt: string | null) => void;
resetBooking: () => void;
selectedRoom: Room | null;
nights: number;
subtotal: number;
taxAmount: number;
discountAmount: number;
total: number;
};
const BookingContext = createContext<BookingContextValue | null>(null);
const emptyGuest: GuestDetails = {
firstName: "",
lastName: "",
email: "",
phone: "",
flightBookingNumber: "",
arrivalTime: "",
};
export function BookingProvider({ children }: { children: ReactNode }) {
const d = defaultDates();
const [checkIn, setCheckIn] = useState(d.checkIn);
const [checkOut, setCheckOut] = useState(d.checkOut);
const [guests, setGuestsState] = useState(2);
const [roomId, setRoomIdState] = useState<string | null>(null);
const [guest, setGuestState] = useState<GuestDetails>({ ...emptyGuest });
const [couponCode, setCouponCodeState] = useState("");
const [couponPercentOff, setCouponPercentOff] = useState(0);
const [holdReference, setHoldReference] = useState<string | null>(null);
const [payLaterHold, setPayLaterHoldState] = useState(false);
const [confirmationId, setConfirmationId] = useState<string | null>(null);
const [paidAt, setPaidAt] = useState<string | null>(null);
const setDates = useCallback((ci: string, co: string) => {
setCheckIn(ci);
setCheckOut(co);
}, []);
const setGuests = useCallback((n: number) => {
setGuestsState(Math.min(12, Math.max(1, n)));
}, []);
const setRoomId = useCallback((id: string | null) => {
setRoomIdState(id);
}, []);
const setGuest = useCallback((g: Partial<GuestDetails>) => {
setGuestState((prev) => ({ ...prev, ...g }));
}, []);
const setCouponCode = useCallback((code: string) => {
setCouponCodeState(code);
setCouponPercentOff(0);
}, []);
const applyCoupon = useCallback(() => {
const c = couponCode.trim().toUpperCase();
if (c === "SHITAYE10") setCouponPercentOff(10);
else if (c === "WELCOME5") setCouponPercentOff(5);
else setCouponPercentOff(0);
}, [couponCode]);
const setPayLaterHold = useCallback((value: boolean) => {
setPayLaterHoldState(value);
}, []);
const setConfirmation = useCallback((id: string | null, at: string | null) => {
setConfirmationId(id);
setPaidAt(at);
if (id) setPayLaterHoldState(false);
}, []);
const resetBooking = useCallback(() => {
const nd = defaultDates();
setCheckIn(nd.checkIn);
setCheckOut(nd.checkOut);
setGuestsState(2);
setRoomIdState(null);
setGuestState({ ...emptyGuest });
setCouponCodeState("");
setCouponPercentOff(0);
setHoldReference(null);
setPayLaterHoldState(false);
setConfirmationId(null);
setPaidAt(null);
}, []);
const selectedRoom = useMemo(
() => rooms.find((r) => r.id === roomId) ?? null,
[roomId],
);
const nights = useMemo(
() => nightsBetween(checkIn, checkOut),
[checkIn, checkOut],
);
const subtotal = useMemo(() => {
if (!selectedRoom) return 0;
return selectedRoom.nightlyRate * nights;
}, [selectedRoom, nights]);
const discountAmount = useMemo(
() => Math.round(subtotal * (couponPercentOff / 100) * 100) / 100,
[subtotal, couponPercentOff],
);
const afterDiscount = Math.max(0, subtotal - discountAmount);
const taxAmount =
Math.round(afterDiscount * siteConfig.taxRate * 100) / 100;
const total = Math.round((afterDiscount + taxAmount) * 100) / 100;
const value: BookingContextValue = {
checkIn,
checkOut,
guests,
roomId,
guest,
couponCode,
couponPercentOff,
holdReference,
payLaterHold,
confirmationId,
paidAt,
setDates,
setGuests,
setRoomId,
setGuest,
setCouponCode,
applyCoupon,
setHoldReference,
setPayLaterHold,
setConfirmation,
resetBooking,
selectedRoom,
nights,
subtotal,
taxAmount,
discountAmount,
total,
};
return (
<BookingContext.Provider value={value}>{children}</BookingContext.Provider>
);
}
export function useBooking() {
const ctx = useContext(BookingContext);
if (!ctx) throw new Error("useBooking must be used within BookingProvider");
return ctx;
}

View File

@ -0,0 +1,87 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useSyncExternalStore,
type ReactNode,
} from "react";
import {
type CurrencyCode,
convertFromUsd,
formatMoneyFromUsd,
isCurrencyCode,
} from "@/lib/currency";
const STORAGE_KEY = "shitaye-currency";
const CURRENCY_EVENT = "shitaye-currency-change";
type CurrencyContextValue = {
currency: CurrencyCode;
setCurrency: (c: CurrencyCode) => void;
formatUsd: (amountUsd: number, maximumFractionDigits?: 0 | 1 | 2) => string;
convertUsd: (amountUsd: number) => number;
};
const CurrencyContext = createContext<CurrencyContextValue | null>(null);
function readCurrency(): CurrencyCode {
if (typeof window === "undefined") return "USD";
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw && isCurrencyCode(raw)) return raw;
} catch {
/* ignore */
}
return "USD";
}
function subscribe(onChange: () => void) {
if (typeof window === "undefined") return () => {};
const handler = () => onChange();
window.addEventListener("storage", handler);
window.addEventListener(CURRENCY_EVENT, handler);
return () => {
window.removeEventListener("storage", handler);
window.removeEventListener(CURRENCY_EVENT, handler);
};
}
export function CurrencyProvider({ children }: { children: ReactNode }) {
const currency = useSyncExternalStore(
subscribe,
readCurrency,
() => "USD" as CurrencyCode,
) as CurrencyCode;
const setCurrency = useCallback((c: CurrencyCode) => {
try {
localStorage.setItem(STORAGE_KEY, c);
window.dispatchEvent(new Event(CURRENCY_EVENT));
} catch {
/* ignore */
}
}, []);
const value = useMemo((): CurrencyContextValue => {
return {
currency,
setCurrency,
formatUsd: (amountUsd, maximumFractionDigits = 2) =>
formatMoneyFromUsd(amountUsd, currency, maximumFractionDigits),
convertUsd: (amountUsd) => convertFromUsd(amountUsd, currency),
};
}, [currency, setCurrency]);
return (
<CurrencyContext.Provider value={value}>{children}</CurrencyContext.Provider>
);
}
export function useCurrency() {
const ctx = useContext(CurrencyContext);
if (!ctx) throw new Error("useCurrency must be used within CurrencyProvider");
return ctx;
}

39
src/lib/currency.ts Normal file
View File

@ -0,0 +1,39 @@
export type CurrencyCode = "USD" | "EUR" | "GBP" | "AED";
export const CURRENCY_OPTIONS: { code: CurrencyCode; shortLabel: string }[] = [
{ code: "USD", shortLabel: "USD" },
{ code: "EUR", shortLabel: "EUR" },
{ code: "GBP", shortLabel: "GBP" },
{ code: "AED", shortLabel: "AED" },
];
/** Display amount = catalog USD × rate (illustrative mock rates). */
export const USD_TO: Record<CurrencyCode, number> = {
USD: 1,
EUR: 0.93,
GBP: 0.79,
AED: 3.67,
};
export function isCurrencyCode(v: string): v is CurrencyCode {
return v === "USD" || v === "EUR" || v === "GBP" || v === "AED";
}
export function convertFromUsd(usd: number, code: CurrencyCode): number {
const n = usd * USD_TO[code];
return Math.round(n * 100) / 100;
}
export function formatMoneyFromUsd(
usd: number,
code: CurrencyCode,
maximumFractionDigits: 0 | 1 | 2 = 2,
): string {
const amount = convertFromUsd(usd, code);
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: code,
minimumFractionDigits: 0,
maximumFractionDigits,
}).format(amount);
}

View File

@ -0,0 +1,11 @@
/** Pretty-print a value from `<input type="time">` (HH:mm). */
export function formatArrivalTimeDisplay(time24: string): string {
const t = time24.trim();
if (!t) return "—";
const parts = t.split(":");
const h = Number.parseInt(parts[0] ?? "", 10);
const m = Number.parseInt(parts[1] ?? "", 10);
if (Number.isNaN(h) || Number.isNaN(m)) return t;
const d = new Date(1970, 0, 1, h, m, 0, 0);
return d.toLocaleTimeString("en-GB", { hour: "numeric", minute: "2-digit" });
}

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)" },
];

41
src/lib/mocks/api.ts Normal file
View File

@ -0,0 +1,41 @@
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export type BookingPayload = {
roomId: string;
email: string;
flightBookingNumber: string;
arrivalTime: string;
};
export type PaymentPayload = {
totalCents: number;
last4?: string;
};
export async function submitBookingHold(
payload: BookingPayload,
): Promise<{ reference: string }> {
void payload;
await delay(900 + Math.random() * 400);
return {
reference: `SHY-${Date.now().toString(36).toUpperCase()}`,
};
}
export async function processPayment(
payload: PaymentPayload,
): Promise<{ confirmationId: string; paidAt: string }> {
void payload;
await delay(1100 + Math.random() * 500);
const id =
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID().slice(0, 8)
: Math.random().toString(36).slice(2, 10);
const confirmationId = `PAY-${id.toUpperCase()}`;
return {
confirmationId,
paidAt: new Date().toISOString(),
};
}

View File

@ -0,0 +1,62 @@
/**
* Illustrative guest reviews shown in the nav style inspired by Booking.com.
* Replace copy/URLs with live data from your Booking.com property page when available.
*/
export type BookingStyleReview = {
id: string;
author: string;
country: string;
rating: number;
maxRating: number;
title: string;
text: string;
stayDate: string;
roomType: string;
};
export const bookingStyleReviews: BookingStyleReview[] = [
{
id: "1",
author: "Sarah M.",
country: "United Kingdom",
rating: 9.2,
maxRating: 10,
title: "Exceptional stay in Addis",
text: "Spotless suites, attentive team, and a perfect base for meetings. Breakfast at FeastVille was a highlight — well return.",
stayDate: "October 2025",
roomType: "Connecting Suite",
},
{
id: "2",
author: "Daniel K.",
country: "Germany",
rating: 8.8,
maxRating: 10,
title: "Great location & comfort",
text: "Quiet rooms, strong WiFi, and easy access to the city. The junior studio had everything we needed for a week of work.",
stayDate: "September 2025",
roomType: "Junior Studio",
},
{
id: "3",
author: "Hanna T.",
country: "Ethiopia",
rating: 9.6,
maxRating: 10,
title: "Family trip made easy",
text: "We booked the penthouse for a celebration — space, views, and service exceeded expectations. Kids loved the IPTV selection.",
stayDate: "August 2025",
roomType: "4 Bedroom Penthouse",
},
];
export function averageBookingStyleRating(
list: BookingStyleReview[] = bookingStyleReviews,
): number {
if (!list.length) return 0;
const sum = list.reduce((a, r) => a + r.rating, 0);
return Math.round((sum / list.length) * 10) / 10;
}
/** Aggregate score shown in the reviews dialog (out of 5), with circle “star” row */
export const overallRatingOutOfFive = 4.5;

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/mocks/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",
},
];

103
src/lib/mocks/rooms.ts Normal file
View File

@ -0,0 +1,103 @@
export type Room = {
id: string;
slug: string;
name: string;
shortDescription: string;
longDescription: string;
nightlyRate: number;
maxGuests: number;
beds: string;
sizeSqM: number;
view: string;
highlights: string[];
gallery: string[];
tourEmbedUrl: string | null;
};
export const rooms: Room[] = [
{
id: "penthouse",
slug: "four-bedroom-penthouse",
name: "The 4 Bedroom Penthouse",
shortDescription: "Our flagship residence with panoramic views and full kitchenette.",
longDescription:
"Experience elevated living in our four-bedroom penthouse — expansive layouts, state-of-the-art kitchenette, and amazing views over Addis Ababa. Ideal for extended stays and distinguished guests who expect space, privacy, and premium finishes.",
nightlyRate: 485,
maxGuests: 8,
beds: "4 bedrooms — mix of king and twin configurations",
sizeSqM: 220,
view: "City skyline",
highlights: ["Private routers", "IPTV", "Mini bar", "In-room safe"],
gallery: [
"https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&q=80&auto=format&fit=crop",
"https://images.unsplash.com/photo-1631049307264-da0ec9d70304?w=1200&q=80&auto=format&fit=crop",
"https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=1200&q=80&auto=format&fit=crop",
],
tourEmbedUrl: null,
},
{
id: "standard",
slug: "standard-rooms",
name: "Standard Rooms",
shortDescription: "Refined comfort with every essential amenity.",
longDescription:
"Our standard rooms combine restful design with practical luxury: premium bedding, dedicated workspace, IPTV, and seamless WiFi / LAN. Perfect for business and leisure travellers who value consistency and calm.",
nightlyRate: 120,
maxGuests: 2,
beds: "1 King or 2 Twin",
sizeSqM: 28,
view: "City or courtyard",
highlights: ["B/B fast", "Iron & board", "Laundry (paid)", "Safe box"],
gallery: [
"https://images.unsplash.com/photo-1590490360182-c33d57733427?w=1200&q=80",
"https://images.unsplash.com/photo-1566665797739-1674de7a215a?w=1200&q=80",
],
tourEmbedUrl: null,
},
{
id: "connecting-suite",
slug: "connecting-suite",
name: "Connecting Suite",
shortDescription: "Flexible suites — convert to a spacious family layout.",
longDescription:
"Connecting suite rooms with the option of converting to family suites. Enjoy separate living and sleeping zones, kitchenette access where applicable, and the same premium amenities found across the property.",
nightlyRate: 210,
maxGuests: 5,
beds: "1 King + connecting twin room",
sizeSqM: 55,
view: "City",
highlights: ["Family-friendly layout", "Kitchenette", "IPTV", "Shuttle"],
gallery: [
"https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&q=80",
"https://images.unsplash.com/photo-1591088398332-8a7791972843?w=1200&q=80",
],
tourEmbedUrl: null,
},
{
id: "junior-studio",
slug: "junior-studios",
name: "Junior Studios",
shortDescription: "Compact sophistication for solo travellers and short stays.",
longDescription:
"Junior studios offer a smart open plan with kitchenette, premium WiFi, IPTV, and efficient storage — designed for guests who want independence without sacrificing hotel service.",
nightlyRate: 95,
maxGuests: 2,
beds: "1 Queen",
sizeSqM: 32,
view: "Urban",
highlights: ["Kitchenette", "Mini bar", "Private router option"],
gallery: [
"https://images.unsplash.com/photo-1522771739844-6a9f6d5f14af?w=1200&q=80",
"https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=1200&q=80",
],
tourEmbedUrl: null,
},
];
export function getRoomBySlug(slug: string): Room | undefined {
return rooms.find((r) => r.slug === slug);
}
export function getAllRoomSlugs(): string[] {
return rooms.map((r) => r.slug);
}

34
src/lib/mocks/site.ts Normal file
View File

@ -0,0 +1,34 @@
/** Site-wide mock config — replace embed URLs when real Matterport/360 tours exist. */
export const siteConfig = {
name: "Shitaye Suite Hotel",
tagline: "The Unwinding Choice",
city: "Addis Ababa",
address:
"Prime location — geo-convenient, close to key attractions and major businesses.",
phones: ["+251 96 688 4400", "+251 96 688 2200", "+251 11 46 21000"],
/** Primary number shown on FAB / quick call */
primaryPhone: "+251 96 688 4400",
email: "reservation@shitayesuitehotel.com",
/** Departments (from official site) */
departments: [
{ label: "Marketing", phones: ["+251 96 688 4400", "+251 96 688 2200"] },
{ label: "Reception", phones: ["+251 11 46 21000"] },
],
videoTourUrl: "https://www.youtube.com/watch?v=oH4hH1P7vdM",
hotelTourEmbedUrl: null as string | null,
/** Property listing (guest reviews, photos) */
bookingComReviewsUrl: "https://www.booking.com/hotel/et/shitaye-suite.html",
/**
* Lobby / lounge photo from the Booking.com gallery (same listing as above).
* Caption on Booking: living area with seating property-authentic asset.
*/
lobbyImageUrl:
"https://cf.bstatic.com/xdata/images/hotel/max1024x768/536142684.jpg?k=e550cdbc87e2b08b7fd6b261d0c719149024f47369a5f53a628fca9630631bb6&o=",
social: {
facebook: "https://www.facebook.com/shitayesuitehotel/",
twitter: "https://twitter.com/ShitayeSuite",
whatsapp: "https://wa.me/0966884400",
instagram: "https://instagram.com/shitaye_suite_hotel",
},
taxRate: 0.15,
};

48
src/lib/mocks/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",
},
];

34
tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}