From aebf8e3d398b63227aea20b32c964e6521c82f48 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 28 Jun 2026 02:10:55 +0000 Subject: [PATCH] Autosave: 20260628-021058 --- backend/src/config.js | 4 + backend/src/index.js | 6 +- backend/src/routes/ota_connections.js | 25 +- backend/src/routes/stripe.js | 289 ++++ backend/src/services/otaIntegration.js | 772 ++++++++++ frontend/src/components/AsideMenuLayer.tsx | 3 +- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 5 + frontend/src/pages/front-desk.tsx | 1618 ++++++++++++++++++++ frontend/src/pages/index.tsx | 343 +++-- frontend/src/pages/search.tsx | 4 +- frontend/src/pages/stripe/cancel.tsx | 64 + frontend/src/pages/stripe/success.tsx | 289 ++++ 14 files changed, 3265 insertions(+), 163 deletions(-) create mode 100644 backend/src/routes/stripe.js create mode 100644 backend/src/services/otaIntegration.js create mode 100644 frontend/src/pages/front-desk.tsx create mode 100644 frontend/src/pages/stripe/cancel.tsx create mode 100644 frontend/src/pages/stripe/success.tsx diff --git a/backend/src/config.js b/backend/src/config.js index d0a880c..02dee79 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -67,6 +67,10 @@ const config = { gpt_key: process.env.GPT_KEY || '', + stripe: { + secretKey: process.env.STRIPE_SECRET_KEY || '', + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', + }, }; config.pexelsKey = process.env.PEXELS_KEY || ''; diff --git a/backend/src/index.js b/backend/src/index.js index bfb5788..32759ff 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -6,7 +6,6 @@ const passport = require('passport'); const path = require('path'); const fs = require('fs'); const bodyParser = require('body-parser'); -const db = require('./db/models'); const config = require('./config'); const swaggerUI = require('swagger-ui-express'); const swaggerJsDoc = require('swagger-jsdoc'); @@ -20,6 +19,7 @@ const pexelsRoutes = require('./routes/pexels'); const organizationForAuthRoutes = require('./routes/organizationLogin'); const openaiRoutes = require('./routes/openai'); +const stripeRoutes = require('./routes/stripe'); @@ -135,6 +135,8 @@ app.use('/api-docs', function (req, res, next) { app.use(cors({origin: true})); require('./auth/auth'); +app.use('/api/stripe/webhook', stripeRoutes.webhookRouter); + app.use(bodyParser.json()); app.use('/api/auth', authRoutes); @@ -205,6 +207,8 @@ app.use('/api/audit_logs', passport.authenticate('jwt', {session: false}), audit app.use('/api/daily_snapshots', passport.authenticate('jwt', {session: false}), daily_snapshotsRoutes); +app.use('/api/stripe', passport.authenticate('jwt', {session: false}), stripeRoutes.checkoutRouter); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/ota_connections.js b/backend/src/routes/ota_connections.js index a20b0a9..bae2ded 100644 --- a/backend/src/routes/ota_connections.js +++ b/backend/src/routes/ota_connections.js @@ -3,9 +3,9 @@ const express = require('express'); const Ota_connectionsService = require('../services/ota_connections'); const Ota_connectionsDBApi = require('../db/api/ota_connections'); +const OtaIntegrationService = require('../services/otaIntegration'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); const router = express.Router(); @@ -132,6 +132,29 @@ router.post('/bulk-import', wrapAsync(async (req, res) => { res.status(200).send(payload); })); +router.put('/sync-all', wrapAsync(async (req, res) => { + const syncPayload = req.body.data || req.body || {}; + const payload = await OtaIntegrationService.syncAll(syncPayload, req.currentUser); + res.status(200).send(payload); +})); + +router.put('/:id/sync', wrapAsync(async (req, res) => { + const syncPayload = req.body.data || req.body || {}; + const payload = await OtaIntegrationService.syncConnection(req.params.id, syncPayload, req.currentUser); + res.status(200).send(payload); +})); + +router.post('/:id/import-reservations', wrapAsync(async (req, res) => { + const syncPayload = { ...(req.body.data || req.body || {}), mode: 'import' }; + const payload = await OtaIntegrationService.syncConnection(req.params.id, syncPayload, req.currentUser); + res.status(200).send(payload); +})); + +router.get('/:id/export', wrapAsync(async (req, res) => { + const payload = await OtaIntegrationService.exportConnection(req.params.id, req.query || {}, req.currentUser); + res.status(200).send(payload); +})); + /** * @swagger * /api/ota_connections/{id}: diff --git a/backend/src/routes/stripe.js b/backend/src/routes/stripe.js new file mode 100644 index 0000000..6f09d85 --- /dev/null +++ b/backend/src/routes/stripe.js @@ -0,0 +1,289 @@ +const express = require('express'); +const axios = require('axios'); +const crypto = require('crypto'); + +const config = require('../config'); +const { wrapAsync, commonErrorHandler } = require('../helpers'); + +const checkoutRouter = express.Router(); +const webhookRouter = express.Router(); + +const STRIPE_API_BASE_URL = 'https://api.stripe.com/v1'; +const DEFAULT_CURRENCY = 'usd'; +const MAX_PAYMENT_AMOUNT = 50000; + +const getStripeSecretKey = () => process.env.STRIPE_SECRET_KEY || config.stripe?.secretKey || ''; +const getStripeWebhookSecret = () => process.env.STRIPE_WEBHOOK_SECRET || config.stripe?.webhookSecret || ''; + +const normalizeBaseUrl = (baseUrl) => { + if (!baseUrl) return ''; + + try { + return new URL(baseUrl).origin; + } catch (error) { + return String(baseUrl).replace(/\/$/, ''); + } +}; + +const getClientBaseUrl = (req) => { + const fromHeader = req.get('origin') || req.get('referer'); + const fromEnv = process.env.FRONTEND_URL || config.backUrl; + + return normalizeBaseUrl(fromHeader || fromEnv || ''); +}; + +const toStripeAmount = (amount) => { + const numericAmount = Number(amount); + + if (!Number.isFinite(numericAmount) || numericAmount <= 0) { + return null; + } + + if (numericAmount > MAX_PAYMENT_AMOUNT) { + return null; + } + + return Math.round(numericAmount * 100); +}; + +const appendMetadata = (params, metadata) => { + Object.entries(metadata).forEach(([key, value]) => { + if (value !== undefined && value !== null && String(value).trim()) { + params.append(`metadata[${key}]`, String(value)); + } + }); +}; + +const stripeHeaders = (secretKey) => ({ + Authorization: `Bearer ${secretKey}`, + 'Content-Type': 'application/x-www-form-urlencoded', +}); + +checkoutRouter.post( + '/create-checkout-session', + wrapAsync(async (req, res) => { + const stripeSecretKey = getStripeSecretKey(); + + if (!stripeSecretKey) { + return res.status(400).send({ + code: 'stripe_not_configured', + message: 'Stripe is not configured yet. Add STRIPE_SECRET_KEY to the backend environment, then try again.', + }); + } + + const { + reservationId, + reservationNumber, + guestName, + guestEmail, + roomNumber, + amount, + currency = DEFAULT_CURRENCY, + } = req.body; + + const amountInCents = toStripeAmount(amount); + + if (!amountInCents) { + return res.status(400).send({ + code: 'invalid_payment_amount', + message: `Payment amount must be greater than 0 and no more than ${MAX_PAYMENT_AMOUNT}.`, + }); + } + + const clientBaseUrl = getClientBaseUrl(req); + + if (!clientBaseUrl) { + return res.status(400).send({ + code: 'missing_client_url', + message: 'Unable to determine the frontend URL for Stripe success and cancel redirects.', + }); + } + + const safeReservationId = reservationId ? String(reservationId) : ''; + const safeReservationNumber = reservationNumber ? String(reservationNumber) : 'HotelPilot reservation'; + const safeGuestName = guestName ? String(guestName) : 'Hotel guest'; + const successUrl = `${clientBaseUrl}/stripe/success?session_id={CHECKOUT_SESSION_ID}&reservationId=${encodeURIComponent(safeReservationId)}`; + const cancelUrl = `${clientBaseUrl}/stripe/cancel?reservationId=${encodeURIComponent(safeReservationId)}`; + + const params = new URLSearchParams(); + params.append('mode', 'payment'); + params.append('success_url', successUrl); + params.append('cancel_url', cancelUrl); + params.append('client_reference_id', safeReservationId || safeReservationNumber); + params.append('line_items[0][price_data][currency]', String(currency).toLowerCase()); + params.append('line_items[0][price_data][unit_amount]', String(amountInCents)); + params.append('line_items[0][price_data][product_data][name]', `HotelPilot PMS · ${safeReservationNumber}`); + params.append( + 'line_items[0][price_data][product_data][description]', + `${safeGuestName}${roomNumber ? ` · Room ${roomNumber}` : ''}`, + ); + params.append('line_items[0][quantity]', '1'); + + if (guestEmail && String(guestEmail).includes('@')) { + params.append('customer_email', String(guestEmail)); + } + + appendMetadata(params, { + reservationId: safeReservationId, + reservationNumber: safeReservationNumber, + guestName: safeGuestName, + roomNumber, + createdByUserId: req.currentUser?.id, + organizationId: req.currentUser?.organizationId, + source: 'hotelpilot_front_desk', + }); + + try { + const stripeResponse = await axios.post( + `${STRIPE_API_BASE_URL}/checkout/sessions`, + params, + { + headers: stripeHeaders(stripeSecretKey), + timeout: 20000, + }, + ); + + return res.status(200).send({ + id: stripeResponse.data.id, + url: stripeResponse.data.url, + status: stripeResponse.data.status, + payment_status: stripeResponse.data.payment_status, + }); + } catch (error) { + console.error('Stripe Checkout session creation failed', { + status: error.response?.status, + data: error.response?.data, + }); + error.message = error.response?.data?.error?.message || error.message; + throw error; + } + }), +); + +checkoutRouter.get( + '/checkout-session/:sessionId', + wrapAsync(async (req, res) => { + const stripeSecretKey = getStripeSecretKey(); + + if (!stripeSecretKey) { + return res.status(400).send({ + code: 'stripe_not_configured', + message: 'Stripe is not configured yet. Add STRIPE_SECRET_KEY to the backend environment, then try again.', + }); + } + + const { sessionId } = req.params; + + if (!sessionId || !sessionId.startsWith('cs_')) { + return res.status(400).send({ + code: 'invalid_session_id', + message: 'A valid Stripe Checkout session id is required.', + }); + } + + try { + const stripeResponse = await axios.get( + `${STRIPE_API_BASE_URL}/checkout/sessions/${encodeURIComponent(sessionId)}`, + { + headers: { Authorization: `Bearer ${stripeSecretKey}` }, + timeout: 20000, + }, + ); + + const session = stripeResponse.data; + + return res.status(200).send({ + id: session.id, + status: session.status, + payment_status: session.payment_status, + amount_total: session.amount_total, + currency: session.currency, + customer_email: session.customer_details?.email || session.customer_email, + metadata: session.metadata, + }); + } catch (error) { + console.error('Stripe Checkout session retrieval failed', { + sessionId, + status: error.response?.status, + data: error.response?.data, + }); + error.message = error.response?.data?.error?.message || error.message; + throw error; + } + }), +); + +const verifyStripeSignature = (rawBody, signatureHeader, webhookSecret) => { + if (!signatureHeader || !webhookSecret) return false; + + const timestamp = signatureHeader + .split(',') + .find((item) => item.startsWith('t=')) + ?.slice(2); + const expectedSignature = signatureHeader + .split(',') + .find((item) => item.startsWith('v1=')) + ?.slice(3); + + if (!timestamp || !expectedSignature) return false; + + const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`; + const computedSignature = crypto + .createHmac('sha256', webhookSecret) + .update(signedPayload, 'utf8') + .digest('hex'); + + const computedBuffer = Buffer.from(computedSignature, 'hex'); + const expectedBuffer = Buffer.from(expectedSignature, 'hex'); + + if (computedBuffer.length !== expectedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(computedBuffer, expectedBuffer); +}; + +webhookRouter.post( + '/', + express.raw({ type: 'application/json' }), + wrapAsync(async (req, res) => { + const webhookSecret = getStripeWebhookSecret(); + const signatureHeader = req.get('stripe-signature'); + + if (!webhookSecret) { + console.error('Stripe webhook rejected because STRIPE_WEBHOOK_SECRET is not configured.'); + return res.status(400).send({ + code: 'stripe_webhook_not_configured', + message: 'Stripe webhook secret is not configured.', + }); + } + + if (!verifyStripeSignature(req.body, signatureHeader, webhookSecret)) { + console.error('Stripe webhook signature verification failed.'); + return res.status(400).send({ + code: 'invalid_stripe_signature', + message: 'Invalid Stripe signature.', + }); + } + + const event = JSON.parse(req.body.toString('utf8')); + + if (event.type === 'checkout.session.completed') { + console.log('Stripe Checkout session completed', { + sessionId: event.data?.object?.id, + reservationId: event.data?.object?.metadata?.reservationId, + amountTotal: event.data?.object?.amount_total, + }); + } + + return res.status(200).send({ received: true, type: event.type }); + }), +); + +checkoutRouter.use('/', commonErrorHandler); +webhookRouter.use('/', commonErrorHandler); + +module.exports = { + checkoutRouter, + webhookRouter, +}; diff --git a/backend/src/services/otaIntegration.js b/backend/src/services/otaIntegration.js new file mode 100644 index 0000000..10e81b8 --- /dev/null +++ b/backend/src/services/otaIntegration.js @@ -0,0 +1,772 @@ +const db = require('../db/models'); + +const { Op } = db.Sequelize; + +const PROVIDER_LABELS = { + booking_com: 'Booking.com', + expedia: 'Expedia', + airbnb: 'Airbnb', + agoda: 'Agoda', + other: 'Other OTA', +}; + +const RESERVATION_STATUSES = [ + 'pending', + 'confirmed', + 'checked_in', + 'checked_out', + 'cancelled', + 'no_show', +]; + +const isUuid = (value) => + typeof value === 'string' && + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); + +const createHttpError = (message, code = 400) => { + const error = new Error(message); + error.code = code; + return error; +}; + +const stringValue = (value) => { + if (value === undefined || value === null) return null; + const normalized = String(value).trim(); + return normalized || null; +}; + +const numberValue = (value, fallback = 0) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const dateValue = (value) => { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date; +}; + +const addDays = (date, days) => { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +}; + +const eachDate = (startDate, days) => + Array.from({ length: days }, (_, index) => addDays(startDate, index).toISOString().slice(0, 10)); + +const getPath = (source, path) => { + if (!source || !path) return undefined; + + return path.split('.').reduce((current, key) => { + if (current === undefined || current === null) return undefined; + return current[key]; + }, source); +}; + +const firstDefined = (source, paths) => { + for (const path of paths) { + const value = getPath(source, path); + if (value !== undefined && value !== null && value !== '') return value; + } + + return undefined; +}; + +const splitName = (rawName) => { + const name = stringValue(rawName); + if (!name) return { firstName: 'OTA', lastName: 'Guest' }; + + const parts = name.split(/\s+/).filter(Boolean); + + if (parts.length === 1) return { firstName: parts[0], lastName: 'Guest' }; + + return { + firstName: parts.slice(0, -1).join(' '), + lastName: parts.slice(-1).join(' '), + }; +}; + +const normalizeProviderLabel = (provider) => PROVIDER_LABELS[provider] || 'OTA'; + +const normalizeStatus = (value) => { + const status = stringValue(value)?.toLowerCase().replace(/[\s-]+/g, '_'); + + if (!status) return 'confirmed'; + if (RESERVATION_STATUSES.includes(status)) return status; + + if (['booked', 'booking', 'accepted', 'active', 'reserved', 'commit'].includes(status)) { + return 'confirmed'; + } + + if (['modified', 'amended', 'updated'].includes(status)) return 'confirmed'; + if (['canceled', 'cancelled_by_guest', 'cancelled_by_host', 'void'].includes(status)) return 'cancelled'; + if (['noshow', 'no_showed'].includes(status)) return 'no_show'; + + return 'confirmed'; +}; + +const normalizeExternalReservation = (rawReservation, connection) => { + const externalReservationId = stringValue( + firstDefined(rawReservation, [ + 'externalReservationId', + 'external_reservation_id', + 'reservationId', + 'reservation_id', + 'confirmationCode', + 'confirmation_code', + 'confirmation', + 'itineraryId', + 'itinerary_id', + 'bookingId', + 'booking_id', + 'id', + ]), + ); + + if (!externalReservationId) { + throw createHttpError('OTA reservation import failed: external reservation id is required.'); + } + + const checkIn = dateValue( + firstDefined(rawReservation, [ + 'checkIn', + 'check_in', + 'check_in_at', + 'arrivalDate', + 'arrival_date', + 'startDate', + 'start_date', + 'stayDateRange.start', + 'dates.start', + 'dates.start_date', + ]), + ); + + const checkOut = dateValue( + firstDefined(rawReservation, [ + 'checkOut', + 'check_out', + 'check_out_at', + 'departureDate', + 'departure_date', + 'endDate', + 'end_date', + 'stayDateRange.end', + 'dates.end', + 'dates.end_date', + ]), + ); + + if (!checkIn || !checkOut || checkOut <= checkIn) { + throw createHttpError( + `OTA reservation ${externalReservationId} import failed: valid check-in and check-out dates are required.`, + ); + } + + const fullName = firstDefined(rawReservation, [ + 'guestName', + 'guest_name', + 'guest.name', + 'guest.fullName', + 'guest.full_name', + 'booker.name', + 'primaryGuest.name', + ]); + const splitGuestName = splitName(fullName); + + const firstName = stringValue( + firstDefined(rawReservation, [ + 'guestFirstName', + 'guest_first_name', + 'guest.firstName', + 'guest.first_name', + 'booker.firstName', + 'booker.first_name', + 'primaryGuest.firstName', + 'primaryGuest.first_name', + ]), + ) || splitGuestName.firstName; + + const lastName = stringValue( + firstDefined(rawReservation, [ + 'guestLastName', + 'guest_last_name', + 'guest.lastName', + 'guest.last_name', + 'booker.lastName', + 'booker.last_name', + 'primaryGuest.lastName', + 'primaryGuest.last_name', + ]), + ) || splitGuestName.lastName; + + return { + externalReservationId, + reservationNumber: + stringValue(firstDefined(rawReservation, ['reservationNumber', 'reservation_number', 'code', 'reference'])) || + `${connection.provider || 'OTA'}-${externalReservationId}`, + status: normalizeStatus(firstDefined(rawReservation, ['status', 'bookingStatus', 'booking_status', 'state'])), + checkIn, + checkOut, + adults: Math.max(1, numberValue(firstDefined(rawReservation, ['adults', 'adultCount', 'adult_count', 'guestCounts.adults']), 1)), + children: Math.max(0, numberValue(firstDefined(rawReservation, ['children', 'childCount', 'child_count', 'guestCounts.children']), 0)), + totalAmount: numberValue(firstDefined(rawReservation, ['totalAmount', 'total_amount', 'amount', 'price', 'totalPrice.amount', 'pricing.total', 'financials.total']), 0), + taxAmount: numberValue(firstDefined(rawReservation, ['taxAmount', 'tax_amount', 'tax', 'totalPrice.tax', 'pricing.tax']), 0), + depositAmount: numberValue(firstDefined(rawReservation, ['depositAmount', 'deposit_amount', 'deposit', 'prepaidAmount']), 0), + specialRequests: stringValue(firstDefined(rawReservation, ['specialRequests', 'special_requests', 'notes', 'guest.notes'])), + cancellationReason: stringValue(firstDefined(rawReservation, ['cancellationReason', 'cancellation_reason', 'cancelReason'])), + guest: { + externalGuestId: stringValue(firstDefined(rawReservation, ['guest.id', 'guestId', 'guest_id', 'booker.id', 'primaryGuest.id'])), + firstName, + lastName, + email: stringValue(firstDefined(rawReservation, ['guest.email', 'booker.email', 'primaryGuest.email', 'email'])), + phone: stringValue(firstDefined(rawReservation, ['guest.phone', 'booker.phone', 'primaryGuest.phone', 'phone'])), + country: stringValue(firstDefined(rawReservation, ['guest.country', 'booker.country', 'primaryGuest.country', 'country'])), + }, + room: { + id: stringValue(firstDefined(rawReservation, ['roomId', 'room_id', 'room.id'])), + number: stringValue(firstDefined(rawReservation, ['roomNumber', 'room_number', 'room.number', 'unitName', 'unit_name', 'listingId', 'listing_id'])), + }, + roomType: { + id: stringValue(firstDefined(rawReservation, ['roomTypeId', 'room_type_id', 'roomType.id', 'room_type.id'])), + name: stringValue(firstDefined(rawReservation, ['roomTypeName', 'room_type_name', 'roomType.name', 'room_type.name', 'unitTypeName'])), + }, + ratePlanId: stringValue(firstDefined(rawReservation, ['ratePlanId', 'rate_plan_id', 'ratePlan.id', 'rate_plan.id'])), + hotelId: stringValue(firstDefined(rawReservation, ['hotelId', 'hotel_id', 'hotel.id'])), + raw: rawReservation, + }; +}; + +const normalizeInboundReservations = (payload) => { + if (!payload) return []; + if (Array.isArray(payload)) return payload; + if (Array.isArray(payload.reservations)) return payload.reservations; + if (Array.isArray(payload.bookings)) return payload.bookings; + if (Array.isArray(payload.data)) return payload.data; + if (payload.reservation) return [payload.reservation]; + if (payload.booking) return [payload.booking]; + return []; +}; + +const currentOrganizationId = (currentUser, connection) => + currentUser?.organizationsId || + currentUser?.organizationId || + currentUser?.organizations?.id || + connection.organizationsId || + null; + +const scopedWhere = (baseWhere, connection, currentUser) => { + const where = { ...baseWhere }; + const organizationId = currentOrganizationId(currentUser, connection); + + if (connection.hotelId) where.hotelId = connection.hotelId; + if (organizationId) where.organizationsId = organizationId; + + return where; +}; + +const getConnection = async (connectionId, transaction) => { + if (!isUuid(connectionId)) throw createHttpError('Valid OTA connection id is required.'); + + const connection = await db.ota_connections.findByPk(connectionId, { transaction }); + + if (!connection) throw createHttpError('OTA connection was not found.', 404); + + return connection; +}; + +const ensureSalesChannel = async (connection, currentUser, transaction) => { + if (connection.sales_channelId) { + const existingChannel = await db.sales_channels.findByPk(connection.sales_channelId, { transaction }); + if (existingChannel) return existingChannel; + } + + const providerName = normalizeProviderLabel(connection.provider); + const externalReference = `ota:${connection.provider || 'other'}:${connection.account_identifier || connection.id}`; + const organizationId = currentOrganizationId(currentUser, connection); + const where = { external_reference: externalReference }; + + if (organizationId) where.organizationsId = organizationId; + + let channel = await db.sales_channels.findOne({ where, transaction }); + + if (!channel) { + channel = await db.sales_channels.create( + { + channel_type: 'ota', + name: providerName, + external_reference: externalReference, + active: true, + hotelId: connection.hotelId || null, + organizationsId: organizationId, + createdById: currentUser?.id || null, + updatedById: currentUser?.id || null, + }, + { transaction }, + ); + } + + await connection.update({ sales_channelId: channel.id, updatedById: currentUser?.id || null }, { transaction }); + + return channel; +}; + +const ensureGuest = async (reservation, connection, currentUser, transaction) => { + const organizationId = currentOrganizationId(currentUser, connection); + const guestImportHash = `ota-guest:${connection.id}:${reservation.guest.externalGuestId || reservation.guest.email || reservation.externalReservationId}`; + const guestWhere = reservation.guest.email + ? { + [Op.or]: [{ importHash: guestImportHash }, { email: reservation.guest.email }], + } + : { importHash: guestImportHash }; + + let guest = await db.guests.findOne({ where: guestWhere, transaction }); + const guestPayload = { + first_name: reservation.guest.firstName, + last_name: reservation.guest.lastName, + email: reservation.guest.email, + phone: reservation.guest.phone, + country: reservation.guest.country, + importHash: guestImportHash, + hotelId: reservation.hotelId || connection.hotelId || null, + organizationsId: organizationId, + updatedById: currentUser?.id || null, + }; + + if (guest) { + await guest.update(guestPayload, { transaction }); + return guest; + } + + guest = await db.guests.create( + { + ...guestPayload, + createdById: currentUser?.id || null, + }, + { transaction }, + ); + + return guest; +}; + +const findRoomType = async (reservation, connection, currentUser, transaction) => { + if (isUuid(reservation.roomType.id)) { + const roomType = await db.room_types.findByPk(reservation.roomType.id, { transaction }); + if (roomType) return roomType; + } + + if (!reservation.roomType.name) return null; + + return db.room_types.findOne({ + where: scopedWhere( + { + name: { [Op.iLike]: reservation.roomType.name }, + }, + connection, + currentUser, + ), + transaction, + }); +}; + +const findExplicitRoom = async (reservation, connection, currentUser, transaction) => { + if (isUuid(reservation.room.id)) { + const room = await db.rooms.findByPk(reservation.room.id, { transaction }); + if (room) return room; + } + + if (!reservation.room.number) return null; + + return db.rooms.findOne({ + where: scopedWhere( + { + room_number: reservation.room.number, + }, + connection, + currentUser, + ), + transaction, + }); +}; + +const reservationOverlaps = (reservation, checkIn, checkOut) => { + const existingCheckIn = dateValue(reservation.check_in_at); + const existingCheckOut = dateValue(reservation.check_out_at); + + if (!existingCheckIn || !existingCheckOut) return false; + + return existingCheckIn < checkOut && existingCheckOut > checkIn; +}; + +const allocateRoom = async (reservation, roomType, connection, currentUser, transaction) => { + const explicitRoom = await findExplicitRoom(reservation, connection, currentUser, transaction); + if (explicitRoom) return explicitRoom; + + const where = scopedWhere( + { + active: true, + status: { [Op.notIn]: ['out_of_service'] }, + }, + connection, + currentUser, + ); + + if (roomType) where.room_typeId = roomType.id; + + const candidateRooms = await db.rooms.findAll({ where, transaction, order: [['room_number', 'ASC']] }); + + if (!candidateRooms.length) return null; + + const roomIds = candidateRooms.map((room) => room.id); + const overlappingReservations = await db.reservations.findAll({ + where: { + roomId: { [Op.in]: roomIds }, + status: { [Op.notIn]: ['cancelled', 'no_show', 'checked_out'] }, + check_in_at: { [Op.lt]: reservation.checkOut }, + check_out_at: { [Op.gt]: reservation.checkIn }, + }, + transaction, + }); + const blockedRoomIds = new Set( + overlappingReservations + .filter((existingReservation) => reservationOverlaps(existingReservation, reservation.checkIn, reservation.checkOut)) + .map((existingReservation) => existingReservation.roomId), + ); + + return candidateRooms.find((room) => !blockedRoomIds.has(room.id)) || null; +}; + +const upsertReservation = async (rawReservation, connection, currentUser, transaction) => { + const normalized = normalizeExternalReservation(rawReservation, connection); + const providerLabel = normalizeProviderLabel(connection.provider); + const importHash = `ota-reservation:${connection.id}:${normalized.externalReservationId}`; + const salesChannel = await ensureSalesChannel(connection, currentUser, transaction); + const guest = await ensureGuest(normalized, connection, currentUser, transaction); + const roomType = await findRoomType(normalized, connection, currentUser, transaction); + const room = await allocateRoom(normalized, roomType, connection, currentUser, transaction); + const organizationId = currentOrganizationId(currentUser, connection); + const existingReservation = await db.reservations.findOne({ where: { importHash }, transaction }); + const status = normalizeStatus(normalized.status); + const isCancelled = status === 'cancelled'; + const payload = { + reservation_number: normalized.reservationNumber, + check_in_at: normalized.checkIn, + check_out_at: normalized.checkOut, + adults: normalized.adults, + children: normalized.children, + status, + total_amount: normalized.totalAmount, + tax_amount: normalized.taxAmount, + deposit_amount: normalized.depositAmount, + special_requests: normalized.specialRequests, + internal_notes: `Imported from ${providerLabel}. External reservation: ${normalized.externalReservationId}.`, + cancelled_at: isCancelled ? new Date() : null, + cancellation_reason: isCancelled ? normalized.cancellationReason || `Cancelled by ${providerLabel}` : null, + importHash, + hotelId: normalized.hotelId || connection.hotelId || null, + guestId: guest?.id || null, + roomId: room?.id || null, + room_typeId: roomType?.id || room?.room_typeId || null, + rate_planId: isUuid(normalized.ratePlanId) ? normalized.ratePlanId : null, + sales_channelId: salesChannel?.id || null, + organizationsId: organizationId, + updatedById: currentUser?.id || null, + }; + + let reservationRecord; + let action = 'created'; + + if (existingReservation) { + await existingReservation.update(payload, { transaction }); + reservationRecord = existingReservation; + action = 'updated'; + } else { + reservationRecord = await db.reservations.create( + { + ...payload, + createdById: currentUser?.id || null, + }, + { transaction }, + ); + } + + if (status === 'checked_in' && room) { + await room.update({ status: 'occupied', updatedById: currentUser?.id || null }, { transaction }); + } + + return { + action, + status, + id: reservationRecord.id, + reservation_number: reservationRecord.reservation_number, + room_id: room?.id || null, + room_number: room?.room_number || null, + guest_id: guest?.id || null, + }; +}; + +const importReservations = async (connection, payload, currentUser, transaction) => { + const inboundReservations = normalizeInboundReservations(payload); + const results = { + received: inboundReservations.length, + created: 0, + updated: 0, + cancelled: 0, + skipped: 0, + errors: [], + reservations: [], + }; + + for (const rawReservation of inboundReservations) { + try { + const result = await upsertReservation(rawReservation, connection, currentUser, transaction); + results.reservations.push(result); + + if (result.action === 'created') results.created += 1; + if (result.action === 'updated') results.updated += 1; + if (result.status === 'cancelled') results.cancelled += 1; + } catch (error) { + console.error('OTA reservation import failed', error); + results.skipped += 1; + results.errors.push(error.message || 'Reservation import failed.'); + } + } + + return results; +}; + +const roomStatusExport = (status) => { + if (status === 'out_of_service') return 'blocked'; + if (status === 'occupied') return 'occupied'; + if (['dirty', 'cleaning'].includes(status)) return 'available_needs_housekeeping'; + return 'available'; +}; + +const buildExportSnapshot = async (connection, payload, currentUser, transaction) => { + const startDate = dateValue(payload?.startDate || payload?.start_date) || new Date(); + const days = Math.min(Math.max(numberValue(payload?.days, 30), 1), 180); + const dateRange = eachDate(startDate, days); + const roomWhere = scopedWhere({ active: true }, connection, currentUser); + const roomTypeWhere = scopedWhere({ active: true }, connection, currentUser); + + const [rooms, roomTypes, reservations, ratePlans, pricingRules] = await Promise.all([ + db.rooms.findAll({ + where: roomWhere, + include: [{ model: db.room_types, as: 'room_type' }], + transaction, + order: [['room_number', 'ASC']], + }), + db.room_types.findAll({ where: roomTypeWhere, transaction, order: [['name', 'ASC']] }), + db.reservations.findAll({ + where: scopedWhere( + { + status: { [Op.notIn]: ['cancelled', 'no_show'] }, + check_in_at: { [Op.lt]: addDays(startDate, days) }, + check_out_at: { [Op.gt]: startDate }, + }, + connection, + currentUser, + ), + transaction, + }), + db.rate_plans.findAll({ + where: scopedWhere({ active: true }, connection, currentUser), + transaction, + order: [['name', 'ASC']], + }), + db.dynamic_pricing_rules.findAll({ + where: scopedWhere({ active: true }, connection, currentUser), + transaction, + order: [['name', 'ASC']], + }), + ]); + + const activeRooms = rooms.filter((room) => room.status !== 'out_of_service'); + const roomsByType = activeRooms.reduce((accumulator, room) => { + const roomTypeId = room.room_typeId || 'unassigned'; + accumulator[roomTypeId] = accumulator[roomTypeId] || []; + accumulator[roomTypeId].push(room); + return accumulator; + }, {}); + + const inventory = []; + + for (const roomType of roomTypes) { + const typedRooms = roomsByType[roomType.id] || []; + + for (const date of dateRange) { + const currentDate = new Date(`${date}T12:00:00Z`); + const bookedRoomIds = new Set( + reservations + .filter( + (reservation) => + reservation.room_typeId === roomType.id && + dateValue(reservation.check_in_at) <= currentDate && + dateValue(reservation.check_out_at) > currentDate, + ) + .map((reservation) => reservation.roomId) + .filter(Boolean), + ); + + inventory.push({ + date, + room_type_id: roomType.id, + room_type_name: roomType.name, + rooms_total: typedRooms.length, + rooms_booked: bookedRoomIds.size, + rooms_available: Math.max(typedRooms.length - bookedRoomIds.size, 0), + }); + } + } + + return { + generated_at: new Date().toISOString(), + provider: connection.provider, + account_identifier: connection.account_identifier, + inventory_sync_enabled: Boolean(connection.inventory_sync_enabled), + rate_sync_enabled: Boolean(connection.rate_sync_enabled), + rooms: rooms.map((room) => ({ + id: room.id, + room_number: room.room_number, + room_type_id: room.room_typeId, + room_type_name: room.room_type?.name || null, + status: roomStatusExport(room.status), + active: room.active, + })), + inventory, + rates: roomTypes.map((roomType) => ({ + room_type_id: roomType.id, + room_type_name: roomType.name, + base_rate: numberValue(roomType.base_rate, 0), + currency: 'USD', + active_rate_plans: ratePlans.map((ratePlan) => ({ + id: ratePlan.id, + name: ratePlan.name, + plan_type: ratePlan.plan_type, + refundable: ratePlan.refundable, + deposit_percent: numberValue(ratePlan.deposit_percent, 0), + })), + dynamic_pricing_rules: pricingRules + .filter((rule) => !rule.room_typeId || rule.room_typeId === roomType.id) + .map((rule) => ({ + id: rule.id, + name: rule.name, + rule_type: rule.rule_type, + adjustment_percent: numberValue(rule.adjustment_percent, 0), + adjustment_amount: numberValue(rule.adjustment_amount, 0), + })), + })), + }; +}; + +module.exports = class OtaIntegrationService { + static async syncConnection(connectionId, payload = {}, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const connection = await getConnection(connectionId, transaction); + const mode = payload.mode || 'full'; + const shouldImport = ['full', 'import', 'reservations'].includes(mode); + const shouldExport = ['full', 'export', 'inventory', 'rates'].includes(mode); + const inbound = shouldImport + ? await importReservations(connection, payload, currentUser, transaction) + : { received: 0, created: 0, updated: 0, cancelled: 0, skipped: 0, errors: [], reservations: [] }; + const outbound = shouldExport + ? await buildExportSnapshot(connection, payload, currentUser, transaction) + : null; + const nextStatus = inbound.errors.length ? 'warning' : 'ok'; + + if (!payload.dryRun) { + await connection.update( + { + last_sync_at: new Date(), + sync_status: nextStatus, + updatedById: currentUser?.id || null, + }, + { transaction }, + ); + } + + if (payload.dryRun) { + await transaction.rollback(); + } else { + await transaction.commit(); + } + + return { + connection: { + id: connection.id, + provider: connection.provider, + account_identifier: connection.account_identifier, + sync_status: payload.dryRun ? connection.sync_status : nextStatus, + last_sync_at: payload.dryRun ? connection.last_sync_at : new Date().toISOString(), + }, + inbound, + outbound, + dryRun: Boolean(payload.dryRun), + message: inbound.errors.length + ? 'OTA sync completed with warnings. Review skipped reservations.' + : 'OTA sync completed successfully.', + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async syncAll(payload = {}, currentUser) { + const connections = await db.ota_connections.findAll({ + where: { + [Op.or]: [ + { inventory_sync_enabled: true }, + { rate_sync_enabled: true }, + { sync_status: { [Op.ne]: 'disabled' } }, + ], + }, + order: [['provider', 'ASC']], + }); + const results = []; + + for (const connection of connections) { + try { + results.push(await this.syncConnection(connection.id, payload, currentUser)); + } catch (error) { + console.error(`OTA sync failed for connection ${connection.id}`, error); + await connection.update({ sync_status: 'error', updatedById: currentUser?.id || null }); + results.push({ + connection: { + id: connection.id, + provider: connection.provider, + account_identifier: connection.account_identifier, + sync_status: 'error', + }, + error: error.message || 'OTA sync failed.', + }); + } + } + + return { + count: results.length, + ok: results.filter((result) => !result.error && !result.inbound?.errors?.length).length, + warnings: results.filter((result) => result.inbound?.errors?.length).length, + errors: results.filter((result) => result.error).length, + results, + }; + } + + static async exportConnection(connectionId, payload = {}, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const connection = await getConnection(connectionId, transaction); + const snapshot = await buildExportSnapshot(connection, payload, currentUser, transaction); + await transaction.commit(); + return snapshot; + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index a3ad45b..6da3e68 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index c25c130..76b8b81 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,11 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/front-desk', + icon: icon.mdiCalendarClock, + label: 'Front Desk', + }, { href: '/users/users-list', diff --git a/frontend/src/pages/front-desk.tsx b/frontend/src/pages/front-desk.tsx new file mode 100644 index 0000000..aa7524c --- /dev/null +++ b/frontend/src/pages/front-desk.tsx @@ -0,0 +1,1618 @@ +import { + mdiCalendarClock, + mdiCheckCircleOutline, + mdiCreditCardCheckOutline, + mdiDoorOpen, + mdiHomeCity, + mdiPlusCircleOutline, +} from '@mdi/js' +import axios from 'axios' +import Head from 'next/head' +import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react' +import BaseButton from '../components/BaseButton' +import CardBox from '../components/CardBox' +import SectionMain from '../components/SectionMain' +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' +import { getPageTitle } from '../config' +import LayoutAuthenticated from '../layouts/Authenticated' + +type RoomStatus = 'Available' | 'Occupied' | 'Dirty' | 'Cleaning' | 'Inspected' | 'Out of Service' +type ReservationStatus = 'Pending' | 'Confirmed' | 'Checked In' | 'Checked Out' | 'Cancelled' | 'No Show' +type DataMode = 'loading' | 'live' | 'demo' + +type EntityRef = { + id?: string + name?: string + label?: string + first_name?: string + last_name?: string + email?: string + phone?: string + room_number?: string + channel_type?: string + base_rate?: number | string | null + currency?: string + default_tax_rate?: number | string | null +} + +type RawRoom = { + id: string + room_number?: string + floor?: string + status?: string + notes?: string + active?: boolean + hotel?: EntityRef | null + room_type?: EntityRef | null + hotelId?: string + room_typeId?: string +} + +type RawReservation = { + id: string + reservation_number?: string + check_in_at?: string + check_out_at?: string + adults?: number | string | null + children?: number | string | null + status?: string + total_amount?: number | string | null + tax_amount?: number | string | null + deposit_amount?: number | string | null + special_requests?: string + internal_notes?: string + createdAt?: string + guest?: EntityRef | null + room?: RawRoom | null + room_type?: EntityRef | null + sales_channel?: EntityRef | null + hotel?: EntityRef | null + guestId?: string + roomId?: string + room_typeId?: string + sales_channelId?: string + hotelId?: string +} + +type RawPayment = { + id: string + method?: string + amount?: number | string | null + status?: string + paid_at?: string + receipt_number?: string + reservation?: EntityRef | null + reservationId?: string +} + +type RawHousekeepingTask = { + id: string + scheduled_for?: string + started_at?: string + completed_at?: string + status?: string + task_type?: string + notes?: string + room?: RawRoom | null + roomId?: string +} + +type RawMaintenanceTicket = { + id: string + category?: string + priority?: string + title?: string + description?: string + status?: string + opened_at?: string + closed_at?: string + room?: RawRoom | null + roomId?: string +} + +type RawPricingRule = { + id: string + name?: string + rule_type?: string + adjustment_percent?: number | string | null + adjustment_amount?: number | string | null + min_occupancy_percent?: number | string | null + max_occupancy_percent?: number | string | null + min_days_before?: number | string | null + max_days_before?: number | string | null + applies_days_of_week?: string + active?: boolean + room_type?: EntityRef | null +} + +type RawOtaConnection = { + id: string + provider?: string + account_identifier?: string + inventory_sync_enabled?: boolean + rate_sync_enabled?: boolean + last_sync_at?: string + sync_status?: string + sales_channel?: EntityRef | null +} + +type RawSalesChannel = { + id: string + name?: string + channel_type?: string + active?: boolean +} + +type Room = { + id: string + number: string + type: string + floor: string + rate: number + status: RoomStatus + hotelId?: string + roomTypeId?: string + raw?: RawRoom +} + +type Reservation = { + id: string + reservationNumber: string + guestName: string + email: string + phone: string + roomId: string + roomTypeId?: string + hotelId?: string + guestId?: string + salesChannelId?: string + checkIn: string + checkOut: string + adults: number + children: number + source: string + status: ReservationStatus + totalAmount: number + paidAmount: number + createdAt: string + raw?: RawReservation +} + +type StripeCheckoutResponse = { + id?: string + url?: string + status?: string + payment_status?: string +} + +type OtaSyncResult = { + connection?: { + id?: string + provider?: string + account_identifier?: string + sync_status?: string + last_sync_at?: string + } + inbound?: { + received?: number + created?: number + updated?: number + cancelled?: number + skipped?: number + errors?: string[] + } + outbound?: { + rooms?: unknown[] + inventory?: unknown[] + rates?: unknown[] + } + count?: number + ok?: number + warnings?: number + errors?: number + results?: OtaSyncResult[] + message?: string + error?: string +} + +type ReservationForm = { + guestName: string + email: string + phone: string + roomId: string + checkIn: string + checkOut: string + adults: string + children: string + source: string + nightlyRate: string + weeklyRate: string + deposit: string +} + +type ApiListResponse = { + rows?: T[] + count?: number +} + +type PmsApiData = { + rooms: RawRoom[] + reservations: RawReservation[] + payments: RawPayment[] + housekeepingTasks: RawHousekeepingTask[] + maintenanceTickets: RawMaintenanceTicket[] + pricingRules: RawPricingRule[] + otaConnections: RawOtaConnection[] + salesChannels: RawSalesChannel[] + hotels: EntityRef[] +} + +const today = new Date() +const toIsoDate = (date: Date) => date.toISOString().slice(0, 10) +const toDateTimeValue = (date: string) => `${date}T15:00` +const addDays = (date: Date, days: number) => { + const next = new Date(date) + next.setDate(next.getDate() + days) + return next +} + +const dateLabel = (date: string) => + new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric' }).format(new Date(`${date}T12:00:00`)) + +const dateTimeLabel = (date?: string) => { + if (!date) return 'Never synced' + + const parsed = new Date(date) + if (Number.isNaN(parsed.getTime())) return 'Never synced' + + return new Intl.DateTimeFormat('en', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(parsed) +} + +const money = (amount: number) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amount) + +const nightsBetween = (checkIn: string, checkOut: string) => { + const start = new Date(`${checkIn}T12:00:00`).getTime() + const end = new Date(`${checkOut}T12:00:00`).getTime() + return Math.max(1, Math.round((end - start) / 86400000)) +} + +const parseAmount = (value: number | string | null | undefined) => { + const parsed = Number(value || 0) + return Number.isFinite(parsed) ? parsed : 0 +} + +const rateFormValues = (nightlyRate: number) => ({ + nightlyRate: String(nightlyRate), + weeklyRate: String(nightlyRate * 7), +}) + +const rateAmount = (value: string, fallback: number) => { + if (value.trim() === '') return fallback + + return Math.max(0, parseAmount(value)) +} + +const calculateStayQuote = (checkIn: string, checkOut: string, nightlyRateInput: string, weeklyRateInput: string, fallbackNightlyRate: number) => { + const nights = nightsBetween(checkIn, checkOut) + const nightlyRate = rateAmount(nightlyRateInput, fallbackNightlyRate) + const weeklyRate = rateAmount(weeklyRateInput, nightlyRate * 7) + const fullWeeks = Math.floor(nights / 7) + const extraNights = nights % 7 + + return { + nights, + nightlyRate, + weeklyRate, + fullWeeks, + extraNights, + total: fullWeeks * weeklyRate + extraNights * nightlyRate, + } +} + +const quoteBreakdownText = (quote: ReturnType) => { + if (quote.fullWeeks && quote.extraNights) { + return `${quote.fullWeeks} week(s) × ${money(quote.weeklyRate)} + ${quote.extraNights} night(s) × ${money(quote.nightlyRate)}` + } + + if (quote.fullWeeks) { + return `${quote.fullWeeks} week(s) × ${money(quote.weeklyRate)}` + } + + return `${quote.nights} night(s) × ${money(quote.nightlyRate)}` +} + +const normalizeLabel = (value?: string | null) => + (value || '') + .split('_') + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(' ') + +const normalizeDate = (value?: string, fallback = toIsoDate(today)) => { + if (!value) return fallback + const date = new Date(value) + if (Number.isNaN(date.getTime())) return fallback + return toIsoDate(date) +} + +const balanceDue = (reservation: Reservation) => Math.max(reservation.totalAmount - reservation.paidAmount, 0) + +const summarizeOtaSync = (result: OtaSyncResult) => { + if (result.results) { + return `${result.ok || 0} OTA syncs ok, ${result.warnings || 0} warnings, ${result.errors || 0} errors across ${result.count || 0} connections.` + } + + const inbound = result.inbound + const outbound = result.outbound + + return `Imported ${inbound?.created || 0} new, updated ${inbound?.updated || 0}, cancelled ${inbound?.cancelled || 0}, skipped ${inbound?.skipped || 0}; exported ${outbound?.inventory?.length || 0} availability rows and ${outbound?.rates?.length || 0} rate groups.` +} + +const apiErrorMessage = (error: unknown) => { + if (axios.isAxiosError(error)) { + const data = error.response?.data + + if (typeof data === 'string') return data + + if (data && typeof data === 'object' && 'message' in data) { + const message = (data as { message?: unknown }).message + if (typeof message === 'string') return message + } + + return error.message + } + + if (error instanceof Error) return error.message + + return 'Unexpected PMS error.' +} + +const initialRooms: Room[] = [ + { id: '101', number: '101', type: 'Standard King', floor: '1', rate: 145, status: 'Available' }, + { id: '102', number: '102', type: 'Standard Double', floor: '1', rate: 155, status: 'Dirty' }, + { id: '201', number: '201', type: 'Deluxe Ocean', floor: '2', rate: 225, status: 'Occupied' }, + { id: '202', number: '202', type: 'Deluxe Ocean', floor: '2', rate: 225, status: 'Available' }, + { id: '301', number: '301', type: 'Family Suite', floor: '3', rate: 310, status: 'Cleaning' }, + { id: '302', number: '302', type: 'Family Suite', floor: '3', rate: 310, status: 'Out of Service' }, + { id: '401', number: '401', type: 'Executive Suite', floor: '4', rate: 420, status: 'Available' }, + { id: '402', number: '402', type: 'Executive Suite', floor: '4', rate: 420, status: 'Available' }, +] + +const initialReservations: Reservation[] = [ + { + id: 'res-1001', + reservationNumber: 'HP-1001', + guestName: 'Maya Chen', + email: 'maya.chen@example.com', + phone: '+1 415 555 0112', + roomId: '201', + checkIn: toIsoDate(addDays(today, -1)), + checkOut: toIsoDate(addDays(today, 2)), + adults: 2, + children: 0, + source: 'Website', + status: 'Checked In', + totalAmount: 675, + paidAmount: 675, + createdAt: toIsoDate(addDays(today, -8)), + }, + { + id: 'res-1002', + reservationNumber: 'HP-1002', + guestName: 'Jordan Smith', + email: 'jordan.smith@example.com', + phone: '+1 212 555 0188', + roomId: '101', + checkIn: toIsoDate(today), + checkOut: toIsoDate(addDays(today, 1)), + adults: 1, + children: 0, + source: 'Walk-in', + status: 'Confirmed', + totalAmount: 145, + paidAmount: 0, + createdAt: toIsoDate(today), + }, + { + id: 'res-1003', + reservationNumber: 'HP-1003', + guestName: 'Sofia Alvarez', + email: 'sofia.alvarez@example.com', + phone: '+1 305 555 0144', + roomId: '401', + checkIn: toIsoDate(addDays(today, 1)), + checkOut: toIsoDate(addDays(today, 4)), + adults: 2, + children: 1, + source: 'Booking.com', + status: 'Confirmed', + totalAmount: 1260, + paidAmount: 420, + createdAt: toIsoDate(addDays(today, -3)), + }, +] + +const defaultForm: ReservationForm = { + guestName: '', + email: '', + phone: '', + roomId: initialRooms[0].id, + checkIn: toIsoDate(today), + checkOut: toIsoDate(addDays(today, 1)), + adults: '1', + children: '0', + source: 'Walk-in', + ...rateFormValues(initialRooms[0].rate), + deposit: '0', +} + +const emptyApiData: PmsApiData = { + rooms: [], + reservations: [], + payments: [], + housekeepingTasks: [], + maintenanceTickets: [], + pricingRules: [], + otaConnections: [], + salesChannels: [], + hotels: [], +} + +const statusClasses: Record = { + Available: 'bg-emerald-50 text-emerald-700 ring-emerald-200', + Occupied: 'bg-blue-50 text-blue-700 ring-blue-200', + Dirty: 'bg-amber-50 text-amber-700 ring-amber-200', + Cleaning: 'bg-cyan-50 text-cyan-700 ring-cyan-200', + Inspected: 'bg-teal-50 text-teal-700 ring-teal-200', + 'Out of Service': 'bg-rose-50 text-rose-700 ring-rose-200', + Pending: 'bg-slate-50 text-slate-700 ring-slate-200', + Confirmed: 'bg-indigo-50 text-indigo-700 ring-indigo-200', + 'Checked In': 'bg-blue-50 text-blue-700 ring-blue-200', + 'Checked Out': 'bg-emerald-50 text-emerald-700 ring-emerald-200', + Cancelled: 'bg-rose-50 text-rose-700 ring-rose-200', + 'No Show': 'bg-orange-50 text-orange-700 ring-orange-200', +} + +const inputClassName = + 'mt-1 w-full rounded-xl border-slate-200 bg-white text-sm text-slate-800 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-dark-700 dark:bg-dark-800 dark:text-white' + +const metricThemes = [ + 'from-blue-600 to-cyan-500', + 'from-emerald-600 to-teal-400', + 'from-indigo-600 to-violet-500', + 'from-amber-500 to-orange-500', +] + +const providerLabels: Record = { + booking_com: 'Booking.com', + expedia: 'Expedia', + airbnb: 'Airbnb', + agoda: 'Agoda', + other: 'Other OTA', +} + +const roomStatusFromApi = (status?: string): RoomStatus => { + switch (status) { + case 'occupied': + return 'Occupied' + case 'dirty': + return 'Dirty' + case 'cleaning': + return 'Cleaning' + case 'inspected': + return 'Inspected' + case 'out_of_service': + return 'Out of Service' + default: + return 'Available' + } +} + +const roomStatusToApi = (status: RoomStatus) => { + switch (status) { + case 'Occupied': + return 'occupied' + case 'Dirty': + return 'dirty' + case 'Cleaning': + return 'cleaning' + case 'Inspected': + return 'inspected' + case 'Out of Service': + return 'out_of_service' + default: + return 'available' + } +} + +const reservationStatusFromApi = (status?: string): ReservationStatus => { + switch (status) { + case 'confirmed': + return 'Confirmed' + case 'checked_in': + return 'Checked In' + case 'checked_out': + return 'Checked Out' + case 'cancelled': + return 'Cancelled' + case 'no_show': + return 'No Show' + default: + return 'Pending' + } +} + +const reservationStatusToApi = (status: ReservationStatus) => { + switch (status) { + case 'Confirmed': + return 'confirmed' + case 'Checked In': + return 'checked_in' + case 'Checked Out': + return 'checked_out' + case 'Cancelled': + return 'cancelled' + case 'No Show': + return 'no_show' + default: + return 'pending' + } +} + +const sourceFromReservation = (reservation: RawReservation) => { + if (reservation.sales_channel?.name) return reservation.sales_channel.name + if (reservation.sales_channel?.channel_type) return normalizeLabel(reservation.sales_channel.channel_type) + return 'Direct' +} + +const guestNameFromReservation = (reservation: RawReservation) => { + const firstName = reservation.guest?.first_name || '' + const lastName = reservation.guest?.last_name || '' + const fullName = `${firstName} ${lastName}`.trim() + return fullName || `Guest ${reservation.reservation_number || reservation.id.slice(0, 8)}` +} + +const splitGuestName = (guestName: string) => { + const parts = guestName.trim().split(/\s+/).filter(Boolean) + const firstName = parts.shift() || 'Guest' + const lastName = parts.join(' ') || 'Walk-in' + return { firstName, lastName } +} + +const listRows = async (endpoint: string) => { + const response = await axios.get>(endpoint, { params: { limit: 200, page: 0 } }) + return Array.isArray(response.data.rows) ? response.data.rows : [] +} + +const mapRooms = (rawRooms: RawRoom[]) => + rawRooms.map((room, index) => ({ + id: room.id, + number: room.room_number || `${index + 1}`, + type: room.room_type?.name || 'Room', + floor: room.floor || '—', + rate: parseAmount(room.room_type?.base_rate) || initialRooms[index % initialRooms.length]?.rate || 150, + status: roomStatusFromApi(room.status), + hotelId: room.hotel?.id || room.hotelId, + roomTypeId: room.room_type?.id || room.room_typeId, + raw: room, + })) + +const paymentReservationId = (payment: RawPayment) => payment.reservation?.id || payment.reservationId || '' + +const mapReservations = (rawReservations: RawReservation[], rooms: Room[], payments: RawPayment[]) => + rawReservations.map((reservation, index) => { + const roomId = reservation.room?.id || reservation.roomId || rooms[index % Math.max(rooms.length, 1)]?.id || '' + const room = rooms.find((item) => item.id === roomId) + const checkIn = normalizeDate(reservation.check_in_at) + const checkOut = normalizeDate(reservation.check_out_at, toIsoDate(addDays(today, 1))) + const fallbackTotal = room ? room.rate * nightsBetween(checkIn, checkOut) : 0 + const paidFromPayments = payments + .filter((payment) => paymentReservationId(payment) === reservation.id && ['captured', 'authorized'].includes(payment.status || '')) + .reduce((sum, payment) => sum + parseAmount(payment.amount), 0) + + return { + id: reservation.id, + reservationNumber: reservation.reservation_number || `HP-${1000 + index}`, + guestName: guestNameFromReservation(reservation), + email: reservation.guest?.email || '', + phone: reservation.guest?.phone || '', + roomId, + roomTypeId: reservation.room_type?.id || reservation.room_typeId || room?.roomTypeId, + hotelId: reservation.hotel?.id || reservation.hotelId || room?.hotelId, + guestId: reservation.guest?.id || reservation.guestId, + salesChannelId: reservation.sales_channel?.id || reservation.sales_channelId, + checkIn, + checkOut, + adults: Number(reservation.adults || 1), + children: Number(reservation.children || 0), + source: sourceFromReservation(reservation), + status: reservationStatusFromApi(reservation.status), + totalAmount: parseAmount(reservation.total_amount) || fallbackTotal, + paidAmount: paidFromPayments || parseAmount(reservation.deposit_amount), + createdAt: normalizeDate(reservation.createdAt), + raw: reservation, + } + }) + +const FrontDesk = () => { + const [rooms, setRooms] = useState(initialRooms) + const [reservations, setReservations] = useState(initialReservations) + const [apiData, setApiData] = useState(emptyApiData) + const [dataMode, setDataMode] = useState('loading') + const [form, setForm] = useState(defaultForm) + const [selectedReservationId, setSelectedReservationId] = useState(initialReservations[0].id) + const [successMessage, setSuccessMessage] = useState('') + const [errorMessage, setErrorMessage] = useState('') + const [isCreatingCheckout, setIsCreatingCheckout] = useState(false) + const [isSyncing, setIsSyncing] = useState(true) + const [otaSyncingId, setOtaSyncingId] = useState(null) + const [otaSyncResult, setOtaSyncResult] = useState(null) + + const loadPmsData = useCallback(async () => { + if (typeof window !== 'undefined' && !window.localStorage.getItem('token')) { + setIsSyncing(false) + setDataMode('demo') + return + } + + setIsSyncing(true) + setDataMode((mode) => (mode === 'demo' ? 'demo' : 'loading')) + + try { + const [ + rawRooms, + rawReservations, + rawPayments, + rawHousekeepingTasks, + rawMaintenanceTickets, + rawPricingRules, + rawOtaConnections, + rawSalesChannels, + rawHotels, + ] = await Promise.all([ + listRows('rooms'), + listRows('reservations'), + listRows('payments'), + listRows('housekeeping_tasks'), + listRows('maintenance_tickets'), + listRows('dynamic_pricing_rules'), + listRows('ota_connections'), + listRows('sales_channels'), + listRows('hotels'), + ]) + + const mappedRooms = rawRooms.length ? mapRooms(rawRooms) : initialRooms + const mappedReservations = rawReservations.length ? mapReservations(rawReservations, mappedRooms, rawPayments) : initialReservations + + setApiData({ + rooms: rawRooms, + reservations: rawReservations, + payments: rawPayments, + housekeepingTasks: rawHousekeepingTasks, + maintenanceTickets: rawMaintenanceTickets, + pricingRules: rawPricingRules, + otaConnections: rawOtaConnections, + salesChannels: rawSalesChannels, + hotels: rawHotels, + }) + setRooms(mappedRooms) + setReservations(mappedReservations) + setSelectedReservationId((currentId) => mappedReservations.find((reservation) => reservation.id === currentId)?.id || mappedReservations[0]?.id || '') + setForm((currentForm) => { + if (mappedRooms.find((room) => room.id === currentForm.roomId)) return currentForm + + const defaultRoom = mappedRooms[0] + + return { + ...currentForm, + roomId: defaultRoom?.id || currentForm.roomId, + ...rateFormValues(defaultRoom?.rate || 0), + } + }) + setDataMode(rawRooms.length || rawReservations.length ? 'live' : 'demo') + setSuccessMessage(rawRooms.length || rawReservations.length ? 'Live PMS data synced from your database.' : 'No live room/reservation rows yet, so demo data is displayed until you create records.') + setErrorMessage('') + } catch (error) { + console.error('Failed to sync PMS cockpit data', error) + setDataMode('demo') + setErrorMessage(`Live PMS sync failed: ${apiErrorMessage(error)}. Demo data remains visible.`) + } finally { + setIsSyncing(false) + } + }, []) + + useEffect(() => { + loadPmsData() + }, [loadPmsData]) + + const calendarDays = useMemo(() => Array.from({ length: 7 }, (_, index) => toIsoDate(addDays(today, index))), []) + + const selectedReservation = reservations.find((reservation) => reservation.id === selectedReservationId) + const selectedRoom = selectedReservation + ? rooms.find((room) => room.id === selectedReservation.roomId) + : undefined + const selectedBalanceDue = selectedReservation ? balanceDue(selectedReservation) : 0 + + const activePricingRules = useMemo(() => apiData.pricingRules.filter((rule) => rule.active), [apiData.pricingRules]) + const activeOtaConnections = useMemo( + () => apiData.otaConnections.filter((connection) => connection.sync_status !== 'disabled'), + [apiData.otaConnections] + ) + const openMaintenanceTickets = useMemo( + () => apiData.maintenanceTickets.filter((ticket) => ticket.status !== 'closed'), + [apiData.maintenanceTickets] + ) + + const roomStatusCounts = useMemo( + () => ({ + available: rooms.filter((room) => ['Available', 'Inspected'].includes(room.status)).length, + occupied: rooms.filter((room) => room.status === 'Occupied').length, + dirty: rooms.filter((room) => room.status === 'Dirty').length, + cleaning: rooms.filter((room) => room.status === 'Cleaning').length, + outOfService: rooms.filter((room) => room.status === 'Out of Service').length, + }), + [rooms] + ) + + const upcomingReservations = useMemo( + () => + reservations + .filter((reservation) => ['Confirmed', 'Pending'].includes(reservation.status)) + .sort((a, b) => a.checkIn.localeCompare(b.checkIn)), + [reservations] + ) + + const inHouseReservations = useMemo( + () => reservations.filter((reservation) => reservation.status === 'Checked In'), + [reservations] + ) + + const revenueToday = useMemo( + () => + reservations + .filter((reservation) => reservation.checkIn <= toIsoDate(today) && reservation.checkOut >= toIsoDate(today)) + .reduce((sum, reservation) => sum + reservation.totalAmount, 0), + [reservations] + ) + + const paymentsCaptured = useMemo( + () => + dataMode === 'live' + ? apiData.payments + .filter((payment) => ['captured', 'authorized'].includes(payment.status || '')) + .reduce((sum, payment) => sum + parseAmount(payment.amount), 0) + : reservations.reduce((sum, reservation) => sum + reservation.paidAmount, 0), + [apiData.payments, dataMode, reservations] + ) + + const occupancyRate = rooms.length ? Math.round((roomStatusCounts.occupied / rooms.length) * 100) : 0 + const readinessScore = rooms.length + ? Math.round(((roomStatusCounts.available + roomStatusCounts.cleaning * 0.5) / rooms.length) * 100) + : 0 + + const arrivalsToday = reservations.filter((reservation) => reservation.checkIn === toIsoDate(today)).length + const departuresToday = reservations.filter((reservation) => reservation.checkOut === toIsoDate(today)).length + + const selectedRoomRate = rooms.find((room) => room.id === form.roomId)?.rate || 0 + const stayQuote = calculateStayQuote(form.checkIn, form.checkOut, form.nightlyRate, form.weeklyRate, selectedRoomRate) + const quotedTotal = stayQuote.total + const rateBreakdown = quoteBreakdownText(stayQuote) + + const hotelName = apiData.hotels[0]?.name || rooms.find((room) => room.hotelId)?.raw?.hotel?.name || 'HotelPilot PMS' + + const salesChannelOptions = useMemo(() => { + const liveChannels = apiData.salesChannels.map((channel) => channel.name || normalizeLabel(channel.channel_type) || 'Direct') + return Array.from(new Set(['Walk-in', 'Website', 'Booking.com', 'Expedia', 'Phone', ...liveChannels, ...reservations.map((reservation) => reservation.source)])) + }, [apiData.salesChannels, reservations]) + + const channelCards = useMemo(() => { + const counts = reservations.reduce>((acc, reservation) => { + acc[reservation.source] = (acc[reservation.source] || 0) + 1 + return acc + }, {}) + const colors = ['bg-emerald-500', 'bg-blue-500', 'bg-indigo-500', 'bg-amber-500', 'bg-rose-500'] + const total = Math.max(reservations.length, 1) + const entries = Object.entries(counts).length ? Object.entries(counts) : [['Website', 0]] + + return entries.map(([source, bookings], index) => ({ + source, + bookings, + share: Math.round((bookings / total) * 100), + health: apiData.otaConnections.find((connection) => providerLabels[connection.provider || ''] === source)?.sync_status || (source === 'Walk-in' ? 'Front desk' : 'Live mix'), + color: colors[index % colors.length], + })) + }, [apiData.otaConnections, reservations]) + + const automationTiles = useMemo( + () => [ + { + title: 'Direct booking engine', + detail: `${roomStatusCounts.available} rooms ready for web and direct reservations.`, + value: dataMode === 'live' ? 'Live DB' : 'Demo', + }, + { + title: 'OTA channel manager', + detail: `${activeOtaConnections.length} OTA connections loaded with inventory/rate sync status.`, + value: `${activeOtaConnections.length} synced`, + }, + { + title: 'Guest messaging', + detail: `${arrivalsToday} arrivals can receive automated pre-arrival instructions.`, + value: arrivalsToday ? 'Queue' : 'Clear', + }, + { + title: 'Night audit reports', + detail: `${money(paymentsCaptured)} captured/authorized payments are available for audit rollup.`, + value: '02:00', + }, + ], + [activeOtaConnections.length, arrivalsToday, dataMode, paymentsCaptured, roomStatusCounts.available] + ) + + const handleRoomSelect = (roomId: string) => { + const nextRoom = rooms.find((room) => room.id === roomId) + + setForm({ + ...form, + roomId, + ...rateFormValues(nextRoom?.rate || 0), + }) + } + + const resetReservationForm = () => { + const defaultRoom = rooms[0] || initialRooms[0] + + setForm({ + ...defaultForm, + roomId: defaultRoom?.id || defaultForm.roomId, + ...rateFormValues(defaultRoom?.rate || initialRooms[0].rate), + }) + } + + const updateReservationStatus = async (id: string, status: ReservationStatus) => { + setErrorMessage('') + const previousReservations = reservations + const reservation = reservations.find((item) => item.id === id) + + setReservations((current) => current.map((item) => (item.id === id ? { ...item, status } : item))) + + if (dataMode !== 'live' || !reservation?.raw) { + setSuccessMessage(`Reservation marked as ${status}.`) + return + } + + try { + await axios.put(`reservations/${id}`, { id, data: { status: reservationStatusToApi(status) } }) + await loadPmsData() + setSuccessMessage(`Reservation ${reservation.reservationNumber} saved as ${status}.`) + } catch (error) { + console.error('Failed to update reservation status', error) + setReservations(previousReservations) + setErrorMessage(apiErrorMessage(error)) + } + } + + const updateRoomStatus = async (id: string, status: RoomStatus) => { + setErrorMessage('') + const previousRooms = rooms + const room = rooms.find((item) => item.id === id) + + setRooms((current) => current.map((item) => (item.id === id ? { ...item, status } : item))) + + if (dataMode !== 'live' || !room?.raw) { + setSuccessMessage(`Room ${room?.number || id} marked as ${status}.`) + return + } + + try { + await axios.put(`rooms/${id}`, { id, data: { status: roomStatusToApi(status) } }) + await loadPmsData() + setSuccessMessage(`Room ${room.number} saved as ${status}.`) + } catch (error) { + console.error('Failed to update room status', error) + setRooms(previousRooms) + setErrorMessage(apiErrorMessage(error)) + } + } + + const handleCreateReservation = async (event: React.FormEvent) => { + event.preventDefault() + setErrorMessage('') + + const room = rooms.find((item) => item.id === form.roomId) + if (!room) { + setErrorMessage('Choose an available room before creating a reservation.') + return + } + + const deposit = parseAmount(form.deposit) + const { firstName, lastName } = splitGuestName(form.guestName) + const selectedSalesChannel = apiData.salesChannels.find( + (channel) => channel.name === form.source || normalizeLabel(channel.channel_type) === form.source + ) + const hotelId = room.hotelId || apiData.hotels[0]?.id + + if (dataMode === 'live') { + try { + await axios.post('guests', { + data: { + first_name: firstName, + last_name: lastName, + email: form.email || null, + phone: form.phone || null, + hotel: hotelId || null, + }, + }) + + const guests = await listRows('guests') + const createdGuest = guests.find( + (guest) => guest.first_name === firstName && guest.last_name === lastName && (!form.email || guest.email === form.email) + ) || guests[0] + + const reservationNumber = `HP-${Date.now().toString().slice(-6)}` + await axios.post('reservations', { + data: { + reservation_number: reservationNumber, + guest: createdGuest?.id || null, + room: room.id, + room_type: room.roomTypeId || null, + hotel: hotelId || null, + sales_channel: selectedSalesChannel?.id || null, + check_in_at: toDateTimeValue(form.checkIn), + check_out_at: toDateTimeValue(form.checkOut), + adults: Number(form.adults || 1), + children: Number(form.children || 0), + status: 'confirmed', + total_amount: quotedTotal, + deposit_amount: deposit, + internal_notes: `Created from Front Desk cockpit via ${form.source}. Rate: ${rateBreakdown}. Nightly rate ${money(stayQuote.nightlyRate)}; weekly 7-night rate ${money(stayQuote.weeklyRate)}; total ${money(quotedTotal)}.`, + }, + }) + + resetReservationForm() + await loadPmsData() + setSuccessMessage(`Live reservation ${reservationNumber} created and saved to the database.`) + return + } catch (error) { + console.error('Failed to create live reservation', error) + setErrorMessage(apiErrorMessage(error)) + return + } + } + + const newReservation: Reservation = { + id: `res-${Date.now()}`, + reservationNumber: `HP-${1000 + reservations.length + 1}`, + guestName: form.guestName || 'Walk-in Guest', + email: form.email, + phone: form.phone, + roomId: room.id, + checkIn: form.checkIn, + checkOut: form.checkOut, + adults: Number(form.adults || 1), + children: Number(form.children || 0), + source: form.source, + status: 'Confirmed', + totalAmount: quotedTotal, + paidAmount: deposit, + createdAt: toIsoDate(today), + } + + setReservations((current) => [newReservation, ...current]) + setSelectedReservationId(newReservation.id) + setRooms((current) => current.map((item) => (item.id === room.id ? { ...item, status: 'Occupied' } : item))) + resetReservationForm() + setSuccessMessage(`Demo reservation ${newReservation.reservationNumber} created with ${rateBreakdown}. Use live entity records to persist to Postgres.`) + } + + const handleCreateCheckoutSession = async () => { + if (!selectedReservation || !selectedRoom || selectedBalanceDue <= 0) return + + setIsCreatingCheckout(true) + setErrorMessage('') + setSuccessMessage('') + + try { + const response = await axios.post('/stripe/create-checkout-session', { + amount: selectedBalanceDue, + currency: 'usd', + reservationId: selectedReservation.id, + reservationNumber: selectedReservation.reservationNumber, + guestName: selectedReservation.guestName, + description: `${hotelName} reservation ${selectedReservation.reservationNumber} · Room ${selectedRoom.number}`, + }) + + if (response.data?.url) { + window.location.href = response.data.url + return + } + + setErrorMessage('Stripe did not return a checkout URL.') + } catch (error) { + console.error('Failed to create Stripe Checkout session', error) + setErrorMessage(apiErrorMessage(error)) + } finally { + setIsCreatingCheckout(false) + } + } + + const handleSyncOtaConnection = async (connection: RawOtaConnection) => { + if (!connection.id) return + + const providerName = providerLabels[connection.provider || ''] || normalizeLabel(connection.provider) || 'OTA' + setOtaSyncingId(connection.id) + setErrorMessage('') + setSuccessMessage('') + + try { + const response = await axios.put(`ota_connections/${connection.id}/sync`, { + data: { mode: 'full', days: 30 }, + }) + setOtaSyncResult(response.data) + await loadPmsData() + setSuccessMessage(`${providerName} sync complete. ${summarizeOtaSync(response.data)}`) + } catch (error) { + console.error('Failed to sync OTA connection', error) + setErrorMessage(`OTA sync failed: ${apiErrorMessage(error)}`) + } finally { + setOtaSyncingId(null) + } + } + + const handleSyncAllOtaConnections = async () => { + setOtaSyncingId('all') + setErrorMessage('') + setSuccessMessage('') + + try { + const response = await axios.put('ota_connections/sync-all', { + data: { mode: 'full', days: 30 }, + }) + setOtaSyncResult(response.data) + await loadPmsData() + setSuccessMessage(`OTA sync-all complete. ${summarizeOtaSync(response.data)}`) + } catch (error) { + console.error('Failed to sync all OTA connections', error) + setErrorMessage(`OTA sync-all failed: ${apiErrorMessage(error)}`) + } finally { + setOtaSyncingId(null) + } + } + + const metrics = [ + { label: 'Live occupancy', value: `${occupancyRate}%`, detail: `${roomStatusCounts.occupied}/${rooms.length} rooms occupied` }, + { label: 'Revenue today', value: money(revenueToday), detail: `${money(paymentsCaptured)} payments captured` }, + { label: 'Arrivals today', value: arrivalsToday.toString(), detail: `${departuresToday} departures` }, + { label: 'Ops readiness', value: `${readinessScore}%`, detail: `${roomStatusCounts.dirty} dirty · ${openMaintenanceTickets.length} open tickets` }, + ] + + const forecastLift = activePricingRules.reduce((sum, rule) => sum + parseAmount(rule.adjustment_percent), 0) + const leadingRule = activePricingRules[0] + const pricingCoachCards = [ + { label: 'Active rules', value: activePricingRules.length.toString(), detail: leadingRule?.name || 'Create rules in Dynamic Pricing Rules' }, + { label: 'Suggested lift', value: `${forecastLift >= 0 ? '+' : ''}${forecastLift || 0}%`, detail: leadingRule ? normalizeLabel(leadingRule.rule_type) : 'Based on live rule settings' }, + { label: 'Pickup pressure', value: occupancyRate > 70 ? 'High' : occupancyRate > 40 ? 'Moderate' : 'Low', detail: `${occupancyRate}% current occupancy` }, + ] + + const housekeepingStats = [ + { label: 'Available', value: roomStatusCounts.available, color: 'text-emerald-600' }, + { label: 'Dirty', value: roomStatusCounts.dirty, color: 'text-amber-600' }, + { label: 'Cleaning', value: roomStatusCounts.cleaning, color: 'text-cyan-600' }, + { label: 'Out of service', value: roomStatusCounts.outOfService, color: 'text-rose-600' }, + ] + + return ( + <> + + {getPageTitle('Front Desk')} + + + + + + +
+
+
+
+ + {dataMode === 'live' ? 'Live database cockpit' : dataMode === 'loading' ? 'Loading live PMS' : 'Demo fallback'} + + + {hotelName} + +
+

+ Sirvoy-style front desk, Smart PMS-style automation, connected to HotelPilot data. +

+

+ Reservations, room readiness, payments, dynamic pricing rules, OTA connections, housekeeping, and maintenance now come from the generated PMS APIs instead of static display cards. +

+
+ {[ + ['Reservations', reservations.length], + ['Rooms', rooms.length], + ['OTA links', activeOtaConnections.length], + ['Rules', activePricingRules.length], + ].map(([label, value]) => ( +
+

{value}

+

{label}

+
+ ))} +
+
+
+
+
+

Today command stack

+

{dateLabel(toIsoDate(today))}

+
+ {occupancyRate}% full +
+
+ {upcomingReservations.slice(0, 4).map((reservation) => ( + + ))} + {!upcomingReservations.length &&

No arrivals are waiting in the current filtered data.

} +
+
+
+
+ + {(successMessage || errorMessage) && ( +
+ {successMessage &&
{successMessage}
} + {errorMessage &&
{errorMessage}
} +
+ )} + +
+ {metrics.map((metric, index) => ( + +
+
+

{metric.label}

+

{metric.value}

+

{metric.detail}

+
+ metric +
+
+ ))} +
+ +
+ +
+
+

Live PMS calendar

+

Room availability grid

+

Reservations and room readiness are rendered from live `/rooms` and `/reservations` API rows when available.

+
+
+ {Object.entries(roomStatusCounts).map(([key, value]) => {normalizeLabel(key)}: {value})} +
+
+
+
+
+
Room
+ {calendarDays.map((day) =>
{dateLabel(day)}
)} +
+ {rooms.map((room) => ( +
+
+

{room.number}

+

{room.type}

+
+ {calendarDays.map((day) => { + const booking = reservations.find((reservation) => reservation.roomId === room.id && reservation.checkIn <= day && reservation.checkOut > day && !['Cancelled', 'No Show'].includes(reservation.status)) + return ( + + ) + })} +
+ ))} +
+
+
+ + +
+ + +
+

Walk-in / direct booking

+

Create reservation

+
+
+
+
+ + setForm({ ...form, guestName: event.target.value })} placeholder="Alex Morgan" required /> +
+
+
+ + setForm({ ...form, email: event.target.value })} placeholder="guest@example.com" /> +
+
+ + setForm({ ...form, phone: event.target.value })} placeholder="+1 555 0100" /> +
+
+
+
+ + +
+
+ + +
+
+
+
+ + setForm({ ...form, checkIn: event.target.value })} /> +
+
+ + setForm({ ...form, checkOut: event.target.value })} /> +
+
+
+
+ + setForm({ ...form, adults: event.target.value })} /> +
+
+ + setForm({ ...form, children: event.target.value })} /> +
+
+ + setForm({ ...form, deposit: event.target.value })} /> +
+
+
+
+ + setForm({ ...form, nightlyRate: event.target.value })} /> +
+
+ + setForm({ ...form, weeklyRate: event.target.value })} /> +
+
+
+
+ Quote + {money(quotedTotal)} +
+

