Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
aebf8e3d39 Autosave: 20260628-021058 2026-06-28 02:10:55 +00:00
14 changed files with 3265 additions and 163 deletions

View File

@ -67,6 +67,10 @@ const config = {
gpt_key: process.env.GPT_KEY || '', 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 || ''; config.pexelsKey = process.env.PEXELS_KEY || '';

View File

@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const db = require('./db/models');
const config = require('./config'); const config = require('./config');
const swaggerUI = require('swagger-ui-express'); const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc'); const swaggerJsDoc = require('swagger-jsdoc');
@ -20,6 +19,7 @@ const pexelsRoutes = require('./routes/pexels');
const organizationForAuthRoutes = require('./routes/organizationLogin'); const organizationForAuthRoutes = require('./routes/organizationLogin');
const openaiRoutes = require('./routes/openai'); 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})); app.use(cors({origin: true}));
require('./auth/auth'); require('./auth/auth');
app.use('/api/stripe/webhook', stripeRoutes.webhookRouter);
app.use(bodyParser.json()); app.use(bodyParser.json());
app.use('/api/auth', authRoutes); 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/daily_snapshots', passport.authenticate('jwt', {session: false}), daily_snapshotsRoutes);
app.use('/api/stripe', passport.authenticate('jwt', {session: false}), stripeRoutes.checkoutRouter);
app.use( app.use(
'/api/openai', '/api/openai',
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),

View File

@ -3,9 +3,9 @@ const express = require('express');
const Ota_connectionsService = require('../services/ota_connections'); const Ota_connectionsService = require('../services/ota_connections');
const Ota_connectionsDBApi = require('../db/api/ota_connections'); const Ota_connectionsDBApi = require('../db/api/ota_connections');
const OtaIntegrationService = require('../services/otaIntegration');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const router = express.Router(); const router = express.Router();
@ -132,6 +132,29 @@ router.post('/bulk-import', wrapAsync(async (req, res) => {
res.status(200).send(payload); 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 * @swagger
* /api/ota_connections/{id}: * /api/ota_connections/{id}:

View File

@ -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,
};

View File

@ -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;
}
}
};

View File

@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'
import AsideMenuList from './AsideMenuList' import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces' import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks' import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Link from 'next/link'; import Link from 'next/link';
import { useAppDispatch } from '../stores/hooks';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios'; import axios from 'axios';

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react' import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider' import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react' import React, { ReactNode, useEffect, useState } from 'react'
import { useState } from 'react'
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside' import menuAside from '../menuAside'

View File

