Add Shitaye Suite Hotel Next.js frontend — booking flow, rooms, meetings, wellness, currency, and UI
Made-with: Cursor
This commit is contained in:
parent
4fe65354b0
commit
0b7c0fcd2b
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal 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
18
eslint.config.mjs
Normal 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
13
next.config.ts
Normal 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
6701
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal 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
7
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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 |
236
src/app/booking/BookingPageClient.tsx
Normal file
236
src/app/booking/BookingPageClient.tsx
Normal 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'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'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
18
src/app/booking/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/app/confirmation/page.tsx
Normal file
101
src/app/confirmation/page.tsx
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
72
src/app/globals.css
Normal file
72
src/app/globals.css
Normal 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
47
src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/app/meetings/[slug]/not-found.tsx
Normal file
15
src/app/meetings/[slug]/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
src/app/meetings/[slug]/page.tsx
Normal file
153
src/app/meetings/[slug]/page.tsx
Normal 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
270
src/app/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
236
src/app/payment/PaymentPageClient.tsx
Normal file
236
src/app/payment/PaymentPageClient.tsx
Normal 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'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
10
src/app/payment/page.tsx
Normal 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
13
src/app/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/app/reserve-held/page.tsx
Normal file
112
src/app/reserve-held/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
18
src/app/rooms/[slug]/not-found.tsx
Normal file
18
src/app/rooms/[slug]/not-found.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
159
src/app/rooms/[slug]/page.tsx
Normal file
159
src/app/rooms/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/AmenityItem.tsx
Normal file
48
src/components/AmenityItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/components/BookRoomButton.tsx
Normal file
27
src/components/BookRoomButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/components/BookingSearchWidget.tsx
Normal file
82
src/components/BookingSearchWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
src/components/CallUsFab.tsx
Normal file
68
src/components/CallUsFab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/CurrencySwitcher.tsx
Normal file
26
src/components/CurrencySwitcher.tsx
Normal 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
185
src/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/components/FormattedUsd.tsx
Normal file
21
src/components/FormattedUsd.tsx
Normal 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
67
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/components/MeetingHalfDayRate.tsx
Normal file
14
src/components/MeetingHalfDayRate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/Mock3DPlaceholder.tsx
Normal file
104
src/components/Mock3DPlaceholder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/components/OutletCard.tsx
Normal file
63
src/components/OutletCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
325
src/components/ReviewsMenu.tsx
Normal file
325
src/components/ReviewsMenu.tsx
Normal 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.com–style 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>
|
||||
);
|
||||
}
|
||||
53
src/components/RoomCard.tsx
Normal file
53
src/components/RoomCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/components/RoomSelectBooking.tsx
Normal file
112
src/components/RoomSelectBooking.tsx
Normal 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
14
src/components/Shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/VirtualTourBlock.tsx
Normal file
28
src/components/VirtualTourBlock.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
src/components/VirtualTourEmbed.tsx
Normal file
24
src/components/VirtualTourEmbed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
363
src/components/icons/AmenityIcon.tsx
Normal file
363
src/components/icons/AmenityIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
218
src/context/BookingContext.tsx
Normal file
218
src/context/BookingContext.tsx
Normal 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;
|
||||
}
|
||||
87
src/context/CurrencyContext.tsx
Normal file
87
src/context/CurrencyContext.tsx
Normal 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
39
src/lib/currency.ts
Normal 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);
|
||||
}
|
||||
11
src/lib/formatArrivalTime.ts
Normal file
11
src/lib/formatArrivalTime.ts
Normal 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" });
|
||||
}
|
||||
21
src/lib/mocks/amenities.ts
Normal file
21
src/lib/mocks/amenities.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { AmenityIconId } from "@/components/icons/AmenityIcon";
|
||||
|
||||
export type AmenityWithIcon = {
|
||||
icon: AmenityIconId;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const roomAmenities: AmenityWithIcon[] = [
|
||||
{ icon: "breakfast", label: "B/B Fast" },
|
||||
{ icon: "shuttle", label: "Shuttle" },
|
||||
{ icon: "wifi", label: "Wi‑Fi / LAN" },
|
||||
{ icon: "sparkle", label: "Premium amenities" },
|
||||
{ icon: "tv", label: "IPTV" },
|
||||
{ icon: "kitchen", label: "State of the art kitchenette" },
|
||||
{ icon: "views", label: "Amazing views" },
|
||||
{ icon: "minibar", label: "Mini bar" },
|
||||
{ icon: "lock", label: "Safe boxes" },
|
||||
{ icon: "iron", label: "Iron & board" },
|
||||
{ icon: "router", label: "Private routers" },
|
||||
{ icon: "laundry", label: "Laundry (paid services)" },
|
||||
];
|
||||
41
src/lib/mocks/api.ts
Normal file
41
src/lib/mocks/api.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
62
src/lib/mocks/bookingReviews.ts
Normal file
62
src/lib/mocks/bookingReviews.ts
Normal 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 — we’ll 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 Wi‑Fi, 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;
|
||||
86
src/lib/mocks/meetingSpaces.ts
Normal file
86
src/lib/mocks/meetingSpaces.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { AmenityWithIcon } from "./amenities";
|
||||
|
||||
export type MeetingSpace = {
|
||||
slug: string;
|
||||
name: string;
|
||||
shortDescription: string;
|
||||
longDescription: string;
|
||||
capacity: string;
|
||||
floor: string;
|
||||
image: string;
|
||||
gallery: string[];
|
||||
amenities: AmenityWithIcon[];
|
||||
layouts: string[];
|
||||
catering: string[];
|
||||
/** Mock half-day rate in USD for display (converted via currency switcher) */
|
||||
halfDayRateUsd: number;
|
||||
};
|
||||
|
||||
export const meetingSpaces: MeetingSpace[] = [
|
||||
{
|
||||
slug: "serenity",
|
||||
name: "Serenity Meeting Room",
|
||||
shortDescription: "Versatile event space for up to 100 guests on the 1st floor.",
|
||||
longDescription:
|
||||
"Serenity is designed for board sessions, cocktail receptions, and medium-scale corporate events. Natural light options, flexible seating, and dedicated support for AV and catering make it the hotel’s flagship meeting venue.",
|
||||
capacity: "Up to 100 guests (theatre / cocktail configurations)",
|
||||
floor: "1st floor",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200&q=80",
|
||||
gallery: [
|
||||
"https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200&q=80",
|
||||
"https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=1200&q=80",
|
||||
"https://images.unsplash.com/photo-1511578314322-379afb476865?w=1200&q=80",
|
||||
],
|
||||
amenities: [
|
||||
{ icon: "wifi", label: "High-speed Wi‑Fi / LAN" },
|
||||
{ icon: "projector", label: "Projector & screen" },
|
||||
{ icon: "microphone", label: "Wireless microphones" },
|
||||
{ icon: "clipboard", label: "Flip charts & stationery" },
|
||||
{ icon: "thermometer", label: "Climate control" },
|
||||
{ icon: "handshake", label: "Dedicated event coordinator (on request)" },
|
||||
{ icon: "doorOpen", label: "Breakout foyer access" },
|
||||
{ icon: "accessibility", label: "Accessible routes" },
|
||||
],
|
||||
layouts: ["Boardroom", "U-shape", "Theatre", "Classroom", "Cocktail / standing"],
|
||||
catering: ["Buffet menus", "Tea & coffee breaks", "Working lunch packages", "Gala dinner (via FeastVille)"],
|
||||
halfDayRateUsd: 850,
|
||||
},
|
||||
{
|
||||
slug: "fasika",
|
||||
name: "Fasika Board Room",
|
||||
shortDescription: "Executive board room for 25–30 guests — intimate and fully equipped.",
|
||||
longDescription:
|
||||
"Fasika offers privacy and polish for leadership offsites, signing ceremonies, and focused workshops. Sound-treated walls, ergonomic seating, and premium coffee service keep sessions productive.",
|
||||
capacity: "25–30 guests (boardroom style)",
|
||||
floor: "1st floor",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1560179707-f14e90ef3623?w=1200&q=80",
|
||||
gallery: [
|
||||
"https://images.unsplash.com/photo-1560179707-f14e90ef3623?w=1200&q=80",
|
||||
"https://images.unsplash.com/photo-1542744173-8e7e53415bb0?w=1200&q=80",
|
||||
"https://images.unsplash.com/photo-1600880292203-757bb62b4baf?w=1200&q=80",
|
||||
],
|
||||
amenities: [
|
||||
{ icon: "monitor", label: "4K display & HDMI / USB-C" },
|
||||
{ icon: "video", label: "Video-conferencing ready" },
|
||||
{ icon: "chair", label: "Executive leather seating" },
|
||||
{ icon: "volumeMuted", label: "Sound dampening" },
|
||||
{ icon: "restroom", label: "Private washroom adjacency" },
|
||||
{ icon: "pen", label: "Notepads & pens" },
|
||||
{ icon: "droplet", label: "Complimentary mineral water" },
|
||||
{ icon: "phone", label: "Dedicated phone line (on request)" },
|
||||
],
|
||||
layouts: ["Boardroom", "Interview (2–4 pax)", "Small workshop"],
|
||||
catering: ["Executive breakfast", "Coffee & pastries", "Light lunch boxes"],
|
||||
halfDayRateUsd: 420,
|
||||
},
|
||||
];
|
||||
|
||||
export function getMeetingSpaceBySlug(slug: string): MeetingSpace | undefined {
|
||||
return meetingSpaces.find((m) => m.slug === slug);
|
||||
}
|
||||
|
||||
export function getAllMeetingSlugs(): string[] {
|
||||
return meetingSpaces.map((m) => m.slug);
|
||||
}
|
||||
78
src/lib/mocks/outlets.ts
Normal file
78
src/lib/mocks/outlets.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
export type Outlet = {
|
||||
slug: string;
|
||||
name: string;
|
||||
tagline: string;
|
||||
bullets: string[];
|
||||
image: string;
|
||||
floor?: string;
|
||||
/** Link to detail page when set (e.g. meeting rooms) */
|
||||
detailHref?: string;
|
||||
};
|
||||
|
||||
export const outlets: Outlet[] = [
|
||||
{
|
||||
slug: "feastville",
|
||||
name: "FeastVille Restaurant",
|
||||
tagline: "Full American breakfast to theme nights — savour every moment.",
|
||||
bullets: [
|
||||
"Full American breakfast",
|
||||
"Traditional & international menu",
|
||||
"Theme nights selection",
|
||||
"Room service menu",
|
||||
],
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=1200&q=80",
|
||||
},
|
||||
{
|
||||
slug: "central-cafe",
|
||||
name: "Central Cafe",
|
||||
tagline: "Purely urban vibes — coffee at the heart of the city.",
|
||||
bullets: [
|
||||
"Your perfect rendezvous in the city centre",
|
||||
"Ideal to initiate, elevate & conclude your day",
|
||||
],
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?w=1200&q=80",
|
||||
},
|
||||
{
|
||||
slug: "tabsia",
|
||||
name: "TABSIA Bar",
|
||||
tagline: "Cocktails, spirits, and a refined atmosphere.",
|
||||
bullets: [
|
||||
"Located on the 1st floor",
|
||||
"Cocktails, spirits & more",
|
||||
"Unwind after a long day",
|
||||
],
|
||||
floor: "1st floor",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1551024506-0bccd828d307?w=1200&q=80&auto=format&fit=crop",
|
||||
},
|
||||
{
|
||||
slug: "serenity",
|
||||
name: "Serenity Meeting Room",
|
||||
tagline: "Board meetings, cocktails, and events up to 100 guests.",
|
||||
bullets: [
|
||||
"Up to 100 pax",
|
||||
"Fully equipped with basics & stationeries",
|
||||
"Buffet or tea break menus",
|
||||
],
|
||||
floor: "1st floor",
|
||||
detailHref: "/meetings/serenity",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200&q=80",
|
||||
},
|
||||
{
|
||||
slug: "fasika",
|
||||
name: "Fasika Board Room",
|
||||
tagline: "Intimate executive sessions for 25–30 guests.",
|
||||
bullets: [
|
||||
"25–30 pax",
|
||||
"Board & cocktail setups",
|
||||
"Equipment & catering options",
|
||||
],
|
||||
floor: "1st floor",
|
||||
detailHref: "/meetings/fasika",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1560179707-f14e90ef3623?w=1200&q=80",
|
||||
},
|
||||
];
|
||||
103
src/lib/mocks/rooms.ts
Normal file
103
src/lib/mocks/rooms.ts
Normal 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 Wi‑Fi / 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 Wi‑Fi, 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
34
src/lib/mocks/site.ts
Normal 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
48
src/lib/mocks/wellness.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { AmenityWithIcon } from "./amenities";
|
||||
|
||||
export type WellnessFacility = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
image: string;
|
||||
amenities: AmenityWithIcon[];
|
||||
hours: string;
|
||||
};
|
||||
|
||||
export const wellnessFacilities: WellnessFacility[] = [
|
||||
{
|
||||
id: "gym",
|
||||
title: "Fitness centre",
|
||||
subtitle: "Train on your schedule",
|
||||
description:
|
||||
"Cardio machines, free weights, and functional training space — maintained daily and stocked with fresh towels and chilled water. Perfect before meetings or after long flights.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=1200&q=80",
|
||||
amenities: [
|
||||
{ icon: "treadmill", label: "Treadmills & ellipticals" },
|
||||
{ icon: "dumbbell", label: "Dumbbells & kettlebells" },
|
||||
{ icon: "stretch", label: "Stretching zone" },
|
||||
{ icon: "towel", label: "Towel service" },
|
||||
{ icon: "headphones", label: "Bluetooth audio (personal headsets)" },
|
||||
],
|
||||
hours: "6:00 — 22:00 daily",
|
||||
},
|
||||
{
|
||||
id: "spa",
|
||||
title: "Spa & wellness",
|
||||
subtitle: "Restore and unwind",
|
||||
description:
|
||||
"Therapeutic massages, express treatments, and calming lounges inspired by Ethiopian botanicals. Book ahead for couples’ rituals or post-event recovery sessions.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1540555700478-4be289fbecef?w=1200&q=80",
|
||||
amenities: [
|
||||
{ icon: "massage", label: "Signature massage menu" },
|
||||
{ icon: "steam", label: "Steam experience (select days)" },
|
||||
{ icon: "leaf", label: "Aromatherapy add-ons" },
|
||||
{ icon: "lounge", label: "Private treatment suites" },
|
||||
{ icon: "boutique", label: "Retail boutique" },
|
||||
],
|
||||
hours: "10:00 — 20:00 · appointments recommended",
|
||||
},
|
||||
];
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user