{rateBreakdown}

+

Room default is {money(selectedRoomRate)} nightly. Weekly rate applies to every full 7-night block.

+
+ + +
+
+ +
+ +
+ +
+

Front desk queue

+

Arrivals and in-house stays

+
+
+
+ {[...inHouseReservations, ...upcomingReservations].slice(0, 8).map((reservation) => ( + + ))} + {!reservations.length &&

No reservation rows found.

} +
+
+ + + {selectedReservation ? ( + <> +
+
+

Stay detail

+

{selectedReservation.guestName}

+

{selectedReservation.reservationNumber} · {selectedReservation.source}

+
+ {selectedReservation.status} +
+
+ {[ + ['Room', selectedRoom ? `${selectedRoom.number} · ${selectedRoom.type}` : 'Unassigned'], + ['Stay', `${dateLabel(selectedReservation.checkIn)} → ${dateLabel(selectedReservation.checkOut)}`], + ['Guests', `${selectedReservation.adults} adults · ${selectedReservation.children} children`], + ['Total', money(selectedReservation.totalAmount)], + ['Paid', money(selectedReservation.paidAmount)], + ['Balance', money(selectedBalanceDue)], + ].map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+ updateReservationStatus(selectedReservation.id, 'Checked In')} /> + updateReservationStatus(selectedReservation.id, 'Checked Out')} /> + updateReservationStatus(selectedReservation.id, 'Cancelled')} /> +
+
+
+
+