@ -7,6 +7,11 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline, icon: icon.mdiViewDashboardOutline,
label: 'Dashboard', label: 'Dashboard',
}, },
{
href: '/front-desk',
icon: icon.mdiCalendarClock,
label: 'Front Desk',
},
{ {
href: '/users/users-list', href: '/users/users-list',

File diff suppressed because it is too large Load Diff

View File

@ -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'; const featureCards = [
import type { ReactElement } from 'react'; {
import Head from 'next/head'; title: 'Front desk cockpit',
import Link from 'next/link'; description: 'Create walk-ins, see room availability, confirm arrivals, and move departures into housekeeping.',
import BaseButton from '../components/BaseButton'; icon: mdiCalendarClock,
import CardBox from '../components/CardBox'; },
import SectionFullScreen from '../components/SectionFullScreen'; {
import LayoutGuest from '../layouts/Guest'; title: 'Multi-hotel ready',
import BaseDivider from '../components/BaseDivider'; description: 'Built around hotel-level operations, staff roles, and isolated workflows for owners and teams.',
import BaseButtons from '../components/BaseButtons'; icon: mdiShieldKeyOutline,
import { getPageTitle } from '../config'; },
import { useAppSelector } from '../stores/hooks'; {
import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; title: 'Revenue intelligence',
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; 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() { 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) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return ( return (
<div <>
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head> <Head>
<title>{getPageTitle('Starter Page')}</title> <title>{getPageTitle('HotelPilot PMS')}</title>
<meta
name="description"
content="HotelPilot PMS is a modern multi-tenant property management system for reservations, front desk, housekeeping, billing, and reporting."
/>
</Head> </Head>
<SectionFullScreen bg='violet'> <main className="min-h-screen bg-slate-50 text-slate-950">
<div <header className="sticky top-0 z-20 border-b border-white/70 bg-white/85 backdrop-blur-xl">
className={`flex ${ <div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row' <Link href="/" className="flex items-center gap-3">
} min-h-screen w-full`} <span className="grid h-11 w-11 place-items-center rounded-2xl bg-blue-700 text-white shadow-lg shadow-blue-700/25">
> <span className="text-xl font-black">H</span>
{contentType === 'image' && contentPosition !== 'background' </span>
? imageBlock(illustrationImage) <div>
: null} <p className="text-lg font-black tracking-tight">HotelPilot PMS</p>
{contentType === 'video' && contentPosition !== 'background' <p className="text-xs font-medium uppercase tracking-[0.24em] text-blue-600">Pilot operations</p>
? videoBlock(illustrationVideo) </div>
: null} </Link>
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'> <nav className="hidden items-center gap-8 text-sm font-semibold text-slate-600 md:flex">
<CardBox className='w-full md:w-3/5 lg:w-2/3'> <a href="#workflow" className="hover:text-blue-700">Workflow</a>
<CardBoxComponentTitle title="Welcome to your App Preview app!"/> <a href="#modules" className="hover:text-blue-700">Modules</a>
<a href="#mvp" className="hover:text-blue-700">First slice</a>
<div className="space-y-3"> </nav>
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p> <div className="flex items-center gap-3">
<p className='text-center text-gray-500'>For guides and documentation please check <BaseButton href="/login" icon={mdiLogin} label="Login" color="whiteDark" />
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p> <BaseButton href="/front-desk" label="Open PMS" color="info" className="hidden border-blue-700 bg-blue-700 text-white hover:bg-blue-800 sm:inline-flex" />
</div> </div>
</div>
<BaseButtons> </header>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons> <section className="relative overflow-hidden bg-[radial-gradient(circle_at_top_left,_#dbeafe,_transparent_36%),linear-gradient(135deg,#ffffff_0%,#eff6ff_50%,#e0f2fe_100%)]">
</CardBox> <div className="absolute right-0 top-20 h-72 w-72 rounded-full bg-cyan-300/30 blur-3xl" />
</div> <div className="absolute bottom-0 left-10 h-80 w-80 rounded-full bg-blue-600/10 blur-3xl" />
</div> <div className="mx-auto grid max-w-7xl gap-10 px-6 py-20 lg:grid-cols-[1.05fr_0.95fr] lg:items-center lg:py-28">
</SectionFullScreen> <div className="relative z-10">
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'> <p className="mb-5 inline-flex items-center gap-2 rounded-full border border-blue-200 bg-white/80 px-4 py-2 text-sm font-bold text-blue-800 shadow-sm">
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p> <span className="h-2 w-2 rounded-full bg-emerald-500" /> New MVP slice live: front desk operations
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'> </p>
Privacy Policy <h1 className="max-w-4xl text-5xl font-black tracking-[-0.05em] text-slate-950 md:text-7xl">
</Link> A modern PMS command center for every hotel team.
</div> </h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
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.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<BaseButton href="/front-desk" label="Try front desk workflow" color="info" className="border-blue-700 bg-blue-700 px-6 py-3 text-white hover:bg-blue-800" />
<BaseButton href="/login" label="Admin login" color="whiteDark" className="px-6 py-3" />
</div>
<div className="mt-10 grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats.map(([value, label]) => (
<div key={label} className="rounded-3xl border border-white bg-white/70 p-4 shadow-sm backdrop-blur">
<p className="text-3xl font-black text-blue-700">{value}</p>
<p className="mt-1 text-xs font-semibold uppercase tracking-wide text-slate-500">{label}</p>
</div>
))}
</div>
</div>
</div> <div className="relative z-10">
); <div className="rounded-[2rem] border border-white bg-white/80 p-4 shadow-2xl shadow-blue-900/10 backdrop-blur-xl">
<div className="rounded-[1.5rem] bg-slate-950 p-4 text-white">
<div className="mb-5 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="rounded-2xl bg-blue-500 p-3"><span className="text-lg"></span></span>
<div>
<p className="font-bold">Sunset Resort</p>
<p className="text-xs text-slate-400">Today&apos;s command center</p>
</div>
</div>
<span className="rounded-full bg-emerald-400/10 px-3 py-1 text-xs font-bold text-emerald-300">Live</span>
</div>
<div className="grid grid-cols-2 gap-3">
{[
['78%', 'Occupancy'],
['$8.4k', 'Revenue today'],
['14', 'Arrivals'],
['9', 'Dirty rooms'],
].map(([value, label]) => (
<div key={label} className="rounded-2xl bg-white/10 p-4">
<p className="text-2xl font-black">{value}</p>
<p className="text-xs text-slate-400">{label}</p>
</div>
))}
</div>
<div className="mt-4 rounded-2xl bg-white p-4 text-slate-900">
<div className="mb-3 flex items-center justify-between">
<p className="font-black">Availability pulse</p>
<span className="text-xs font-bold text-blue-700">7-day view</span>
</div>
<div className="space-y-2">
{['101 Standard', '201 Deluxe Ocean', '301 Family Suite', '401 Executive'].map((room, index) => (
<div key={room} className="grid grid-cols-[7rem_1fr] items-center gap-3 text-xs">
<span className="font-semibold text-slate-500">{room}</span>
<span className={`h-8 rounded-full ${index === 1 ? 'bg-blue-600' : index === 2 ? 'bg-amber-300' : 'bg-emerald-300'}`} />
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="workflow" className="mx-auto max-w-7xl px-6 py-16">
<div className="mb-10 max-w-3xl">
<p className="mb-3 inline-flex rounded-full bg-blue-100 px-3 py-1 text-xs font-black uppercase tracking-[0.2em] text-blue-700">Workflow-first MVP</p>
<h2 className="text-4xl font-black tracking-tight">Not just tables an actual daily hotel flow.</h2>
<p className="mt-3 text-slate-600">The first delivery stitches together the operational path a front desk agent needs most often.</p>
</div>
<div className="grid gap-5 md:grid-cols-4">
{['Search availability', 'Register guest', 'Confirm reservation', 'Check in / housekeeping'].map((step, index) => (
<div key={step} className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
<span className="mb-5 inline-grid h-10 w-10 place-items-center rounded-2xl bg-blue-700 text-sm font-black text-white">{index + 1}</span>
<p className="text-lg font-black">{step}</p>
<p className="mt-2 text-sm leading-6 text-slate-500">Built into the new authenticated front desk cockpit for staff.</p>
</div>
))}
</div>
</section>
<section id="modules" className="bg-white py-16">
<div className="mx-auto max-w-7xl px-6">
<div className="grid gap-5 lg:grid-cols-3">
{featureCards.map((feature) => (
<CardBox key={feature.title} className="border-0 bg-slate-50 shadow-sm ring-1 ring-slate-100">
<div className="mb-5 inline-grid h-14 w-14 place-items-center rounded-2xl bg-blue-700 text-white">
<span className="text-xl"></span>
</div>
<h3 className="text-2xl font-black tracking-tight">{feature.title}</h3>
<p className="mt-3 leading-7 text-slate-600">{feature.description}</p>
</CardBox>
))}
</div>
</div>
</section>
<section id="mvp" className="mx-auto max-w-7xl px-6 py-16">
<div className="overflow-hidden rounded-[2rem] bg-blue-700 p-8 text-white shadow-xl shadow-blue-700/20 md:p-12">
<div className="grid gap-8 lg:grid-cols-[1fr_0.7fr] lg:items-center">
<div>
<p className="mb-3 inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-xs font-black uppercase tracking-[0.2em] text-blue-100">
<span></span> First iteration
</p>
<h2 className="text-4xl font-black tracking-tight">Open the PMS and run a booking from input to operational status.</h2>
<p className="mt-4 max-w-2xl leading-7 text-blue-50">
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.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row lg:flex-col">
<BaseButton href="/front-desk" icon={mdiHomeCity} label="Launch Front Desk" color="white" className="py-3" />
<BaseButton href="/login" icon={mdiLogin} label="Go to admin login" color="white" outline className="border-white py-3 text-white hover:bg-white/10" />
</div>
</div>
</div>
</section>
</main>
</>
)
} }
Starter.getLayout = function getLayout(page: ReactElement) { Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>
}; }
export default Starter

