feat(services): spa & gym page, mock selection, logo loading state
- Add /services with Spa-only and Gym-only offerings, filters, add/remove selection - Selection panel with subtotal and mailto request; mobile sticky bar when items selected - ShitayeLogoLoader + route loading.tsx with breathe/ring animations in globals.css - Home: replace full services grid with promo strip linking to /services - Nav/footer point to /services; include logo assets and map-related components Made-with: Cursor
This commit is contained in:
parent
0065ea5c34
commit
93f93eb087
BIN
public/images/shitaye-logo-mono.png
Normal file
BIN
public/images/shitaye-logo-mono.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
public/images/shitaye-logo.png
Normal file
BIN
public/images/shitaye-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
|
|
@ -173,3 +173,33 @@ body {
|
||||||
animation: mock3d-rotate 8s ease-in-out infinite alternate;
|
animation: mock3d-rotate 8s ease-in-out infinite alternate;
|
||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Branded route loader — /services and similar */
|
||||||
|
@keyframes shitaye-logo-breathe {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.045);
|
||||||
|
opacity: 0.94;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shitaye-logo-ring {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shitaye-logo {
|
||||||
|
animation: shitaye-logo-breathe 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shitaye-ring {
|
||||||
|
animation: shitaye-logo-ring 12s linear infinite;
|
||||||
|
}
|
||||||
|
|
|
||||||
135
src/app/page.tsx
135
src/app/page.tsx
|
|
@ -4,6 +4,7 @@ import { AmenityItem } from "@/components/AmenityItem";
|
||||||
import { BookingSearchWidget } from "@/components/BookingSearchWidget";
|
import { BookingSearchWidget } from "@/components/BookingSearchWidget";
|
||||||
import { OutletCard } from "@/components/OutletCard";
|
import { OutletCard } from "@/components/OutletCard";
|
||||||
import { RoomCard } from "@/components/RoomCard";
|
import { RoomCard } from "@/components/RoomCard";
|
||||||
|
import { GoogleMapEmbed } from "@/components/GoogleMapEmbed";
|
||||||
import { VirtualTourBlock } from "@/components/VirtualTourBlock";
|
import { VirtualTourBlock } from "@/components/VirtualTourBlock";
|
||||||
import { roomAmenities } from "@/lib/mocks/amenities";
|
import { roomAmenities } from "@/lib/mocks/amenities";
|
||||||
import { bookingStyleReviews } from "@/lib/mocks/bookingReviews";
|
import { bookingStyleReviews } from "@/lib/mocks/bookingReviews";
|
||||||
|
|
@ -64,10 +65,11 @@ export default function HomePage() {
|
||||||
Guest trust
|
Guest trust
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 font-heading text-3xl md:text-4xl">
|
<h2 className="mt-2 font-heading text-3xl md:text-4xl">
|
||||||
What guests say before they book
|
What our guests say after their stay
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-[var(--color-muted)]">
|
<p className="mt-2 max-w-2xl text-sm text-[var(--color-muted)]">
|
||||||
Real feedback from recent stays helps new visitors book directly with confidence.
|
Honest reviews from people who’ve stayed with us — so you can book directly with
|
||||||
|
confidence.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -104,41 +106,40 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mx-auto max-w-7xl px-4 py-20 md:px-8">
|
<section id="location" className="border-y border-[var(--color-border)] bg-[var(--color-surface-muted)] py-16 md:py-20">
|
||||||
<div className="grid gap-12 lg:grid-cols-2 lg:items-center">
|
<div className="mx-auto max-w-7xl px-4 md:px-8">
|
||||||
<div>
|
<div className="grid gap-10 lg:grid-cols-2 lg:items-start lg:gap-12">
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--color-primary)]">
|
<div>
|
||||||
About us
|
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--color-primary)]">
|
||||||
</p>
|
Find us
|
||||||
<h2 className="mt-3 font-heading text-3xl text-[var(--color-text)] md:text-4xl">
|
</p>
|
||||||
Geo-convenient. Unmistakably Shitaye.
|
<h2 className="mt-2 font-heading text-3xl text-[var(--color-text)] md:text-4xl">
|
||||||
</h2>
|
Location & directions
|
||||||
<p className="mt-4 leading-relaxed text-[var(--color-muted)]">
|
</h2>
|
||||||
Close to key places of attraction and major businesses or institutions — your base
|
<p className="mt-4 leading-relaxed text-[var(--color-muted)]">
|
||||||
for work, culture, and rest. Begin your journey with us.
|
{siteConfig.address}. Search “Shitaye Suite Hotel” on Google Maps or use
|
||||||
</p>
|
the map below.
|
||||||
<Link href="/#rooms" className="btn-mustard mt-6 inline-flex px-6 py-3 text-sm">
|
</p>
|
||||||
View rooms
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
</Link>
|
<a
|
||||||
</div>
|
href={siteConfig.googleMapsDirectionsUrl}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
target="_blank"
|
||||||
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl">
|
rel="noopener noreferrer"
|
||||||
<Image
|
className="btn-mustard inline-flex px-6 py-3 text-sm"
|
||||||
src={siteConfig.lobbyImageUrl}
|
>
|
||||||
alt="Shitaye Suite Hotel lobby and lounge"
|
Get directions
|
||||||
fill
|
</a>
|
||||||
className="object-cover"
|
<a
|
||||||
sizes="(max-width: 640px) 100vw, 50vw"
|
href={siteConfig.googleMapsPlaceUrl}
|
||||||
/>
|
target="_blank"
|
||||||
</div>
|
rel="noopener noreferrer"
|
||||||
<div className="relative mt-0 aspect-[4/5] overflow-hidden rounded-2xl sm:mt-12">
|
className="inline-flex items-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-3 text-sm font-semibold text-[var(--color-text)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
|
||||||
<Image
|
>
|
||||||
src="https://images.unsplash.com/photo-1631049307264-da0ec9d70304?w=800&q=80"
|
Open in Google Maps
|
||||||
alt="Luxury suite interior"
|
</a>
|
||||||
fill
|
</div>
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<GoogleMapEmbed />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -171,6 +172,29 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="services"
|
||||||
|
className="border-y border-[var(--color-border)] bg-pattern-brand-gold py-16 md:py-20"
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col items-start gap-6 px-4 md:flex-row md:items-center md:justify-between md:px-8">
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-[var(--color-primary)]">
|
||||||
|
Spa & gym
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 font-heading text-2xl text-[var(--color-text)] md:text-3xl">
|
||||||
|
Treatments & fitness sessions
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-relaxed text-[var(--color-muted)]">
|
||||||
|
Browse our spa menu and gym add-ons — build a mock selection and send a request from the
|
||||||
|
dedicated services page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/services" className="btn-mustard shrink-0 px-8 py-3.5 text-sm">
|
||||||
|
View spa & gym services
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="wellness"
|
id="wellness"
|
||||||
className="border-y border-[var(--color-border)] bg-[var(--color-surface-muted)] py-24 md:py-32"
|
className="border-y border-[var(--color-border)] bg-[var(--color-surface-muted)] py-24 md:py-32"
|
||||||
|
|
@ -294,6 +318,45 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="about" 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-heading 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="btn-mustard mt-6 inline-flex px-6 py-3 text-sm">
|
||||||
|
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 className="border-y border-[var(--color-border)] bg-[var(--color-surface)] py-16">
|
<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">
|
<div className="mx-auto max-w-7xl px-4 md:px-8">
|
||||||
<h2 className="font-heading text-2xl md:text-3xl">All rooms include</h2>
|
<h2 className="font-heading text-2xl md:text-3xl">All rooms include</h2>
|
||||||
|
|
|
||||||
283
src/app/services/ServicesPageClient.tsx
Normal file
283
src/app/services/ServicesPageClient.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
spaGymFilters,
|
||||||
|
spaGymServices,
|
||||||
|
type SpaGymFilterId,
|
||||||
|
type SpaGymService,
|
||||||
|
} from "@/lib/mocks/services";
|
||||||
|
import { siteConfig } from "@/lib/mocks/site";
|
||||||
|
|
||||||
|
function ServiceCard({
|
||||||
|
service,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
service: SpaGymService;
|
||||||
|
selected: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const kindLabel = service.kind === "spa" ? "Spa" : "Gym";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="card-lift flex flex-col overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm">
|
||||||
|
<div className="relative aspect-[16/10] overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={service.image}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
className="object-cover transition duration-500"
|
||||||
|
sizes="(max-width:640px) 100vw, (max-width:1024px) 50vw, 33vw"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||||
|
<span className="absolute left-3 top-3 rounded-full bg-[var(--color-surface)]/95 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-[var(--color-primary)] shadow-sm backdrop-blur-sm">
|
||||||
|
{kindLabel}
|
||||||
|
</span>
|
||||||
|
<span className="absolute bottom-3 right-3 rounded-full bg-[var(--color-primary)] px-3 py-1 text-xs font-bold text-[var(--color-on-primary)] shadow-md">
|
||||||
|
${service.priceUsd}
|
||||||
|
<span className="font-normal opacity-90"> · {service.priceNote}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col p-5 md:p-6">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
{service.duration}
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 font-heading text-lg font-semibold text-[var(--color-text)] md:text-xl">
|
||||||
|
{service.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 flex-1 text-sm leading-relaxed text-[var(--color-muted)]">
|
||||||
|
{service.description}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={`mt-5 w-full rounded-full border-2 border-transparent px-4 py-2.5 text-sm font-semibold transition md:mt-6 ${
|
||||||
|
selected
|
||||||
|
? "bg-[var(--color-primary)] text-[var(--color-on-primary)] shadow-md"
|
||||||
|
: "bg-[var(--color-accent-soft)] text-[var(--color-primary)] ring-1 ring-[var(--color-accent)]/40 hover:bg-[var(--color-accent)]/15"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected ? "Added — tap to remove" : "Add to selection"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectionPanel({
|
||||||
|
items,
|
||||||
|
onRemove,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
items: SpaGymService[];
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const total = useMemo(
|
||||||
|
() => items.reduce((sum, s) => sum + s.priceUsd, 0),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm md:p-6">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
|
||||||
|
Your selection
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
||||||
|
Mock basket — pick services to preview a request (no real payment).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="mt-6 rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-8 text-center text-sm text-[var(--color-muted)]">
|
||||||
|
Tap “Add to selection” on any spa or gym service to build your list.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-5 max-h-[min(320px,50vh)] space-y-3 overflow-y-auto pr-1">
|
||||||
|
{items.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-start justify-between gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold text-[var(--color-text)]">{s.title}</p>
|
||||||
|
<p className="text-xs text-[var(--color-muted)]">
|
||||||
|
{s.kind === "spa" ? "Spa" : "Gym"} · {s.duration}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="font-semibold text-[var(--color-primary)]">${s.priceUsd}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(s.id)}
|
||||||
|
className="rounded-full p-1 text-[var(--color-muted)] transition hover:bg-[var(--color-border)]/50 hover:text-[var(--color-text)]"
|
||||||
|
aria-label={`Remove ${s.title}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="mt-5 border-t border-[var(--color-border)] pt-4">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-[var(--color-muted)]">Subtotal (mock)</span>
|
||||||
|
<span className="font-heading text-xl font-semibold text-[var(--color-text)]">
|
||||||
|
${total.toFixed(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
|
<a
|
||||||
|
href={`mailto:${siteConfig.email}?subject=Spa%20%26%20Gym%20request&body=${encodeURIComponent(
|
||||||
|
`Selected services:\n${items.map((s) => `- ${s.title} ($${s.priceUsd})`).join("\n")}\n\nTotal (estimate): $${total}`,
|
||||||
|
)}`}
|
||||||
|
className="btn-mustard px-4 py-3 text-center text-sm"
|
||||||
|
>
|
||||||
|
Email request
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="rounded-full border border-[var(--color-border)] py-2.5 text-sm font-semibold text-[var(--color-muted)] transition hover:bg-[var(--color-surface-muted)]"
|
||||||
|
>
|
||||||
|
Clear selection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServicesPageClient() {
|
||||||
|
const [filter, setFilter] = useState<SpaGymFilterId>("all");
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (filter === "all") return spaGymServices;
|
||||||
|
return spaGymServices.filter((s) => s.kind === filter);
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
const selectedItems = useMemo(
|
||||||
|
() => spaGymServices.filter((s) => selected.has(s.id)),
|
||||||
|
[selected],
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
setSelected(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--color-bg)]">
|
||||||
|
<section className="border-b border-[var(--color-border)] bg-pattern-brand-gold py-12 md:py-16">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 md:px-8">
|
||||||
|
<nav className="text-xs font-medium text-[var(--color-muted)]">
|
||||||
|
<Link href="/" className="transition hover:text-[var(--color-accent)]">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2 opacity-50">/</span>
|
||||||
|
<span className="text-[var(--color-text)]">Spa & gym</span>
|
||||||
|
</nav>
|
||||||
|
<h1 className="mt-4 font-heading text-3xl font-semibold tracking-tight text-[var(--color-text)] md:text-4xl lg:text-[2.35rem]">
|
||||||
|
Spa & gym services
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-[var(--color-muted)] md:text-base">
|
||||||
|
Choose treatments and gym sessions — your selection is shown on the right (desktop) or
|
||||||
|
below on mobile. This is a demo flow; confirm times and pricing at the desk.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-[var(--color-muted)]">
|
||||||
|
Taxes and service charges may apply. Prices shown in USD (mock).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className={`mx-auto max-w-7xl px-4 py-10 md:px-8 md:py-14 ${selectedItems.length > 0 ? "pb-28 lg:pb-14" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2 md:justify-start md:gap-2.5">
|
||||||
|
{spaGymFilters.map((f) => {
|
||||||
|
const active = filter === f.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter(f.id)}
|
||||||
|
className={`rounded-full border px-4 py-2 text-xs font-semibold transition md:px-5 md:text-sm ${
|
||||||
|
active
|
||||||
|
? "border-[var(--color-primary)] bg-[var(--color-primary)] text-[var(--color-on-primary)] shadow-md"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:border-[var(--color-primary)]/40 hover:bg-[var(--color-surface-muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px] lg:items-start lg:gap-12">
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
|
{filtered.map((service) => (
|
||||||
|
<ServiceCard
|
||||||
|
key={service.id}
|
||||||
|
service={service}
|
||||||
|
selected={selected.has(service.id)}
|
||||||
|
onToggle={() => toggle(service.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="lg:sticky lg:top-28">
|
||||||
|
<SelectionPanel items={selectedItems} onRemove={remove} onClear={clear} />
|
||||||
|
<Link
|
||||||
|
href="/#wellness"
|
||||||
|
className="mt-4 block text-center text-sm font-semibold text-[var(--color-primary)] underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
← Back to hotel wellness overview
|
||||||
|
</Link>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile sticky summary bar */}
|
||||||
|
{selectedItems.length > 0 ? (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-30 border-t border-[var(--color-border)] bg-[var(--color-surface)]/95 p-4 shadow-[0_-8px_30px_rgba(0,0,0,0.08)] backdrop-blur-md lg:hidden">
|
||||||
|
<div className="mx-auto flex max-w-lg items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
<span className="font-semibold text-[var(--color-text)]">{selectedItems.length}</span>{" "}
|
||||||
|
selected
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={`mailto:${siteConfig.email}?subject=Spa%20%26%20Gym%20request`}
|
||||||
|
className="btn-mustard shrink-0 px-5 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
Email request
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/app/services/loading.tsx
Normal file
9
src/app/services/loading.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { ShitayeLogoLoader } from "@/components/ShitayeLogoLoader";
|
||||||
|
|
||||||
|
export default function ServicesLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[min(70vh,560px)] flex-col items-center justify-center bg-[var(--color-bg)] px-4 py-20">
|
||||||
|
<ShitayeLogoLoader label="Preparing spa & gym…" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/services/page.tsx
Normal file
12
src/app/services/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { ServicesPageClient } from "./ServicesPageClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Spa & gym services",
|
||||||
|
description:
|
||||||
|
"Browse spa treatments and gym sessions at Shitaye Suite Hotel — build a mock selection and send a request.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ServicesPage() {
|
||||||
|
return <ServicesPageClient />;
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { siteConfig } from "@/lib/mocks/site";
|
import { siteConfig } from "@/lib/mocks/site";
|
||||||
|
|
||||||
|
|
@ -10,9 +11,25 @@ export function Footer() {
|
||||||
<div className="mx-auto max-w-7xl px-4 py-16 md:px-8">
|
<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="grid gap-12 md:grid-cols-2 lg:grid-cols-12">
|
||||||
<div className="lg:col-span-4">
|
<div className="lg:col-span-4">
|
||||||
<p className="font-heading text-2xl text-white">{siteConfig.name}</p>
|
<Image
|
||||||
<p className="mt-2 text-sm text-stone-300">{siteConfig.address}</p>
|
src="/images/shitaye-logo-mono.png"
|
||||||
<p className="mt-4 text-sm text-stone-300">{siteConfig.city}, Ethiopia</p>
|
alt="Shitaye Suite Hotel"
|
||||||
|
width={400}
|
||||||
|
height={333}
|
||||||
|
className="h-12 w-auto max-w-[240px] brightness-0 invert sm:h-14"
|
||||||
|
/>
|
||||||
|
<p className="mt-5 text-sm leading-relaxed text-stone-300">{siteConfig.address}</p>
|
||||||
|
<p className="mt-2 text-sm text-stone-300">{siteConfig.city}, Ethiopia</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
<a
|
||||||
|
href={siteConfig.googleMapsDirectionsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm font-semibold text-[var(--color-accent)] underline-offset-4 transition hover:text-white hover:underline"
|
||||||
|
>
|
||||||
|
Directions on Google Maps
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-3">
|
<div className="lg:col-span-3">
|
||||||
|
|
@ -128,6 +145,11 @@ export function Footer() {
|
||||||
Rooms
|
Rooms
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/services" className="text-stone-200 hover:text-white">
|
||||||
|
Spa & gym services
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/#wellness" className="text-stone-200 hover:text-white">
|
<Link href="/#wellness" className="text-stone-200 hover:text-white">
|
||||||
Gym & Spa
|
Gym & Spa
|
||||||
|
|
@ -138,6 +160,11 @@ export function Footer() {
|
||||||
Dining
|
Dining
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/#location" className="text-stone-200 hover:text-white">
|
||||||
|
Location & map
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/meetings/serenity" className="text-stone-200 hover:text-white">
|
<Link href="/meetings/serenity" className="text-stone-200 hover:text-white">
|
||||||
Serenity meeting room
|
Serenity meeting room
|
||||||
|
|
|
||||||
22
src/components/GoogleMapEmbed.tsx
Normal file
22
src/components/GoogleMapEmbed.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { siteConfig } from "@/lib/mocks/site";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Maps embed (search result for the hotel). Uses the same pattern as
|
||||||
|
* Maps “Share → Embed” without requiring an API key.
|
||||||
|
*/
|
||||||
|
export function GoogleMapEmbed({ className = "" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative aspect-[16/10] w-full overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] shadow-sm md:aspect-[21/9] ${className}`}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
title="Shitaye Suite Hotel — Google Maps"
|
||||||
|
src={siteConfig.googleMapsEmbedUrl}
|
||||||
|
className="absolute inset-0 h-full w-full border-0"
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CurrencySwitcher } from "@/components/CurrencySwitcher";
|
import { CurrencySwitcher } from "@/components/CurrencySwitcher";
|
||||||
import { ReviewsMenu } from "@/components/ReviewsMenu";
|
import { ReviewsMenu } from "@/components/ReviewsMenu";
|
||||||
|
|
@ -5,9 +6,11 @@ import { siteConfig } from "@/lib/mocks/site";
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ href: "/#rooms", label: "Rooms" },
|
{ href: "/#rooms", label: "Rooms" },
|
||||||
|
{ href: "/services", label: "Services" },
|
||||||
{ href: "/#wellness", label: "Gym & Spa" },
|
{ href: "/#wellness", label: "Gym & Spa" },
|
||||||
{ href: "/#dining", label: "Dining & venues" },
|
{ href: "/#dining", label: "Dining & venues" },
|
||||||
{ href: "/#meetings", label: "Meetings" },
|
{ href: "/#meetings", label: "Meetings" },
|
||||||
|
{ href: "/#location", label: "Location" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
|
|
@ -30,12 +33,25 @@ export function Header() {
|
||||||
</div>
|
</div>
|
||||||
<div className="border-b border-[var(--color-border)] bg-[var(--color-surface)]/95 backdrop-blur-md">
|
<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">
|
<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">
|
<Link
|
||||||
<span className="font-nav text-lg tracking-tight text-[var(--color-primary)] sm:text-xl md:text-2xl">
|
href="/"
|
||||||
{siteConfig.name}
|
className="group flex min-w-0 shrink items-center gap-2.5 leading-tight sm:gap-3"
|
||||||
</span>
|
>
|
||||||
<span className="text-[10px] font-medium uppercase tracking-[0.2em] text-[var(--color-muted)] sm:text-[11px]">
|
<Image
|
||||||
{siteConfig.city}
|
src="/images/shitaye-logo.png"
|
||||||
|
alt=""
|
||||||
|
width={400}
|
||||||
|
height={390}
|
||||||
|
className="h-9 w-auto shrink-0 sm:h-10 md:h-11"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="font-nav 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>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav
|
<nav
|
||||||
|
|
|
||||||
44
src/components/ShitayeLogoLoader.tsx
Normal file
44
src/components/ShitayeLogoLoader.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-brand loading state — Shitaye mark with soft pulse + slow ring.
|
||||||
|
*/
|
||||||
|
export function ShitayeLogoLoader({ label = "Loading…", className = "" }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col items-center justify-center gap-6 ${className}`}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<div className="relative flex h-32 w-32 items-center justify-center md:h-36 md:w-36">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-full bg-[var(--color-accent)]/12"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="animate-shitaye-ring absolute inset-1 rounded-full border border-dashed border-[var(--color-primary)]/30"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-3 rounded-full border border-[var(--color-accent)]/25"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src="/images/shitaye-logo.png"
|
||||||
|
alt=""
|
||||||
|
width={400}
|
||||||
|
height={390}
|
||||||
|
className="animate-shitaye-logo relative z-10 h-[4.5rem] w-auto drop-shadow-sm md:h-[5.25rem]"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium tracking-wide text-[var(--color-muted)]">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/lib/mocks/services.ts
Normal file
108
src/lib/mocks/services.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
/**
|
||||||
|
* Bookable Spa & Gym offerings for the dedicated /services page (mock pricing).
|
||||||
|
*/
|
||||||
|
export type SpaGymKind = "spa" | "gym";
|
||||||
|
|
||||||
|
export type SpaGymService = {
|
||||||
|
id: string;
|
||||||
|
kind: SpaGymKind;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
duration: string;
|
||||||
|
priceUsd: number;
|
||||||
|
/** Shown on card badge, e.g. "per session" */
|
||||||
|
priceNote: string;
|
||||||
|
image: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const spaGymFilterIds = ["all", "spa", "gym"] as const;
|
||||||
|
export type SpaGymFilterId = (typeof spaGymFilterIds)[number];
|
||||||
|
|
||||||
|
export const spaGymFilters: { id: SpaGymFilterId; label: string }[] = [
|
||||||
|
{ id: "all", label: "All" },
|
||||||
|
{ id: "spa", label: "Spa" },
|
||||||
|
{ id: "gym", label: "Gym" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const spaGymServices: SpaGymService[] = [
|
||||||
|
{
|
||||||
|
id: "gym-day-pass",
|
||||||
|
kind: "gym",
|
||||||
|
title: "Fitness day pass",
|
||||||
|
description: "Full access to cardio, weights, and stretch zones for one calendar day.",
|
||||||
|
duration: "All day · 6:00 — 22:00",
|
||||||
|
priceUsd: 18,
|
||||||
|
priceNote: "per guest / day",
|
||||||
|
image: "https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=900&q=80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gym-pt",
|
||||||
|
kind: "gym",
|
||||||
|
title: "Personal training",
|
||||||
|
description: "One-on-one session tailored to your goals — form, intensity, and recovery.",
|
||||||
|
duration: "45 minutes",
|
||||||
|
priceUsd: 55,
|
||||||
|
priceNote: "per session",
|
||||||
|
image: "https://images.unsplash.com/photo-1571019614242-c5c5dee9f50b?w=900&q=80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gym-hiit",
|
||||||
|
kind: "gym",
|
||||||
|
title: "Small-group HIIT",
|
||||||
|
description: "High-energy class in our studio — limited spots, hotel guests priority.",
|
||||||
|
duration: "50 minutes",
|
||||||
|
priceUsd: 28,
|
||||||
|
priceNote: "per class",
|
||||||
|
image: "https://images.unsplash.com/photo-1517836357463-d25dfeac3438?w=900&q=80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "spa-swedish",
|
||||||
|
kind: "spa",
|
||||||
|
title: "Signature Swedish massage",
|
||||||
|
description: "Long, flowing strokes to ease travel tension and improve circulation.",
|
||||||
|
duration: "60 minutes",
|
||||||
|
priceUsd: 85,
|
||||||
|
priceNote: "per treatment",
|
||||||
|
image: "https://images.unsplash.com/photo-1544161515-4ab6ce6db874?w=900&q=80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "spa-deep",
|
||||||
|
kind: "spa",
|
||||||
|
title: "Deep tissue therapy",
|
||||||
|
description: "Targeted work for shoulders, back, and legs after long flights.",
|
||||||
|
duration: "90 minutes",
|
||||||
|
priceUsd: 125,
|
||||||
|
priceNote: "per treatment",
|
||||||
|
image: "https://images.unsplash.com/photo-1600334129128-0c9b275703e6?w=900&q=80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "spa-express",
|
||||||
|
kind: "spa",
|
||||||
|
title: "Express back & neck",
|
||||||
|
description: "Focused relief when you’re between meetings — clothes-on option.",
|
||||||
|
duration: "30 minutes",
|
||||||
|
priceUsd: 52,
|
||||||
|
priceNote: "per treatment",
|
||||||
|
image: "https://images.unsplash.com/photo-1519823551278-64ac92734fb1?w=900&q=80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "spa-aroma",
|
||||||
|
kind: "spa",
|
||||||
|
title: "Aromatherapy ritual",
|
||||||
|
description: "Custom oil blend, warm compress, and full-body massage sequence.",
|
||||||
|
duration: "75 minutes",
|
||||||
|
priceUsd: 98,
|
||||||
|
priceNote: "per treatment",
|
||||||
|
image: "https://images.unsplash.com/photo-1540555700478-4be289fbecef?w=900&q=80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "spa-couples",
|
||||||
|
kind: "spa",
|
||||||
|
title: "Couples’ suite ritual",
|
||||||
|
description: "Side-by-side massage in our private suite — sparkling water included.",
|
||||||
|
duration: "90 minutes",
|
||||||
|
priceUsd: 220,
|
||||||
|
priceNote: "per couple",
|
||||||
|
image: "https://images.unsplash.com/photo-1600334089648-b0d9d3028eb2?w=900&q=80",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -3,8 +3,16 @@ export const siteConfig = {
|
||||||
name: "Shitaye Suite Hotel",
|
name: "Shitaye Suite Hotel",
|
||||||
tagline: "The Unwinding Choice",
|
tagline: "The Unwinding Choice",
|
||||||
city: "Addis Ababa",
|
city: "Addis Ababa",
|
||||||
address:
|
address: "Ethio China Street, Kirkos, Addis Ababa, Ethiopia",
|
||||||
"Prime location — geo-convenient, close to key attractions and major businesses.",
|
/** Google Maps — search result embed (hotel place). */
|
||||||
|
googleMapsEmbedUrl:
|
||||||
|
"https://www.google.com/maps?q=Shitaye+Suite+Hotel+Addis+Ababa+Ethiopia&output=embed&z=16",
|
||||||
|
/** Opens Google Maps with directions to the hotel (destination preset). */
|
||||||
|
googleMapsDirectionsUrl:
|
||||||
|
"https://www.google.com/maps/dir/?api=1&destination=Shitaye+Suite+Hotel+Ethio+China+Street+Kirkos+Addis+Ababa+Ethiopia",
|
||||||
|
/** Place search — opens the hotel pin in Google Maps (not directions mode). */
|
||||||
|
googleMapsPlaceUrl:
|
||||||
|
"https://www.google.com/maps/search/?api=1&query=Shitaye+Suite+Hotel+Addis+Ababa+Ethiopia",
|
||||||
phones: ["+251 96 688 4400", "+251 96 688 2200", "+251 11 46 21000"],
|
phones: ["+251 96 688 4400", "+251 96 688 2200", "+251 11 46 21000"],
|
||||||
/** Primary number shown on FAB / quick call */
|
/** Primary number shown on FAB / quick call */
|
||||||
primaryPhone: "+251 96 688 4400",
|
primaryPhone: "+251 96 688 4400",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user