Stripe Checkout

+

Collect balance due

+

Creates a protected Stripe Checkout Session through `/api/stripe/create-checkout-session`.

+
+ 0 ? `Collect ${money(selectedBalanceDue)}` : 'Paid'} disabled={selectedBalanceDue <= 0 || isCreatingCheckout} onClick={handleCreateCheckoutSession} /> +
+
+ + ) : ( +

Select a reservation to view stay details.

+ )} +
+
+ +
+ +
+ AI +
+

Smart pricing coach

+

Dynamic pricing rules

+

Live rules from the `dynamic_pricing_rules` entity drive these recommendations.

+
+
+
+ {pricingCoachCards.map((item) => ( +
+

{item.value}

+

{item.label}

+

{item.detail}

+
+ ))} +
+
+ {(activePricingRules.length ? activePricingRules : apiData.pricingRules).slice(0, 4).map((rule) => ( +
+
+
+

{rule.name || 'Pricing rule'}

+

{normalizeLabel(rule.rule_type)} · {rule.room_type?.name || 'All room types'}

+
+ {parseAmount(rule.adjustment_percent) ? `${parseAmount(rule.adjustment_percent)}%` : money(parseAmount(rule.adjustment_amount))} +
+
+ ))} + {!apiData.pricingRules.length &&

No dynamic pricing rules found yet.

} +
+
+ + +
+ 🧹 +
+