View File

@ -1,9 +1,7 @@
import React, { ReactElement, useEffect, useState } from 'react'; import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import { useAppDispatch } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import LayoutAuthenticated from '../layouts/Authenticated'; import LayoutAuthenticated from '../layouts/Authenticated';

View File

@ -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 (
<>
<Head>
<title>{getPageTitle('Stripe Payment Cancelled')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiAlertCircleOutline} title="Stripe payment cancelled" main>
<BaseButton href="/front-desk" icon={mdiArrowLeft} label="Back to Front Desk" color="info" />
</SectionTitleLineWithButton>
<CardBox className="border-0 shadow-sm ring-1 ring-amber-100">
<div className="rounded-3xl bg-amber-50 p-6 text-amber-900">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-amber-600">HotelPilot PMS</p>
<h2 className="mt-2 text-3xl font-black">Checkout was cancelled</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-amber-700">
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.
</p>
</div>
<div className="mt-6 space-y-4">
{reservationId && (
<p className="rounded-2xl bg-slate-50 p-4 text-sm text-slate-600">
Reservation reference: <span className="font-bold">{reservationId}</span>
</p>
)}
<div className="flex flex-wrap gap-3">
<BaseButton href="/front-desk" color="info" label="Retry from Front Desk" />
<BaseButton href="/reservations/reservations-list" color="whiteDark" label="Open Reservations" />
</div>
</div>
</CardBox>
</SectionMain>
</>
)
}
StripeCancelPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default StripeCancelPage

