From aba29922c74eb2486c6d773ab3c3206c8f5f2cf3 Mon Sep 17 00:00:00 2001 From: brooktewabe Date: Mon, 13 Apr 2026 17:06:04 +0300 Subject: [PATCH 1/9] removed dummy data --- src/lib/mocks/amenities.ts | 21 ------ src/lib/mocks/api.ts | 41 ------------ src/lib/mocks/bookingReviews.ts | 62 ------------------ src/lib/mocks/guestData.ts | 88 ------------------------- src/lib/mocks/laundryCatalog.ts | 45 ------------- src/lib/mocks/meetingSpaces.ts | 86 ------------------------ src/lib/mocks/outlets.ts | 78 ---------------------- src/lib/mocks/roomServiceMenu.ts | 68 ------------------- src/lib/mocks/rooms.ts | 103 ----------------------------- src/lib/mocks/services.ts | 108 ------------------------------- src/lib/mocks/site.ts | 42 ------------ src/lib/mocks/wellness.ts | 48 -------------- 12 files changed, 790 deletions(-) delete mode 100644 src/lib/mocks/amenities.ts delete mode 100644 src/lib/mocks/api.ts delete mode 100644 src/lib/mocks/bookingReviews.ts delete mode 100644 src/lib/mocks/guestData.ts delete mode 100644 src/lib/mocks/laundryCatalog.ts delete mode 100644 src/lib/mocks/meetingSpaces.ts delete mode 100644 src/lib/mocks/outlets.ts delete mode 100644 src/lib/mocks/roomServiceMenu.ts delete mode 100644 src/lib/mocks/rooms.ts delete mode 100644 src/lib/mocks/services.ts delete mode 100644 src/lib/mocks/site.ts delete mode 100644 src/lib/mocks/wellness.ts diff --git a/src/lib/mocks/amenities.ts b/src/lib/mocks/amenities.ts deleted file mode 100644 index 91ed432..0000000 --- a/src/lib/mocks/amenities.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { AmenityIconId } from "@/components/icons/AmenityIcon"; - -export type AmenityWithIcon = { - icon: AmenityIconId; - label: string; -}; - -export const roomAmenities: AmenityWithIcon[] = [ - { icon: "breakfast", label: "B/B Fast" }, - { icon: "shuttle", label: "Shuttle" }, - { icon: "wifi", label: "Wi‑Fi / LAN" }, - { icon: "sparkle", label: "Premium amenities" }, - { icon: "tv", label: "IPTV" }, - { icon: "kitchen", label: "State of the art kitchenette" }, - { icon: "views", label: "Amazing views" }, - { icon: "minibar", label: "Mini bar" }, - { icon: "lock", label: "Safe boxes" }, - { icon: "iron", label: "Iron & board" }, - { icon: "router", label: "Private routers" }, - { icon: "laundry", label: "Laundry (paid services)" }, -]; diff --git a/src/lib/mocks/api.ts b/src/lib/mocks/api.ts deleted file mode 100644 index 826e6bf..0000000 --- a/src/lib/mocks/api.ts +++ /dev/null @@ -1,41 +0,0 @@ -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(), - }; -} diff --git a/src/lib/mocks/bookingReviews.ts b/src/lib/mocks/bookingReviews.ts deleted file mode 100644 index 693d270..0000000 --- a/src/lib/mocks/bookingReviews.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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; diff --git a/src/lib/mocks/guestData.ts b/src/lib/mocks/guestData.ts deleted file mode 100644 index 7b52f3e..0000000 --- a/src/lib/mocks/guestData.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** Demo booking references — any guest can use these in mock mode. */ -export const DEMO_BOOKING_REFS: Record< - string, - { guestName: string; room: string; checkOut: string } -> = { - "SHITAYE-2026-DEMO": { - guestName: "Demo Guest", - room: "Junior Studio · 1204", - checkOut: "2026-04-12", - }, - "GUEST-1234": { - guestName: "Abebe T.", - room: "Standard King · 805", - checkOut: "2026-04-09", - }, -}; - -export type MockAppointment = { - id: string; - title: string; - when: string; - where: string; - status: "confirmed" | "pending"; -}; - -export type MockShuttle = { - /** ISO date */ - departureDate: string; - /** e.g. "04:15" */ - lobbyPickupTime: string; - /** e.g. "Bole International (ADD)" */ - airport: string; - flightLabel: string; - notes: string; -}; - -export type MockReward = { - id: string; - label: string; - points: number; - earnedAt: string; -}; - -export const seedAppointments: MockAppointment[] = [ - { - id: "a1", - title: "Deep tissue massage", - when: "Today · 16:30", - where: "Spa · Treatment suite B", - status: "confirmed", - }, - { - id: "a2", - title: "Small-group HIIT", - when: "Tomorrow · 07:00", - where: "Fitness centre · Studio", - status: "confirmed", - }, -]; - -export const seedShuttle: MockShuttle = { - departureDate: "2026-04-11", - lobbyPickupTime: "04:15", - airport: "Bole International (ADD)", - flightLabel: "ET 302 · Addis → Frankfurt", - notes: "Please be in the lobby 10 minutes early. Shuttle is complimentary for this stay.", -}; - -export const seedRewardsHistory: MockReward[] = [ - { - id: "r1", - label: "Welcome bonus — direct booking", - points: 500, - earnedAt: "2026-04-01", - }, - { - id: "r2", - label: "Room service order", - points: 40, - earnedAt: "2026-04-03", - }, - { - id: "r3", - label: "Spa visit", - points: 120, - earnedAt: "2026-04-04", - }, -]; diff --git a/src/lib/mocks/laundryCatalog.ts b/src/lib/mocks/laundryCatalog.ts deleted file mode 100644 index ae5829a..0000000 --- a/src/lib/mocks/laundryCatalog.ts +++ /dev/null @@ -1,45 +0,0 @@ -export type LaundryItem = { - id: string; - name: string; - description: string; - priceUsd: number; - unit: string; -}; - -export const laundryItems: LaundryItem[] = [ - { - id: "l-1", - name: "Shirt / blouse", - description: "Pressed", - priceUsd: 4, - unit: "each", - }, - { - id: "l-2", - name: "Trousers / skirt", - description: "Pressed", - priceUsd: 5, - unit: "each", - }, - { - id: "l-3", - name: "Suit (2 pc)", - description: "Clean & press", - priceUsd: 18, - unit: "set", - }, - { - id: "l-4", - name: "Dress", - description: "Delicate cycle", - priceUsd: 12, - unit: "each", - }, - { - id: "l-5", - name: "Express (same day)", - description: "Surcharge on top of item prices", - priceUsd: 15, - unit: "per order", - }, -]; diff --git a/src/lib/mocks/meetingSpaces.ts b/src/lib/mocks/meetingSpaces.ts deleted file mode 100644 index b245f0a..0000000 --- a/src/lib/mocks/meetingSpaces.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { AmenityWithIcon } from "./amenities"; - -export type MeetingSpace = { - slug: string; - name: string; - shortDescription: string; - longDescription: string; - capacity: string; - floor: string; - image: string; - gallery: string[]; - amenities: AmenityWithIcon[]; - layouts: string[]; - catering: string[]; - /** Mock half-day rate in USD for display (converted via currency switcher) */ - halfDayRateUsd: number; -}; - -export const meetingSpaces: MeetingSpace[] = [ - { - slug: "serenity", - name: "Serenity Meeting Room", - shortDescription: "Versatile event space for up to 100 guests on the 1st floor.", - longDescription: - "Serenity is designed for board sessions, cocktail receptions, and medium-scale corporate events. Natural light options, flexible seating, and dedicated support for AV and catering make it the hotel’s flagship meeting venue.", - capacity: "Up to 100 guests (theatre / cocktail configurations)", - floor: "1st floor", - image: - "https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200&q=80", - gallery: [ - "https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200&q=80", - "https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=1200&q=80", - "https://images.unsplash.com/photo-1511578314322-379afb476865?w=1200&q=80", - ], - amenities: [ - { icon: "wifi", label: "High-speed Wi‑Fi / LAN" }, - { icon: "projector", label: "Projector & screen" }, - { icon: "microphone", label: "Wireless microphones" }, - { icon: "clipboard", label: "Flip charts & stationery" }, - { icon: "thermometer", label: "Climate control" }, - { icon: "handshake", label: "Dedicated event coordinator (on request)" }, - { icon: "doorOpen", label: "Breakout foyer access" }, - { icon: "accessibility", label: "Accessible routes" }, - ], - layouts: ["Boardroom", "U-shape", "Theatre", "Classroom", "Cocktail / standing"], - catering: ["Buffet menus", "Tea & coffee breaks", "Working lunch packages", "Gala dinner (via FeastVille)"], - halfDayRateUsd: 850, - }, - { - slug: "fasika", - name: "Fasika Board Room", - shortDescription: "Executive board room for 25–30 guests — intimate and fully equipped.", - longDescription: - "Fasika offers privacy and polish for leadership offsites, signing ceremonies, and focused workshops. Sound-treated walls, ergonomic seating, and premium coffee service keep sessions productive.", - capacity: "25–30 guests (boardroom style)", - floor: "1st floor", - image: - "https://images.unsplash.com/photo-1560179707-f14e90ef3623?w=1200&q=80", - gallery: [ - "https://images.unsplash.com/photo-1560179707-f14e90ef3623?w=1200&q=80", - "https://images.unsplash.com/photo-1542744173-8e7e53415bb0?w=1200&q=80", - "https://images.unsplash.com/photo-1600880292203-757bb62b4baf?w=1200&q=80", - ], - amenities: [ - { icon: "monitor", label: "4K display & HDMI / USB-C" }, - { icon: "video", label: "Video-conferencing ready" }, - { icon: "chair", label: "Executive leather seating" }, - { icon: "volumeMuted", label: "Sound dampening" }, - { icon: "restroom", label: "Private washroom adjacency" }, - { icon: "pen", label: "Notepads & pens" }, - { icon: "droplet", label: "Complimentary mineral water" }, - { icon: "phone", label: "Dedicated phone line (on request)" }, - ], - layouts: ["Boardroom", "Interview (2–4 pax)", "Small workshop"], - catering: ["Executive breakfast", "Coffee & pastries", "Light lunch boxes"], - halfDayRateUsd: 420, - }, -]; - -export function getMeetingSpaceBySlug(slug: string): MeetingSpace | undefined { - return meetingSpaces.find((m) => m.slug === slug); -} - -export function getAllMeetingSlugs(): string[] { - return meetingSpaces.map((m) => m.slug); -} diff --git a/src/lib/mocks/outlets.ts b/src/lib/mocks/outlets.ts deleted file mode 100644 index 3ad4914..0000000 --- a/src/lib/mocks/outlets.ts +++ /dev/null @@ -1,78 +0,0 @@ -export type Outlet = { - slug: string; - name: string; - tagline: string; - bullets: string[]; - image: string; - floor?: string; - /** Link to detail page when set (e.g. meeting rooms) */ - detailHref?: string; -}; - -export const outlets: Outlet[] = [ - { - slug: "feastville", - name: "FeastVille Restaurant", - tagline: "Full American breakfast to theme nights — savour every moment.", - bullets: [ - "Full American breakfast", - "Traditional & international menu", - "Theme nights selection", - "Room service menu", - ], - image: - "https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=1200&q=80", - }, - { - slug: "central-cafe", - name: "Central Cafe", - tagline: "Purely urban vibes — coffee at the heart of the city.", - bullets: [ - "Your perfect rendezvous in the city centre", - "Ideal to initiate, elevate & conclude your day", - ], - image: - "https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?w=1200&q=80", - }, - { - slug: "tabsia", - name: "TABSIA Bar", - tagline: "Cocktails, spirits, and a refined atmosphere.", - bullets: [ - "Located on the 1st floor", - "Cocktails, spirits & more", - "Unwind after a long day", - ], - floor: "1st floor", - image: - "https://images.unsplash.com/photo-1551024506-0bccd828d307?w=1200&q=80&auto=format&fit=crop", - }, - { - slug: "serenity", - name: "Serenity Meeting Room", - tagline: "Board meetings, cocktails, and events up to 100 guests.", - bullets: [ - "Up to 100 pax", - "Fully equipped with basics & stationeries", - "Buffet or tea break menus", - ], - floor: "1st floor", - detailHref: "/meetings/serenity", - image: - "https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200&q=80", - }, - { - slug: "fasika", - name: "Fasika Board Room", - tagline: "Intimate executive sessions for 25–30 guests.", - bullets: [ - "25–30 pax", - "Board & cocktail setups", - "Equipment & catering options", - ], - floor: "1st floor", - detailHref: "/meetings/fasika", - image: - "https://images.unsplash.com/photo-1560179707-f14e90ef3623?w=1200&q=80", - }, -]; diff --git a/src/lib/mocks/roomServiceMenu.ts b/src/lib/mocks/roomServiceMenu.ts deleted file mode 100644 index f1f3deb..0000000 --- a/src/lib/mocks/roomServiceMenu.ts +++ /dev/null @@ -1,68 +0,0 @@ -export type MenuCategory = "breakfast" | "mains" | "desserts" | "beverages"; - -export const roomServiceCategories: { id: MenuCategory; label: string }[] = [ - { id: "breakfast", label: "Breakfast" }, - { id: "mains", label: "Mains & light bites" }, - { id: "desserts", label: "Desserts" }, - { id: "beverages", label: "Beverages" }, -]; - -export type MenuItem = { - id: string; - category: MenuCategory; - name: string; - description: string; - priceUsd: number; -}; - -export const roomServiceItems: MenuItem[] = [ - { - id: "bf-1", - category: "breakfast", - name: "Full American breakfast", - description: "Eggs any style, beef bacon, chicken sausage, beans, toast, juice, coffee.", - priceUsd: 18, - }, - { - id: "bf-2", - category: "breakfast", - name: "Ethiopian breakfast platter", - description: "Injera, spiced lentils, fresh cheese, honey, seasonal fruit.", - priceUsd: 14, - }, - { - id: "mn-1", - category: "mains", - name: "Grilled salmon", - description: "Herb butter, seasonal vegetables, lemon.", - priceUsd: 28, - }, - { - id: "mn-2", - category: "mains", - name: "Beef tibs", - description: "Traditional sauté with peppers, injera or rice.", - priceUsd: 22, - }, - { - id: "ds-1", - category: "desserts", - name: "Chocolate fondant", - description: "Warm centre, vanilla ice cream.", - priceUsd: 12, - }, - { - id: "bv-1", - category: "beverages", - name: "Fresh juice", - description: "Orange, mango, or mixed.", - priceUsd: 6, - }, - { - id: "bv-2", - category: "beverages", - name: "Ethiopian coffee ceremony (2)", - description: "Traditional preparation — allow 20 min.", - priceUsd: 15, - }, -]; diff --git a/src/lib/mocks/rooms.ts b/src/lib/mocks/rooms.ts deleted file mode 100644 index c1a27e7..0000000 --- a/src/lib/mocks/rooms.ts +++ /dev/null @@ -1,103 +0,0 @@ -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); -} diff --git a/src/lib/mocks/services.ts b/src/lib/mocks/services.ts deleted file mode 100644 index 4044c5b..0000000 --- a/src/lib/mocks/services.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Bookable Spa & Gym offerings for the dedicated /services page (mock pricing). - */ -export type SpaGymKind = "spa" | "gym"; - -export type SpaGymService = { - id: string; - kind: SpaGymKind; - title: string; - description: string; - duration: string; - priceUsd: number; - /** Shown on card badge, e.g. "per session" */ - priceNote: string; - image: string; -}; - -export const spaGymFilterIds = ["all", "spa", "gym"] as const; -export type SpaGymFilterId = (typeof spaGymFilterIds)[number]; - -export const spaGymFilters: { id: SpaGymFilterId; label: string }[] = [ - { id: "all", label: "All" }, - { id: "spa", label: "Spa" }, - { id: "gym", label: "Gym" }, -]; - -export const spaGymServices: SpaGymService[] = [ - { - id: "gym-day-pass", - kind: "gym", - title: "Fitness day pass", - description: "Full access to cardio, weights, and stretch zones for one calendar day.", - duration: "All day · 6:00 — 22:00", - priceUsd: 18, - priceNote: "per guest / day", - image: "https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=900&q=80", - }, - { - id: "gym-pt", - kind: "gym", - title: "Personal training", - description: "One-on-one session tailored to your goals — form, intensity, and recovery.", - duration: "45 minutes", - priceUsd: 55, - priceNote: "per session", - image: "https://images.unsplash.com/photo-1571019614242-c5c5dee9f50b?w=900&q=80", - }, - { - id: "gym-hiit", - kind: "gym", - title: "Small-group HIIT", - description: "High-energy class in our studio — limited spots, hotel guests priority.", - duration: "50 minutes", - priceUsd: 28, - priceNote: "per class", - image: "https://images.unsplash.com/photo-1517836357463-d25dfeac3438?w=900&q=80", - }, - { - id: "spa-swedish", - kind: "spa", - title: "Signature Swedish massage", - description: "Long, flowing strokes to ease travel tension and improve circulation.", - duration: "60 minutes", - priceUsd: 85, - priceNote: "per treatment", - image: "https://images.unsplash.com/photo-1544161515-4ab6ce6db874?w=900&q=80", - }, - { - id: "spa-deep", - kind: "spa", - title: "Deep tissue therapy", - description: "Targeted work for shoulders, back, and legs after long flights.", - duration: "90 minutes", - priceUsd: 125, - priceNote: "per treatment", - image: "https://images.unsplash.com/photo-1600334129128-0c9b275703e6?w=900&q=80", - }, - { - id: "spa-express", - kind: "spa", - title: "Express back & neck", - description: "Focused relief when you’re between meetings — clothes-on option.", - duration: "30 minutes", - priceUsd: 52, - priceNote: "per treatment", - image: "https://images.unsplash.com/photo-1519823551278-64ac92734fb1?w=900&q=80", - }, - { - id: "spa-aroma", - kind: "spa", - title: "Aromatherapy ritual", - description: "Custom oil blend, warm compress, and full-body massage sequence.", - duration: "75 minutes", - priceUsd: 98, - priceNote: "per treatment", - image: "https://images.unsplash.com/photo-1540555700478-4be289fbecef?w=900&q=80", - }, - { - id: "spa-couples", - kind: "spa", - title: "Couples’ suite ritual", - description: "Side-by-side massage in our private suite — sparkling water included.", - duration: "90 minutes", - priceUsd: 220, - priceNote: "per couple", - image: "https://images.unsplash.com/photo-1600334089648-b0d9d3028eb2?w=900&q=80", - }, -]; diff --git a/src/lib/mocks/site.ts b/src/lib/mocks/site.ts deleted file mode 100644 index 2345d82..0000000 --- a/src/lib/mocks/site.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** 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: "Ethio China Street, Kirkos, Addis Ababa, Ethiopia", - /** 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"], - /** 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, -}; diff --git a/src/lib/mocks/wellness.ts b/src/lib/mocks/wellness.ts deleted file mode 100644 index 0d31caa..0000000 --- a/src/lib/mocks/wellness.ts +++ /dev/null @@ -1,48 +0,0 @@ -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", - }, -]; From 429cdb7094abcbda5c4f2cb0aae5f63c75dd2756 Mon Sep 17 00:00:00 2001 From: brooktewabe Date: Tue, 14 Apr 2026 11:50:43 +0300 Subject: [PATCH 2/9] auth done --- next.config.ts | 2 - package-lock.json | 328 +++++++++++++++--------- package.json | 4 +- src/app/api/auth/[...nextauth]/route.ts | 6 + src/app/providers.tsx | 13 +- src/context/AuthContext.tsx | 309 ++++++++-------------- src/lib/api-client.ts | 45 ++++ src/lib/auth-options.ts | 213 +++++++++++++++ src/lib/env.ts | 11 + src/types/next-auth.d.ts | 26 ++ 10 files changed, 626 insertions(+), 331 deletions(-) create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/lib/api-client.ts create mode 100644 src/lib/auth-options.ts create mode 100644 src/lib/env.ts create mode 100644 src/types/next-auth.d.ts diff --git a/next.config.ts b/next.config.ts index 254ed69..637fb97 100644 --- a/next.config.ts +++ b/next.config.ts @@ -11,5 +11,3 @@ const nextConfig: NextConfig = { }; export default nextConfig; - -import('@opennextjs/cloudflare').then(m => m.initOpenNextCloudflareForDev()); diff --git a/package-lock.json b/package-lock.json index 01a6fb3..c9cf8ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.0", "dependencies": { "next": "16.2.1", + "next-auth": "^4.24.11", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "zustand": "^5.0.8" }, "devDependencies": { "@opennextjs/aws": "^3.9.16", @@ -102,9 +104,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -122,9 +121,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -142,9 +138,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -162,9 +155,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1635,6 +1625,14 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1931,6 +1929,7 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2603,6 +2602,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2625,6 +2625,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2647,6 +2648,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2663,6 +2665,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2679,9 +2682,7 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2698,9 +2699,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2717,9 +2716,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2736,9 +2733,7 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2755,9 +2750,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2774,9 +2767,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2793,9 +2784,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2812,9 +2801,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2831,9 +2818,7 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2856,9 +2841,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2881,9 +2864,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2906,9 +2887,7 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2931,9 +2910,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2956,9 +2933,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2981,9 +2956,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3006,9 +2979,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3031,6 +3002,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -3050,6 +3022,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3069,6 +3042,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3088,6 +3062,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3239,9 +3214,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3258,9 +3230,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3277,9 +3246,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3296,9 +3262,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3632,6 +3595,14 @@ "wrangler": "^4.65.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -4627,9 +4598,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4647,9 +4615,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4667,9 +4632,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4687,9 +4649,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4841,7 +4800,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -5258,9 +5217,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5275,9 +5231,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5292,9 +5245,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5309,9 +5259,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5326,9 +5273,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5343,9 +5287,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5360,9 +5301,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5377,9 +5315,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6263,7 +6198,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -8717,6 +8652,14 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8997,9 +8940,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9021,9 +8961,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9045,9 +8982,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9069,9 +9003,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9472,6 +9403,45 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.13", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz", + "integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next-auth/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -9581,6 +9551,11 @@ "node": ">=8" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9591,6 +9566,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -9721,6 +9704,14 @@ "dev": true, "license": "MIT" }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -9760,6 +9751,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10000,6 +10021,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10010,6 +10051,11 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -11426,6 +11472,14 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -12312,6 +12366,34 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 5b82198..1806093 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ }, "dependencies": { "next": "16.2.1", + "next-auth": "^4.24.11", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "zustand": "^5.0.8" }, "devDependencies": { "@opennextjs/aws": "^3.9.16", diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..62a9268 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from "next-auth"; +import { authOptions } from "@/lib/auth-options"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 5375e61..dce7af1 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,16 +1,15 @@ "use client"; +import { SessionProvider } from "next-auth/react"; +import { StoreHydration } from "@/components/StoreHydration"; import { AuthProvider } from "@/context/AuthContext"; -import { BookingProvider } from "@/context/BookingContext"; -import { CurrencyProvider } from "@/context/CurrencyContext"; import type { ReactNode } from "react"; export function Providers({ children }: { children: ReactNode }) { return ( - - - {children} - - + + + {children} + ); } diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 3111fe0..1de7396 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -4,166 +4,111 @@ import { createContext, useCallback, useContext, - useEffect, useMemo, - useState, type ReactNode, } from "react"; -import { DEMO_BOOKING_REFS } from "@/lib/mocks/guestData"; +import { signIn, signOut, useSession } from "next-auth/react"; +import { getHotelPropertyId, getPublicApiUrl } from "@/lib/env"; +import { useGuestUiStore } from "@/stores/guest-ui-store"; +import { useOrdersStore } from "@/stores/orders-store"; +import type { OrderRecord } from "@/types/guest-order"; -const STORAGE_SESSION = "shitaye_session_v1"; -const STORAGE_ORDERS = "shitaye_orders_v1"; - -export type OrderCategory = "room-service" | "laundry" | "gym" | "spa"; - -export type OrderRecord = { - id: string; - category: OrderCategory; - title: string; - detail: string; - totalUsd: number; - placedAt: string; - status: "pending" | "confirmed" | "completed"; -}; +export type { OrderCategory, OrderRecord } from "@/types/guest-order"; export type MemberSession = { kind: "member"; + accessToken: string; email: string; displayName: string; + propertyId?: string; points: number; - tier: "Gold" | "Silver"; - /** How they signed in — for display only */ - authMethod: "otp" | "password" | "google" | "apple" | "facebook"; + authMethod: "otp" | "password" | "google"; + bookingCode?: string | null; + bookingId?: string | null; + role?: string; }; - -export type BookingRefSession = { - kind: "bookingRef"; - bookingRef: string; - guestName: string; - roomLabel: string; - checkOut: string; -}; - -export type GuestSession = MemberSession | BookingRefSession; +export type GuestSession = MemberSession; type AuthContextValue = { session: GuestSession | null; orders: OrderRecord[]; isHydrated: boolean; - /** Demo OTP is always 123456 */ + accessToken: string | null; requestOtp: (email: string) => Promise<{ ok: boolean; message: string }>; verifyOtp: (email: string, code: string) => Promise<{ ok: boolean; message: string }>; loginPassword: (email: string, password: string) => Promise<{ ok: boolean; message: string }>; - loginSocial: (provider: "google" | "apple" | "facebook") => void; - loginBookingRef: (ref: string) => { ok: boolean; message: string }; - logout: () => void; + loginGoogle: () => Promise; + loginBookingRef: (ref: string) => Promise<{ ok: boolean; message: string }>; + logout: () => Promise; addOrder: (o: Omit & { status?: OrderRecord["status"] }) => void; awardPoints: (points: number) => void; + setOrders: (orders: OrderRecord[] | ((prev: OrderRecord[]) => OrderRecord[])) => void; }; const AuthContext = createContext(null); -function loadOrders(): OrderRecord[] { - if (typeof window === "undefined") return []; - try { - const raw = localStorage.getItem(STORAGE_ORDERS); - if (!raw) return seedOrders(); - const parsed = JSON.parse(raw) as OrderRecord[]; - return Array.isArray(parsed) ? parsed : seedOrders(); - } catch { - return seedOrders(); - } -} - -function seedOrders(): OrderRecord[] { - return [ - { - id: "seed-rs-1", - category: "room-service", - title: "Room service · American breakfast ×2", - detail: "Delivered 07:15 · Room charge", - totalUsd: 36, - placedAt: new Date(Date.now() - 86400000 * 2).toISOString(), - status: "completed", - }, - { - id: "seed-l-1", - category: "laundry", - title: "Laundry · Express + 3 shirts", - detail: "Returned same evening", - totalUsd: 27, - placedAt: new Date(Date.now() - 86400000).toISOString(), - status: "completed", - }, - { - id: "seed-sp-1", - category: "spa", - title: "Spa · Signature Swedish 60 min", - detail: "Apr 4 · 15:00", - totalUsd: 85, - placedAt: new Date(Date.now() - 86400000 * 3).toISOString(), - status: "confirmed", - }, - ]; -} - -function loadSession(): GuestSession | null { - if (typeof window === "undefined") return null; - try { - const raw = localStorage.getItem(STORAGE_SESSION); - if (!raw) return null; - return JSON.parse(raw) as GuestSession; - } catch { - return null; - } -} - -function persistSession(s: GuestSession | null) { - if (typeof window === "undefined") return; - if (s) localStorage.setItem(STORAGE_SESSION, JSON.stringify(s)); - else localStorage.removeItem(STORAGE_SESSION); -} - -function persistOrders(orders: OrderRecord[]) { - if (typeof window === "undefined") return; - localStorage.setItem(STORAGE_ORDERS, JSON.stringify(orders)); -} - export function AuthProvider({ children }: { children: ReactNode }) { - const [session, setSession] = useState(null); - const [orders, setOrders] = useState([]); - const [isHydrated, setIsHydrated] = useState(false); + const { data, status } = useSession(); + const orders = useOrdersStore((s) => s.orders); + const localBonusPoints = useGuestUiStore((s) => s.localBonusPoints); - useEffect(() => { - setSession(loadSession()); - setOrders(loadOrders()); - setIsHydrated(true); - }, []); + const isHydrated = status !== "loading"; + + const guestSession = useMemo((): GuestSession | null => { + if (status !== "authenticated" || !data?.accessToken) return null; + const email = data.user?.email ?? ""; + const displayName = data.user?.name ?? email.split("@")[0] ?? "Guest"; + return { + kind: "member", + accessToken: data.accessToken, + email, + displayName: displayName.charAt(0).toUpperCase() + displayName.slice(1), + propertyId: data.propertyId ?? getHotelPropertyId(), + points: localBonusPoints, + authMethod: data.authMethod ?? "password", + bookingCode: data.bookingCode ?? null, + bookingId: data.bookingId ?? null, + role: data.role, + }; + }, [status, data, localBonusPoints]); const requestOtp = useCallback(async (email: string) => { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return { ok: false, message: "Enter a valid email address." }; } - return { ok: true, message: "Demo code sent. Use OTP 123456 to continue." }; + const propertyId = getHotelPropertyId(); + if (!propertyId) { + return { ok: false, message: "Hotel is not configured (missing NEXT_PUBLIC_HOTEL_PROPERTY_ID)." }; + } + try { + const base = getPublicApiUrl(); + const res = await fetch(`${base}/auth/hotel-guest/send-otp`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ propertyId, email: email.trim().toLowerCase() }), + }); + const body = (await res.json().catch(() => ({}))) as { message?: string }; + if (!res.ok) { + return { + ok: false, + message: typeof body.message === "string" ? body.message : "Could not send code.", + }; + } + return { ok: true, message: "Check your email for the one-time code." }; + } catch { + return { ok: false, message: "Could not send code. Try again." }; + } }, []); const verifyOtp = useCallback(async (email: string, code: string) => { - const trimmed = code.replace(/\s/g, ""); - if (trimmed !== "123456") { - return { ok: false, message: "Invalid code. Demo OTP is 123456." }; + const r = await signIn("hotel-otp", { + email: email.trim().toLowerCase(), + otp: code.replace(/\s/g, ""), + redirect: false, + }); + if (r?.error) { + return { ok: false, message: "Invalid or expired code." }; } - const local = email.split("@")[0] ?? "Guest"; - const name = local.charAt(0).toUpperCase() + local.slice(1); - const next: MemberSession = { - kind: "member", - email: email.toLowerCase(), - displayName: name, - points: 2400, - tier: "Gold", - authMethod: "otp", - }; - setSession(next); - persistSession(next); return { ok: true, message: "Signed in." }; }, []); @@ -171,69 +116,47 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (!email || !password) { return { ok: false, message: "Email and password required." }; } - if (password !== "shitaye" && password !== "demo123") { - return { - ok: false, - message: "Incorrect password. Try demo password: shitaye", - }; + const r = await signIn("credentials", { + identifier: email.trim().toLowerCase(), + password, + redirect: false, + }); + if (r?.error) { + return { ok: false, message: "Invalid email or password." }; } - const local = email.split("@")[0] ?? "Guest"; - const name = local.charAt(0).toUpperCase() + local.slice(1); - const next: MemberSession = { - kind: "member", - email: email.toLowerCase(), - displayName: name, - points: 2400, - tier: "Gold", - authMethod: "password", - }; - setSession(next); - persistSession(next); return { ok: true, message: "Signed in." }; }, []); - const loginSocial = useCallback((provider: "google" | "apple" | "facebook") => { - const names: Record = { - google: "Google Guest", - apple: "Apple Guest", - facebook: "Facebook Guest", - }; - const next: MemberSession = { - kind: "member", - email: `guest.${provider}@shitaye.demo`, - displayName: names[provider], - points: 2100, - tier: "Silver", - authMethod: provider, - }; - setSession(next); - persistSession(next); + const loginGoogle = useCallback(async () => { + await signIn("google", { callbackUrl: "/", redirect: true }); }, []); - const loginBookingRef = useCallback((ref: string) => { - const key = ref.trim().toUpperCase(); - const row = DEMO_BOOKING_REFS[key]; - if (!row) { + const loginBookingRef = useCallback(async (ref: string) => { + const propertyId = getHotelPropertyId(); + if (!propertyId) { return { ok: false, - message: "Reference not found. Try SHITAYE-2026-DEMO or GUEST-1234.", + message: "Hotel is not configured (missing NEXT_PUBLIC_HOTEL_PROPERTY_ID).", }; } - const next: BookingRefSession = { - kind: "bookingRef", - bookingRef: key, - guestName: row.guestName, - roomLabel: row.room, - checkOut: row.checkOut, - }; - setSession(next); - persistSession(next); - return { ok: true, message: "Linked to your stay." }; + const r = await signIn("booking-code", { + bookingCode: ref.trim(), + propertyId, + redirect: false, + }); + if (r?.error) { + return { ok: false, message: "Invalid booking code or account not linked." }; + } + return { ok: true, message: "Signed in with your booking." }; }, []); - const logout = useCallback(() => { - setSession(null); - persistSession(null); + const logout = useCallback(async () => { + useGuestUiStore.getState().resetLocalBonus(); + await signOut({ callbackUrl: "/" }); + }, []); + + const setOrders = useCallback((next: OrderRecord[] | ((prev: OrderRecord[]) => OrderRecord[])) => { + useOrdersStore.getState().setOrders(next); }, []); const addOrder = useCallback( @@ -248,59 +171,49 @@ export function AuthProvider({ children }: { children: ReactNode }) { placedAt: new Date().toISOString(), status: o.status ?? "pending", }; - setOrders((prev) => { - const next = [rec, ...prev]; - persistOrders(next); - return next; - }); - if (session?.kind === "member") { + useOrdersStore.getState().pushOrder(rec); + if (guestSession?.kind === "member") { const bonus = Math.min(150, Math.round(o.totalUsd * 2)); - setSession((s) => { - if (!s || s.kind !== "member") return s; - const u = { ...s, points: s.points + bonus }; - persistSession(u); - return u; - }); + useGuestUiStore.getState().addLocalBonus(bonus); } }, - [session], + [guestSession], ); const awardPoints = useCallback((points: number) => { - setSession((s) => { - if (!s || s.kind !== "member") return s; - const u = { ...s, points: s.points + points }; - persistSession(u); - return u; - }); + useGuestUiStore.getState().addLocalBonus(points); }, []); const value = useMemo( () => ({ - session, + session: guestSession, orders, isHydrated, + accessToken: data?.accessToken ?? null, requestOtp, verifyOtp, loginPassword, - loginSocial, + loginGoogle, loginBookingRef, logout, addOrder, awardPoints, + setOrders, }), [ - session, + guestSession, orders, isHydrated, + data?.accessToken, requestOtp, verifyOtp, loginPassword, - loginSocial, + loginGoogle, loginBookingRef, logout, addOrder, awardPoints, + setOrders, ], ); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts new file mode 100644 index 0000000..5f17a9e --- /dev/null +++ b/src/lib/api-client.ts @@ -0,0 +1,45 @@ +import { getPublicApiUrl } from "@/lib/env"; + +export class ApiError extends Error { + constructor( + message: string, + public status: number, + public body?: unknown, + ) { + super(message); + this.name = "ApiError"; + } +} + +export async function apiFetch( + path: string, + init: RequestInit & { accessToken?: string } = {}, +): Promise { + const base = getPublicApiUrl(); + const url = path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`; + const headers = new Headers(init.headers); + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + if (init.accessToken) { + headers.set("Authorization", `Bearer ${init.accessToken}`); + } + const res = await fetch(url, { ...init, headers }); + const text = await res.text(); + let data: unknown = null; + if (text) { + try { + data = JSON.parse(text) as unknown; + } catch { + data = text; + } + } + if (!res.ok) { + const msg = + typeof data === "object" && data !== null && "message" in data + ? String((data as { message: unknown }).message) + : res.statusText; + throw new ApiError(msg || `HTTP ${res.status}`, res.status, data); + } + return data as T; +} diff --git a/src/lib/auth-options.ts b/src/lib/auth-options.ts new file mode 100644 index 0000000..66dad51 --- /dev/null +++ b/src/lib/auth-options.ts @@ -0,0 +1,213 @@ +import type { NextAuthOptions } from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import GoogleProvider from "next-auth/providers/google"; +import { getPublicApiUrl, getHotelPropertyId } from "@/lib/env"; + +async function postJson(path: string, body: Record): Promise { + const api = getPublicApiUrl(); + const res = await fetch(`${api}${path.startsWith("/") ? path : `/${path}`}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = (await res.json().catch(() => ({}))) as T & { message?: string }; + if (!res.ok) { + throw new Error( + typeof data === "object" && data && "message" in data && typeof data.message === "string" + ? data.message + : `Auth failed (${res.status})`, + ); + } + return data as T; +} + +type LoginPayload = { + access_token: string; + user: { + id: string; + email: string | null; + name: string | null; + role: string; + propertyId?: string; + }; +}; + +type AuthMethod = "otp" | "password" | "google"; + +type BookingCodePayload = LoginPayload & { + booking?: { + id: string; + bookingCode: string | null; + checkIn: string; + checkOut: string; + status: string; + }; +}; + +const providers: NextAuthOptions["providers"] = [ + CredentialsProvider({ + id: "credentials", + name: "Email & password", + credentials: { + identifier: { label: "Email", type: "text" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + if (!credentials?.identifier || !credentials?.password) return null; + try { + const data = await postJson("/auth/login", { + identifier: credentials.identifier, + password: credentials.password, + }); + return { + id: data.user.id, + email: data.user.email ?? undefined, + name: data.user.name ?? undefined, + accessToken: data.access_token, + role: data.user.role, + propertyId: data.user.propertyId, + authMethod: "password" as AuthMethod, + }; + } catch { + return null; + } + }, + }), + CredentialsProvider({ + id: "hotel-otp", + name: "Hotel email OTP", + credentials: { + email: { label: "Email", type: "text" }, + otp: { label: "Code", type: "text" }, + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.otp) return null; + try { + const data = await postJson("/auth/hotel-user/login-email-otp", { + email: credentials.email, + otp: credentials.otp, + }); + return { + id: data.user.id, + email: data.user.email ?? undefined, + name: data.user.name ?? undefined, + accessToken: data.access_token, + role: data.user.role, + propertyId: data.user.propertyId, + authMethod: "otp" as AuthMethod, + }; + } catch { + return null; + } + }, + }), + CredentialsProvider({ + id: "booking-code", + name: "Booking code", + credentials: { + bookingCode: { label: "Booking code", type: "text" }, + propertyId: { label: "Property", type: "text" }, + }, + async authorize(credentials) { + const propertyId = credentials?.propertyId?.trim() || getHotelPropertyId(); + const bookingCode = credentials?.bookingCode?.trim(); + if (!propertyId || !bookingCode) return null; + try { + const data = await postJson("/auth/hotel-guest/login-booking-code", { + propertyId, + bookingCode, + }); + return { + id: data.user.id, + email: data.user.email ?? undefined, + name: data.user.name ?? undefined, + accessToken: data.access_token, + role: data.user.role, + propertyId: data.user.propertyId ?? propertyId, + bookingCode: data.booking?.bookingCode ?? bookingCode, + bookingId: data.booking?.id ?? null, + }; + } catch { + return null; + } + }, + }), +]; + +if (process.env.GOOGLE_CLIENT_ID?.trim() && process.env.GOOGLE_CLIENT_SECRET?.trim()) { + providers.push( + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }), + ); +} + +export const authOptions: NextAuthOptions = { + providers, + callbacks: { + async jwt({ token, user, account, profile }) { + if (account?.provider === "google" && profile && "email" in profile && profile.email) { + try { + const data = await postJson("/auth/google", { + email: profile.email, + name: profile.name, + googleId: profile.sub, + role: "CUSTOMER", + }); + token.accessToken = data.access_token; + token.sub = data.user.id; + token.email = data.user.email ?? undefined; + token.name = data.user.name ?? undefined; + token.role = data.user.role; + token.propertyId = data.user.propertyId; + token.authMethod = "google"; + token.error = undefined; + } catch { + token.error = "GoogleSignInFailed"; + } + return token; + } + + if (user) { + const u = user as { + accessToken?: string; + role?: string; + propertyId?: string; + bookingCode?: string | null; + bookingId?: string | null; + authMethod?: AuthMethod; + }; + token.accessToken = u.accessToken; + token.role = u.role; + token.propertyId = u.propertyId; + token.bookingCode = u.bookingCode ?? undefined; + token.bookingId = u.bookingId ?? undefined; + token.authMethod = u.authMethod ?? token.authMethod ?? "password"; + } + return token; + }, + async session({ session, token }) { + if (session.user) { + session.user.email = (token.email as string) ?? session.user.email; + session.user.name = (token.name as string) ?? session.user.name; + } + session.accessToken = token.accessToken as string | undefined; + session.role = token.role as string | undefined; + session.propertyId = (token.propertyId as string | undefined) ?? getHotelPropertyId(); + session.bookingCode = token.bookingCode ?? null; + session.bookingId = token.bookingId ?? null; + session.authMethod = (token.authMethod as AuthMethod | undefined) ?? "password"; + session.error = token.error as string | undefined; + return session; + }, + }, + pages: { + signIn: "/login", + }, + session: { + strategy: "jwt", + maxAge: 60 * 60 * 24 * 7, + }, + secret: process.env.NEXTAUTH_SECRET, +}; diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..79fda7e --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,11 @@ +/** API origin including /api prefix, e.g. http://localhost:7777/api */ +export function getPublicApiUrl(): string { + const u = process.env.NEXT_PUBLIC_API_URL?.trim(); + if (u) return u.replace(/\/$/, ""); + return "http://localhost:7777/api"; +} + +export function getHotelPropertyId(): string | undefined { + const id = process.env.NEXT_PUBLIC_HOTEL_PROPERTY_ID?.trim(); + return id || undefined; +} diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..29b9a2d --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,26 @@ +import type { DefaultSession } from "next-auth"; + +declare module "next-auth" { + interface Session extends DefaultSession { + accessToken?: string; + role?: string; + propertyId?: string; + /** Set when signing in with booking code */ + bookingCode?: string | null; + bookingId?: string | null; + authMethod?: "otp" | "password" | "google"; + error?: string; + } +} + +declare module "next-auth/jwt" { + interface JWT { + accessToken?: string; + role?: string; + propertyId?: string; + bookingCode?: string | null; + bookingId?: string | null; + authMethod?: "otp" | "password" | "google"; + error?: string; + } +} From c4748aa0ee36c29d08ccf1552e516662d278f812 Mon Sep 17 00:00:00 2001 From: brooktewabe Date: Tue, 14 Apr 2026 11:52:24 +0300 Subject: [PATCH 3/9] layouts --- src/app/login/LoginPageClient.tsx | 64 +++----- src/app/profile/ProfilePageClient.tsx | 224 +++++++++++++++----------- src/components/Footer.tsx | 2 +- src/components/Header.tsx | 21 ++- src/components/HeaderAccount.tsx | 10 +- src/components/OutletCard.tsx | 2 +- 6 files changed, 175 insertions(+), 148 deletions(-) diff --git a/src/app/login/LoginPageClient.tsx b/src/app/login/LoginPageClient.tsx index 8244f35..784f7aa 100644 --- a/src/app/login/LoginPageClient.tsx +++ b/src/app/login/LoginPageClient.tsx @@ -12,13 +12,7 @@ export function LoginPageClient() { const searchParams = useSearchParams(); const nextPath = searchParams.get("next") || "/profile"; - const { - requestOtp, - verifyOtp, - loginPassword, - loginSocial, - loginBookingRef, - } = useAuth(); + const { requestOtp, verifyOtp, loginPassword, loginGoogle, loginBookingRef } = useAuth(); const [tab, setTab] = useState("otp"); const [email, setEmail] = useState(""); @@ -59,15 +53,17 @@ export function LoginPageClient() { if (r.ok) router.push(nextPath); } - function handleSocial(provider: "google" | "apple" | "facebook") { - loginSocial(provider); - router.push(nextPath); + async function handleGoogle() { + setMessage(null); + await loginGoogle(); } - function handleBookingRef(e: React.FormEvent) { + async function handleBookingRef(e: React.FormEvent) { e.preventDefault(); setMessage(null); - const r = loginBookingRef(bookingRef); + setLoading(true); + const r = await loginBookingRef(bookingRef); + setLoading(false); setMessage(r.message); if (r.ok) router.push(nextPath); } @@ -162,7 +158,7 @@ export function LoginPageClient() { />

- Demo: enter 123456 + Use the code sent to your email (hotel guest OTP).

- -
)} @@ -269,11 +253,15 @@ export function LoginPageClient() { />

- Try SHITAYE-2026-DEMO or GUEST-1234 — no email - required. You can place orders and view a limited stay profile. + Enter the booking code from your confirmation email. You must have used the same + email at booking so your account links for the full guest portal.

- )} diff --git a/src/app/profile/ProfilePageClient.tsx b/src/app/profile/ProfilePageClient.tsx index f45ced1..c1a9fc7 100644 --- a/src/app/profile/ProfilePageClient.tsx +++ b/src/app/profile/ProfilePageClient.tsx @@ -1,16 +1,18 @@ "use client"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { RequireAuth } from "@/components/RequireAuth"; import { useAuth } from "@/context/AuthContext"; import type { OrderCategory, OrderRecord } from "@/context/AuthContext"; import { - seedAppointments, - seedRewardsHistory, - seedShuttle, -} from "@/lib/mocks/guestData"; -import { siteConfig } from "@/lib/mocks/site"; + guestMe, + guestOrders, + guestPointsHistory, + guestSpaBookings, + type PointLedgerRow, + type SpaBookingRow, +} from "@/lib/guest-hotel-api"; const orderTabs: { id: OrderCategory | "all"; label: string }[] = [ { id: "all", label: "All" }, @@ -60,13 +62,66 @@ export function ProfilePageClient() { } function ProfileContent() { - const { session, orders, logout } = useAuth(); + const { session, logout, accessToken } = useAuth(); const [orderFilter, setOrderFilter] = useState("all"); + const [apiBalance, setApiBalance] = useState(null); + const [apiLedger, setApiLedger] = useState([]); + const [apiOrders, setApiOrders] = useState([]); + const [appointments, setAppointments] = useState([]); + + useEffect(() => { + if (!accessToken || !session) return; + const pid = session.propertyId; + if (!pid) return; + let cancelled = false; + (async () => { + try { + const me = await guestMe(pid, accessToken); + const ph = await guestPointsHistory(pid, accessToken); + const ord = await guestOrders(pid, accessToken); + const spa = await guestSpaBookings(pid, accessToken); + + if (!cancelled) { + setApiBalance(me.balance); + setApiLedger(ph.data ?? []); + setApiOrders( + (ord.data ?? []).map((o) => ({ + id: o.id, + category: o.type, + title: + o.type === "room-service" + ? "Room Service Order" + : o.type === "laundry" + ? "Laundry Request" + : o.type === "gym" + ? "Gym Booking" + : "Spa Booking", + detail: o.detail, + totalUsd: Number(o.total ?? 0), + placedAt: o.createdAt, + status: (["pending", "confirmed", "completed"].includes(o.status.toLowerCase()) + ? o.status.toLowerCase() + : "pending") as OrderRecord["status"], + })), + ); + setAppointments(spa.data ?? []); + } + } catch { + if (!cancelled) { + setApiOrders([]); + setAppointments([]); + } + } + })(); + return () => { + cancelled = true; + }; + }, [accessToken, session]); const filteredOrders = useMemo(() => { - if (orderFilter === "all") return orders; - return orders.filter((o) => o.category === orderFilter); - }, [orders, orderFilter]); + if (orderFilter === "all") return apiOrders; + return apiOrders.filter((o) => o.category === orderFilter); + }, [apiOrders, orderFilter]); if (!session) { return null; @@ -86,24 +141,19 @@ function ProfileContent() {

- {session.kind === "member" - ? `Hello, ${session.displayName}` - : `Welcome, ${session.guestName}`} + Hello, {session.displayName}

- {session.kind === "member" ? ( + {session.email} + {session.bookingCode ? ( <> - {session.email} {" · "} - Signed in via {session.authMethod} + Booking code{" "} + + {session.bookingCode} + - ) : ( - <> - Booking {session.bookingRef} - {" · "} - {session.roomLabel} · checkout {session.checkOut} - - )} + ) : null}

@@ -125,75 +175,49 @@ function ProfileContent() {

Rewards points

- {session.kind === "member" ? ( - <> -

- {session.points.toLocaleString()} -

-

- {session.tier} tier · earn on stays & dining -

- - ) : ( -

- Full loyalty points unlock when you sign in with email. Booking-ID access covers - orders and stay tools. + <> +

+ {(apiBalance ?? session.points).toLocaleString()}

- )} -
- -
-

- Airport shuttle -

-

- Lobby pickup · {seedShuttle.lobbyPickupTime} -

-

- {new Date(seedShuttle.departureDate).toLocaleDateString(undefined, { - weekday: "long", - month: "long", - day: "numeric", - })}{" "} - · {seedShuttle.airport} -

-

- {seedShuttle.flightLabel} -

-

{seedShuttle.notes}

- - Request a change - +

+ {apiBalance != null ? "Live balance" : "Balance unavailable"} +

+

Booked appointments

-
    - {seedAppointments.map((a) => ( -
  • -

    - {a.status} -

    -

    {a.title}

    -

    {a.when}

    -

    {a.where}

    -
  • - ))} -
+ {appointments.length === 0 ? ( +

+ No gym/spa bookings found. +

+ ) : ( +
    + {appointments.map((a) => ( +
  • +

    + {a.status} +

    +

    + {a.offering?.name ?? "Spa/Gym booking"} +

    +

    + {formatWhen(a.scheduledAt ?? a.createdAt)} +

    +
  • + ))} +
+ )}

Orders

- Room service, laundry, gym, and spa — including demo history and new orders from this - device. + Room service, laundry, gym, and spa

{orderTabs.map((t) => ( @@ -225,21 +249,33 @@ function ProfileContent() {
-

Rewards earned

+

Rewards history

    - {seedRewardsHistory.map((r) => ( -
  • -
    -

    {r.label}

    -

    {r.earnedAt}

    -
    - +{r.points} pts -
  • - ))} + {apiLedger.length > 0 + ? apiLedger.map((r) => ( +
  • +
    +

    {r.reason.replace(/_/g, " ")}

    +

    {formatWhen(r.createdAt)}

    +
    + = 0 ? "badge-mustard" : "rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-800" + } + > + {r.delta >= 0 ? "+" : ""} + {r.delta} pts + +
  • + )) + : null}
+ {apiLedger.length === 0 ? ( +

No rewards history returned yet.

+ ) : null}
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index d5955b1..493e858 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,6 +1,6 @@ import Image from "next/image"; import Link from "next/link"; -import { siteConfig } from "@/lib/mocks/site"; +import { siteConfig } from "@/lib/site-config"; export function Footer() { return ( diff --git a/src/components/Header.tsx b/src/components/Header.tsx index a942ee0..60b79e0 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,9 +1,12 @@ +"use client"; + import Image from "next/image"; import Link from "next/link"; import { HeaderAccount } from "@/components/HeaderAccount"; import { CurrencySwitcher } from "@/components/CurrencySwitcher"; import { ReviewsMenu } from "@/components/ReviewsMenu"; -import { siteConfig } from "@/lib/mocks/site"; +import { siteConfig } from "@/lib/site-config"; +import { useAuth } from "@/context/AuthContext"; const nav = [ { href: "/#rooms", label: "Rooms" }, @@ -16,6 +19,8 @@ const nav = [ ]; export function Header() { + const { session } = useAuth(); + return (
@@ -72,12 +77,14 @@ export function Header() {
- - Book - + {!session && ( + + Book + + )}
diff --git a/src/components/HeaderAccount.tsx b/src/components/HeaderAccount.tsx index bc42d7c..19f0253 100644 --- a/src/components/HeaderAccount.tsx +++ b/src/components/HeaderAccount.tsx @@ -13,12 +13,8 @@ export function HeaderAccount() { } if (session) { - const points = - session.kind === "member" ? session.points : "—"; - const label = - session.kind === "member" - ? session.displayName.split(" ")[0] ?? "Guest" - : session.guestName.split(" ")[0] ?? "Guest"; + const points = session.points; + const label = session.displayName.split(" ")[0] ?? "Guest"; return (
@@ -27,7 +23,7 @@ export function HeaderAccount() { className="hidden max-w-[140px] truncate rounded-full border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-1.5 text-xs font-semibold text-[var(--color-primary)] sm:inline-block" title="Loyalty points" > - {points !== "—" ? `${points} pts` : "Stay"} + {`${points} pts`} Date: Tue, 14 Apr 2026 15:41:11 +0300 Subject: [PATCH 4/9] site configs --- src/app/page.tsx | 19 +++++++------------ src/lib/env.ts | 1 - src/lib/format-etb.ts | 7 +++++++ src/lib/site-config.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 src/lib/format-etb.ts create mode 100644 src/lib/site-config.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index d74ec7b..f75d568 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,15 +3,14 @@ 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 { CatalogRoomsSection } from "@/components/CatalogRoomsSection"; import { GoogleMapEmbed } from "@/components/GoogleMapEmbed"; import { VirtualTourBlock } from "@/components/VirtualTourBlock"; -import { roomAmenities } from "@/lib/mocks/amenities"; -import { bookingStyleReviews } from "@/lib/mocks/bookingReviews"; -import { outlets } from "@/lib/mocks/outlets"; -import { rooms } from "@/lib/mocks/rooms"; -import { siteConfig } from "@/lib/mocks/site"; -import { wellnessFacilities } from "@/lib/mocks/wellness"; +import { roomAmenities } from "@/lib/data/amenities"; +import { bookingStyleReviews } from "@/lib/data/bookingReviews"; +import { outlets } from "@/lib/data/outlets"; +import { siteConfig } from "@/lib/site-config"; +import { wellnessFacilities } from "@/lib/data/wellness"; const heroImage = "https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1920&q=80"; @@ -164,11 +163,7 @@ export default function HomePage() { Book a room →
-
- {rooms.map((room) => ( - - ))} -
+ diff --git a/src/lib/env.ts b/src/lib/env.ts index 79fda7e..5a2acc3 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,4 +1,3 @@ -/** API origin including /api prefix, e.g. http://localhost:7777/api */ export function getPublicApiUrl(): string { const u = process.env.NEXT_PUBLIC_API_URL?.trim(); if (u) return u.replace(/\/$/, ""); diff --git a/src/lib/format-etb.ts b/src/lib/format-etb.ts new file mode 100644 index 0000000..fb629df --- /dev/null +++ b/src/lib/format-etb.ts @@ -0,0 +1,7 @@ +export function formatEtb(amount: number, maximumFractionDigits: 0 | 1 | 2 = 2): string { + return new Intl.NumberFormat("en-GB", { + style: "currency", + currency: "ETB", + maximumFractionDigits, + }).format(amount); +} diff --git a/src/lib/site-config.ts b/src/lib/site-config.ts new file mode 100644 index 0000000..46e4901 --- /dev/null +++ b/src/lib/site-config.ts @@ -0,0 +1,42 @@ +/** Site-wide static config until CMS / API-backed content exists. */ +export const siteConfig = { + name: "Shitaye Suite Hotel", + tagline: "The Unwinding Choice", + city: "Addis Ababa", + address: "Ethio China Street, Kirkos, Addis Ababa, Ethiopia", + /** 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"], + /** 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, +}; From 0160816b8e9bf6904c5574d669f40ce48a506197 Mon Sep 17 00:00:00 2001 From: brooktewabe Date: Tue, 14 Apr 2026 15:43:20 +0300 Subject: [PATCH 5/9] booking and amenities --- src/app/booking/BookingPageClient.tsx | 60 ++-- src/app/guest/laundry/LaundryClient.tsx | 196 ++++++------ src/app/guest/page.tsx | 38 ++- .../guest/room-service/RoomServiceClient.tsx | 269 +++++++++++------ src/app/reserve-held/page.tsx | 11 +- src/app/rooms/[slug]/page.tsx | 12 +- src/components/AmenityItem.tsx | 2 +- src/components/BookRoomButton.tsx | 9 +- src/components/CatalogRoomsSection.tsx | 31 ++ src/context/BookingContext.tsx | 220 +------------- src/lib/data/laundryCatalog.ts | 45 +++ src/lib/data/roomServiceMenu.ts | 68 +++++ src/lib/guest-hotel-api.ts | 283 ++++++++++++++++++ src/lib/useGuestActiveBooking.ts | 50 ++++ src/stores/booking-store.ts | 262 ++++++++++++++++ src/stores/currency-store.ts | 44 +++ src/stores/orders-store.ts | 18 ++ src/types/guest-order.ts | 11 + 18 files changed, 1184 insertions(+), 445 deletions(-) create mode 100644 src/components/CatalogRoomsSection.tsx create mode 100644 src/lib/data/laundryCatalog.ts create mode 100644 src/lib/data/roomServiceMenu.ts create mode 100644 src/lib/guest-hotel-api.ts create mode 100644 src/lib/useGuestActiveBooking.ts create mode 100644 src/stores/booking-store.ts create mode 100644 src/stores/currency-store.ts create mode 100644 src/stores/orders-store.ts create mode 100644 src/types/guest-order.ts diff --git a/src/app/booking/BookingPageClient.tsx b/src/app/booking/BookingPageClient.tsx index 6af5230..09f558f 100644 --- a/src/app/booking/BookingPageClient.tsx +++ b/src/app/booking/BookingPageClient.tsx @@ -6,9 +6,8 @@ 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"; +import { siteConfig } from "@/lib/site-config"; +import { createPublicBooking, ensurePropertyId } from "@/lib/public-hotel-api"; export function BookingPageClient() { const searchParams = useSearchParams(); @@ -24,6 +23,9 @@ export function BookingPageClient() { setPayLaterHold, selectedRoom, nights, + rooms, + couponCode, + setLastCreatedBooking, } = useBooking(); const [pending, setPending] = useState(null); @@ -32,33 +34,55 @@ export function BookingPageClient() { useEffect(() => { const r = searchParams.get("room"); if (r && rooms.some((x) => x.id === r)) setRoomId(r); - }, [searchParams, setRoomId]); + }, [searchParams, setRoomId, rooms]); const canContinue = selectedRoom && guest.firstName.trim() && guest.lastName.trim() && guest.email.trim() && - guest.phone.trim() && - guest.flightBookingNumber.trim() && - guest.arrivalTime.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({ + const propertyId = await ensurePropertyId(); + const booking = await createPublicBooking(propertyId, { roomId: selectedRoom.id, - email: guest.email, - flightBookingNumber: guest.flightBookingNumber.trim(), + checkIn, + checkOut, + guestCount: guests, + firstName: guest.firstName.trim(), + lastName: guest.lastName.trim(), + email: guest.email.trim().toLowerCase(), + phone: guest.phone.trim(), + flightPnr: guest.flightBookingNumber.trim(), arrivalTime: guest.arrivalTime.trim(), + discountCode: couponCode.trim() || undefined, + payLaterHold: mode === "reserve", }); - setHoldReference(reference); + const code = booking.bookingCode ?? ""; + setHoldReference(code); setPayLaterHold(mode === "reserve"); + const tp = + booking.totalPrice != null + ? typeof booking.totalPrice === "string" + ? Number.parseFloat(booking.totalPrice) + : booking.totalPrice + : 0; + setLastCreatedBooking({ + id: booking.id, + bookingCode: booking.bookingCode, + totalPrice: Number.isFinite(tp) ? tp : 0, + currency: booking.currency ?? "ETB", + }); router.push(mode === "payment" ? "/payment" : "/reserve-held"); - } catch { - setError("Something went wrong. Please try again."); + } catch (e) { + setError(e instanceof Error ? e.message : "Something went wrong. Please try again."); } finally { setPending(null); } @@ -69,11 +93,9 @@ export function BookingPageClient() {

Book your stay

-

- It only takes a moment -

+

It only takes a moment

- Pay now, or reserve first and complete payment later in this session — mock only. + Live rates from the hotel. You'll receive a booking code to sign in and manage your stay.

@@ -206,8 +228,8 @@ export function BookingPageClient() { {pending === "reserve" ? "Saving your hold…" : "Reserve now — pay later"}

- Pay later keeps your details and hold reference; finish checkout from the next screen - whenever you're ready. + Pay later keeps your hold; you'll get a booking code. Payment is completed at the hotel unless + you add card checkout later.

diff --git a/src/app/guest/laundry/LaundryClient.tsx b/src/app/guest/laundry/LaundryClient.tsx index 22dd9e2..63db670 100644 --- a/src/app/guest/laundry/LaundryClient.tsx +++ b/src/app/guest/laundry/LaundryClient.tsx @@ -1,10 +1,12 @@ "use client"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { RequireAuth } from "@/components/RequireAuth"; import { useAuth } from "@/context/AuthContext"; -import { laundryItems } from "@/lib/mocks/laundryCatalog"; +import { guestPlaceLaundry } from "@/lib/guest-hotel-api"; +import { formatEtb } from "@/lib/format-etb"; +import { useGuestActiveBooking } from "@/lib/useGuestActiveBooking"; export function LaundryClient() { return ( @@ -15,58 +17,46 @@ export function LaundryClient() { } function LaundryInner() { - const { addOrder } = useAuth(); - const [qty, setQty] = useState>({}); - const [express, setExpress] = useState(false); + const { accessToken } = useAuth(); + const { bookingId, loading: bookingLoading, propertyId } = useGuestActiveBooking(); + const [notes, setNotes] = useState(""); + const [pickupAt, setPickupAt] = useState(""); + const [deliverAt, setDeliverAt] = useState(""); + const [estimateEtb, setEstimateEtb] = useState(""); const [sent, setSent] = useState(false); + const [submitErr, setSubmitErr] = useState(null); + const [submitting, setSubmitting] = useState(false); - function bump(id: string, delta: number) { - setQty((prev) => { - const next = { ...prev }; - const n = Math.max(0, (next[id] ?? 0) + delta); - if (n === 0) delete next[id]; - else next[id] = n; - return next; - }); - } + const canUseApi = !!(propertyId && accessToken && bookingId); - const lines = useMemo(() => { - const out: { id: string; name: string; count: number; unitUsd: number }[] = []; - for (const row of laundryItems) { - const q = qty[row.id]; - if (q && q > 0) { - out.push({ id: row.id, name: row.name, count: q, unitUsd: row.priceUsd }); - } + async function submit() { + if (!canUseApi) return; + setSubmitErr(null); + setSubmitting(true); + try { + await guestPlaceLaundry(propertyId!, accessToken!, { + bookingId: bookingId!, + items: [], + notes: notes.trim() || undefined, + pickupAt: pickupAt || undefined, + deliverAt: deliverAt || undefined, + total: estimateEtb.trim() || undefined, + }); + } catch (e) { + setSubmitErr(e instanceof Error ? e.message : "Could not submit laundry request"); + setSubmitting(false); + return; } - return out; - }, [qty]); - - const subtotal = useMemo(() => { - let s = lines.reduce((a, l) => a + l.unitUsd * l.count, 0); - if (express) s += 15; - return s; - }, [lines, express]); - - function submit() { - if (lines.length === 0 && !express) return; - const detail = [ - ...lines.map((l) => `${l.name} ×${l.count}`), - express ? "Express same-day (+$15)" : null, - ] - .filter(Boolean) - .join("; "); - addOrder({ - category: "laundry", - title: "Laundry · " + (lines.length ? `${lines.length} item type(s)` : "Express only"), - detail, - totalUsd: Math.round(subtotal * 100) / 100, - status: "pending", - }); - setQty({}); - setExpress(false); + setSubmitting(false); + setNotes(""); + setPickupAt(""); + setDeliverAt(""); + setEstimateEtb(""); setSent(true); } + const needBooking = !bookingLoading && !bookingId; + return (
@@ -88,62 +78,78 @@ function LaundryInner() { Laundry service

- Select pieces and optional express surcharge. Mock request — pickup at reception. + Submit a real laundry request attached to your active booking.

- + View profile →
+ {needBooking ? ( +
+ Sign in with a booking code or use a reservation on your account to sync laundry with the + hotel. +
+ ) : null} + + {submitErr ? ( +
+ {submitErr} +
+ ) : null} + {sent ? (
- Request logged (demo). Our team will confirm timing by phone. + Laundry request submitted successfully.
) : null}
-
- {laundryItems.map((row) => ( -
-
-

{row.name}

-

- {row.description} · ${row.priceUsd}/{row.unit} -

-
-
- - {qty[row.id] ?? 0} - -
-
- ))} -