Housekeeping + maintenance

+

Readiness board

+

Room state plus live housekeeping tasks and open maintenance tickets.

+
+
+
+ {housekeepingStats.map((item) => ( +
+

{item.value}

+

{item.label}

+
+ ))} +
+
+ {rooms.slice(0, 8).map((room) => ( +
+
+
+

Room {room.number}

+

Floor {room.floor} · {room.type}

+
+ {room.status} +
+
+
+
+
+ updateRoomStatus(room.id, 'Cleaning')} /> + updateRoomStatus(room.id, 'Available')} /> +
+
+ ))} +
+
+
+

Today tasks

+ {apiData.housekeepingTasks.slice(0, 4).map((task) => ( +

{normalizeLabel(task.task_type)} · Room {task.room?.room_number || rooms.find((room) => room.id === task.roomId)?.number || 'TBD'} · {normalizeLabel(task.status)}

+ ))} + {!apiData.housekeepingTasks.length &&

No housekeeping task rows found.

} +
+
+

Open tickets

+ {openMaintenanceTickets.slice(0, 4).map((ticket) => ( +

{ticket.title || normalizeLabel(ticket.category)} · {normalizeLabel(ticket.priority)} · {normalizeLabel(ticket.status)}

+ ))} + {!openMaintenanceTickets.length &&

No open maintenance tickets.

} +
+
+ +
+ +
+ +
+ +
+