View File

@ -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<string, string | undefined>
}
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<PaymentsListResponse>('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<StripeSession | null>(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<StripeSession>(`/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 (
<>
<Head>
<title>{getPageTitle('Stripe Payment Success')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiCreditCardCheckOutline} title="Stripe payment success" main>
<BaseButton href="/front-desk" icon={mdiArrowLeft} label="Back to Front Desk" color="info" />
</SectionTitleLineWithButton>
<CardBox className="border-0 shadow-sm ring-1 ring-emerald-100">
<div className="rounded-3xl bg-emerald-50 p-6 text-emerald-900">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-emerald-600">HotelPilot PMS</p>
<h2 className="mt-2 text-3xl font-black">Payment return received</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-emerald-700">
HotelPilot is verifying the Stripe Checkout session before updating the front desk payment state.
</p>
</div>
<div className="mt-6 space-y-4">
{loading && <p className="rounded-2xl bg-slate-50 p-4 text-sm text-slate-600">Verifying Stripe payment...</p>}
{!loading && errorMessage && (
<p className="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm font-medium text-rose-800">{errorMessage}</p>
)}
{!loading && !errorMessage && session && (
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-slate-100 p-4">
<p className="text-xs uppercase tracking-wide text-slate-400">Session</p>
<p className="mt-1 break-all font-bold text-slate-900">{session.id}</p>
</div>
<div className="rounded-2xl border border-slate-100 p-4">
<p className="text-xs uppercase tracking-wide text-slate-400">Payment status</p>
<p className="mt-1 font-bold text-slate-900">{session.payment_status || 'Unknown'}</p>
</div>
<div className="rounded-2xl border border-slate-100 p-4">
<p className="text-xs uppercase tracking-wide text-slate-400">Amount</p>
<p className="mt-1 font-bold text-slate-900">{moneyFromCents(session.amount_total, session.currency)}</p>
</div>
<div className="rounded-2xl border border-slate-100 p-4">
<p className="text-xs uppercase tracking-wide text-slate-400">Guest email</p>
<p className="mt-1 font-bold text-slate-900">{session.customer_email || 'Not provided'}</p>
</div>
</div>
)}
{syncMessage && <p className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm font-medium text-emerald-800">{syncMessage}</p>}
{syncErrorMessage && <p className="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm font-medium text-rose-800">{syncErrorMessage}</p>}
<div className="flex flex-wrap gap-3">
<BaseButton href="/front-desk" color="info" label="Return to Front Desk" />
<BaseButton href="/payments/payments-list" color="whiteDark" label="Open Payments CRUD" />
</div>
</div>
</CardBox>
</SectionMain>
</>
)
}
StripeSuccessPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default StripeSuccessPage