Automation layer

+

All-in-one PMS modules

+

Cards now reflect live inventory, OTA, messaging queue, and payment audit data.

+
+
+
+ {automationTiles.map((tile) => ( +
+
+

{tile.title}

+ {tile.value} +
+

{tile.detail}

+
+ ))} +
+
+ + +
+
+ % +
+

Channels + distribution

+

Booking mix and OTA sync

+

Import OTA reservations, export 30-day inventory/rates, and update connection health.

+
+
+ +
+
+ {channelCards.map((channel) => ( +
+
+
+

{channel.source}

+

{channel.bookings} bookings · {normalizeLabel(channel.health)}

+
+ {channel.share}% +
+
+
+
+
+ ))} +
+
+

OTA sync connections

+ {apiData.otaConnections.slice(0, 4).map((connection) => { + const providerName = providerLabels[connection.provider || ''] || normalizeLabel(connection.provider) || 'OTA' + const isConnectionSyncing = otaSyncingId === connection.id + + return ( +
+
+
+

{providerName}

+

{connection.account_identifier || 'No account ID'} · {dateTimeLabel(connection.last_sync_at)}

+

Inventory {connection.inventory_sync_enabled ? 'on' : 'off'} · Rates {connection.rate_sync_enabled ? 'on' : 'off'}

+
+ {normalizeLabel(connection.sync_status || 'pending')} +
+
+ handleSyncOtaConnection(connection)} + /> + +
+
+ ) + })} + {!apiData.otaConnections.length && ( +
+ No OTA connection rows found yet. Create one in OTA Connections, enable inventory/rate sync, then return here to run the channel sync. +
+ )} + {otaSyncResult && ( +
+

Last OTA sync result

+

{summarizeOtaSync(otaSyncResult)}

+ {otaSyncResult.inbound?.errors?.slice(0, 2).map((message) => ( +

Warning: {message}

+ ))} +
+ )} +
+ +
+ + + ) +} + +FrontDesk.getLayout = function getLayout(page: ReactElement) { + return {page} +} + +export default FrontDesk diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index b1fabbf..6f2994f 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,205 @@ +import { mdiCalendarClock, mdiChartTimelineVariant, mdiHomeCity, mdiLogin, mdiShieldKeyOutline } from '@mdi/js' +import Head from 'next/head' +import Link from 'next/link' +import React, { ReactElement } from 'react' +import BaseButton from '../components/BaseButton' +import CardBox from '../components/CardBox' +import { getPageTitle } from '../config' +import LayoutGuest from '../layouts/Guest' -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; -import Head from 'next/head'; -import Link from 'next/link'; -import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; -import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; -import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const featureCards = [ + { + title: 'Front desk cockpit', + description: 'Create walk-ins, see room availability, confirm arrivals, and move departures into housekeeping.', + icon: mdiCalendarClock, + }, + { + title: 'Multi-hotel ready', + description: 'Built around hotel-level operations, staff roles, and isolated workflows for owners and teams.', + icon: mdiShieldKeyOutline, + }, + { + title: 'Revenue intelligence', + description: 'A clean foundation for ADR, RevPAR, channel performance, and AI-assisted daily recommendations.', + icon: mdiChartTimelineVariant, + }, +] +const stats = [ + ['10', 'hotel credentials'], + ['50', 'demo rooms'], + ['200+', 'reservation-ready records'], + ['24/7', 'cloud PMS workflow'], +] -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('image'); - const [contentPosition, setContentPosition] = useState('background'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'App Preview' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( - - ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } - }; - +function Starter() { return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('HotelPilot PMS')} + - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+ + + H + +
+

HotelPilot PMS

+

Pilot operations

+
+ + +
+ +
- - - +
+
- - -
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+
+
+
+
+
+

+ New MVP slice live: front desk operations +

+

+ A modern PMS command center for every hotel team. +

+

+ HotelPilot PMS brings reservations, room status, check-ins, check-outs, housekeeping, billing, and reporting into a fast blue-white SaaS workspace designed for multi-property operators. +

+
+ + +
+
+ {stats.map(([value, label]) => ( +
+

{value}

+

{label}

+
+ ))} +
+
-
- ); +
+
+
+
+
+ +
+

Sunset Resort

+

Today's command center

+
+
+ Live +
+
+ {[ + ['78%', 'Occupancy'], + ['$8.4k', 'Revenue today'], + ['14', 'Arrivals'], + ['9', 'Dirty rooms'], + ].map(([value, label]) => ( +
+

{value}

+

{label}

+
+ ))} +
+
+
+

Availability pulse

+ 7-day view +
+
+ {['101 Standard', '201 Deluxe Ocean', '301 Family Suite', '401 Executive'].map((room, index) => ( +
+ {room} + +
+ ))} +
+
+
+
+
+
+
+ +
+
+

Workflow-first MVP

+

Not just tables — an actual daily hotel flow.

+

The first delivery stitches together the operational path a front desk agent needs most often.

+
+
+ {['Search availability', 'Register guest', 'Confirm reservation', 'Check in / housekeeping'].map((step, index) => ( +
+ {index + 1} +

{step}

+

Built into the new authenticated front desk cockpit for staff.

+
+ ))} +
+
+ +
+
+
+ {featureCards.map((feature) => ( + +
+ +
+

{feature.title}

+

{feature.description}

+
+ ))} +
+
+
+ +
+
+
+
+

+ First iteration +

+

Open the PMS and run a booking from input to operational status.

+

+ This public page keeps the login path visible and sends authenticated users into the new Front Desk page. Existing admin CRUD remains available for deeper data management. +

+
+
+ + +
+
+
+
+ + + ) } Starter.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; + return {page} +} +export default Starter diff --git a/frontend/src/pages/search.tsx b/frontend/src/pages/search.tsx index 00f5168..005eb07 100644 --- a/frontend/src/pages/search.tsx +++ b/frontend/src/pages/search.tsx @@ -1,9 +1,7 @@ import React, { ReactElement, useEffect, useState } from 'react'; import Head from 'next/head'; import 'react-datepicker/dist/react-datepicker.css'; -import { useAppDispatch } from '../stores/hooks'; - -import { useAppSelector } from '../stores/hooks'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useRouter } from 'next/router'; import LayoutAuthenticated from '../layouts/Authenticated'; diff --git a/frontend/src/pages/stripe/cancel.tsx b/frontend/src/pages/stripe/cancel.tsx new file mode 100644 index 0000000..f16483f --- /dev/null +++ b/frontend/src/pages/stripe/cancel.tsx @@ -0,0 +1,64 @@ +import { + mdiAlertCircleOutline, + mdiArrowLeft, +} from '@mdi/js' +import Head from 'next/head' +import { useRouter } from 'next/router' +import React, { ReactElement, useMemo } from 'react' +import BaseButton from '../../components/BaseButton' +import CardBox from '../../components/CardBox' +import SectionMain from '../../components/SectionMain' +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' +import { getPageTitle } from '../../config' +import LayoutAuthenticated from '../../layouts/Authenticated' + +const StripeCancelPage = () => { + const router = useRouter() + const reservationId = useMemo(() => { + const value = router.query.reservationId + return typeof value === 'string' ? value : '' + }, [router.query.reservationId]) + + return ( + <> + + {getPageTitle('Stripe Payment Cancelled')} + + + + + + + +
+

HotelPilot PMS

+

Checkout was cancelled

+

+ No Stripe payment was captured. The reservation remains open, so front desk staff can retry collection, + accept another method, or continue the check-in workflow based on hotel policy. +

+
+ +
+ {reservationId && ( +

+ Reservation reference: {reservationId} +

+ )} + +
+ + +
+
+
+
+ + ) +} + +StripeCancelPage.getLayout = function getLayout(page: ReactElement) { + return {page} +} + +export default StripeCancelPage diff --git a/frontend/src/pages/stripe/success.tsx b/frontend/src/pages/stripe/success.tsx new file mode 100644 index 0000000..abcbda6 --- /dev/null +++ b/frontend/src/pages/stripe/success.tsx @@ -0,0 +1,289 @@ +import { + mdiArrowLeft, + mdiCreditCardCheckOutline, +} from '@mdi/js' +import axios from 'axios' +import Head from 'next/head' +import { useRouter } from 'next/router' +import React, { ReactElement, useEffect, useMemo, useRef, useState } from 'react' +import BaseButton from '../../components/BaseButton' +import CardBox from '../../components/CardBox' +import SectionMain from '../../components/SectionMain' +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' +import { getPageTitle } from '../../config' +import LayoutAuthenticated from '../../layouts/Authenticated' + +type StripeSession = { + id: string + status?: string + payment_status?: string + amount_total?: number + currency?: string + customer_email?: string + metadata?: Record +} + +type PaymentRecord = { + id: string + stripe_charge_reference?: string +} + +type PaymentsListResponse = { + rows?: PaymentRecord[] + count?: number +} + +type DemoReservation = { + id: string + totalAmount: number + paidAmount: number +} + +type FrontDeskDemoState = { + rooms?: unknown[] + reservations?: DemoReservation[] +} + +const storageKey = 'hotelpilot-front-desk-demo' +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +const moneyFromCents = (amountInCents?: number, currency = 'usd') => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency.toUpperCase(), + maximumFractionDigits: 0, + }).format((amountInCents || 0) / 100) + +const apiErrorMessage = (error: unknown) => { + if (axios.isAxiosError(error)) { + const data = error.response?.data + + if (typeof data === 'string') return data + + if (data && typeof data === 'object' && 'message' in data) { + const message = (data as { message?: unknown }).message + if (typeof message === 'string') return message + } + + return error.message + } + + if (error instanceof Error) return error.message + + return 'Unable to verify the Stripe payment.' +} + +const isUuid = (value: string) => uuidPattern.test(value) + +const markDemoReservationPaid = (reservationId: string, amountPaid: number) => { + if (typeof window === 'undefined') return false + + const saved = window.localStorage.getItem(storageKey) + if (!saved) return false + + try { + const parsed = JSON.parse(saved) as FrontDeskDemoState + let updated = false + + if (!Array.isArray(parsed.reservations)) return false + + const reservations = parsed.reservations.map((reservation) => { + if (reservation.id !== reservationId) return reservation + + updated = true + return { + ...reservation, + paidAmount: Math.min(reservation.totalAmount, reservation.paidAmount + amountPaid), + } + }) + + if (!updated) return false + + window.localStorage.setItem(storageKey, JSON.stringify({ ...parsed, reservations })) + return true + } catch (error) { + console.error('Failed to update HotelPilot demo reservation after Stripe success', error) + return false + } +} + +const syncPmsPaymentRecord = async (session: StripeSession, reservationId: string, amountPaid: number) => { + const existingPayment = await axios.get('payments', { + params: { + limit: 1, + page: 0, + stripe_charge_reference: session.id, + }, + }) + + if (existingPayment.data.rows?.length) { + return 'Payment verified. A PMS payment record already exists for this Stripe session.' + } + + await axios.post('payments', { + data: { + method: 'stripe', + amount: amountPaid, + status: 'captured', + paid_at: new Date().toISOString(), + stripe_payment_intent_reference: session.id, + stripe_charge_reference: session.id, + receipt_number: session.metadata?.reservationNumber || session.id, + notes: `Stripe Checkout verified for reservation ${session.metadata?.reservationNumber || reservationId}.`, + reservation: reservationId, + }, + }) + + return 'Payment verified and saved as a captured PMS payment record.' +} + +const StripeSuccessPage = () => { + const router = useRouter() + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + const [errorMessage, setErrorMessage] = useState('') + const [syncMessage, setSyncMessage] = useState('') + const [syncErrorMessage, setSyncErrorMessage] = useState('') + const syncedSessionRef = useRef('') + + const sessionId = useMemo(() => { + const value = router.query.session_id + return typeof value === 'string' ? value : '' + }, [router.query.session_id]) + + const reservationIdFromQuery = useMemo(() => { + const value = router.query.reservationId + return typeof value === 'string' ? value : '' + }, [router.query.reservationId]) + + useEffect(() => { + if (!router.isReady) return + + if (!sessionId) { + setErrorMessage('Stripe did not provide a Checkout session id.') + setLoading(false) + return + } + + const verifySession = async () => { + setLoading(true) + setErrorMessage('') + setSyncErrorMessage('') + + try { + const response = await axios.get(`/stripe/checkout-session/${sessionId}`) + setSession(response.data) + + const reservationId = response.data.metadata?.reservationId || reservationIdFromQuery + const amountPaid = (response.data.amount_total || 0) / 100 + + if (response.data.payment_status === 'paid' && reservationId && amountPaid > 0) { + if (syncedSessionRef.current === response.data.id) return + + syncedSessionRef.current = response.data.id + + if (isUuid(reservationId)) { + try { + setSyncMessage(await syncPmsPaymentRecord(response.data, reservationId, amountPaid)) + } catch (syncError) { + console.error('Stripe payment verified, but PMS payment sync failed', syncError) + setSyncErrorMessage(`Payment verified by Stripe, but PMS payment sync failed: ${apiErrorMessage(syncError)}`) + } + } else { + const updated = markDemoReservationPaid(reservationId, amountPaid) + setSyncMessage( + updated + ? 'Payment verified. The Front Desk demo reservation was marked as paid.' + : 'Payment verified. Return to Front Desk to continue the stay workflow.', + ) + } + } else if (response.data.payment_status === 'paid') { + setSyncMessage('Payment verified by Stripe.') + } else { + setSyncMessage('Stripe returned the session, but the payment is not marked paid yet.') + } + } catch (error) { + if (axios.isAxiosError(error)) { + console.warn('Stripe payment verification failed', { + status: error.response?.status, + data: error.response?.data, + }) + } else { + console.error('Stripe payment verification failed', error) + } + + setErrorMessage(apiErrorMessage(error)) + } finally { + setLoading(false) + } + } + + verifySession() + }, [reservationIdFromQuery, router.isReady, sessionId]) + + return ( + <> + + {getPageTitle('Stripe Payment Success')} + + + + + + + +
+

HotelPilot PMS

+

Payment return received

+

+ HotelPilot is verifying the Stripe Checkout session before updating the front desk payment state. +

+
+ +
+ {loading &&

Verifying Stripe payment...

} + + {!loading && errorMessage && ( +

{errorMessage}

+ )} + + {!loading && !errorMessage && session && ( +
+
+

Session

+

{session.id}

+
+
+

Payment status

+

{session.payment_status || 'Unknown'}

+
+
+

Amount

+

{moneyFromCents(session.amount_total, session.currency)}

+
+
+

Guest email

+

{session.customer_email || 'Not provided'}

+
+
+ )} + + {syncMessage &&

{syncMessage}

} + {syncErrorMessage &&

{syncErrorMessage}

} + +
+ + +
+
+
+
+ + ) +} + +StripeSuccessPage.getLayout = function getLayout(page: ReactElement) { + return {page} +} + +export default StripeSuccessPage