Compare commits

...

10 Commits

Author SHA1 Message Date
76101e8811 fix: skip macOS ._* artifact files in migrations runner
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:08:24 +03:00
6d07632f01 feat: notification settings per role + review/unread-msg notifications
- Add notificationSettings JSONB to role_permissions (migration 089)
- sendPushForNotification() — pushes only to users with the notif type enabled
- reviews.ts — push + in-app on new direct and QR reviews
- room-service.ts — use sendPushForNotification('room_service_order')
- publicWidget.ts — push + in-app on new online booking
- jobs.ts — runUnreadMessagesJob() every 15 min, deduped by in-process set
- UsersPage: NOTIFICATION_GROUPS UI in roles tab with per-type toggles
- api.ts / RolePermissionsContext: notificationSettings in types and save()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:43:39 +03:00
1975663cdd feat: full Room Service backend integration
- Migrations 086-088: room_service_settings, menu_items, orders+order_items
- Backend: /api/hotels/:slug/room-service/* routes (settings, menu CRUD,
  orders CRUD, public config, public order status)
- push.ts: sendPushToHotel() — push to all hotel staff on new order
- On new order: in-app notification + push to hotel staff
- api.ts: RoomService types + roomService API methods
- RoomServicePage: replaced mocked state with real API, 30s polling,
  add/edit/delete menu items modal, real advance/cancel order
- GuestRoomServicePage: fetches real menu + config, uses hotel payment
  methods, submits real order, polls status every 15s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:44:28 +03:00
0e2180e3bc feat: configurable source→platform map, custom location QR codes
- Settings tab: replace static source map rows with editable table
  (add/delete mappings, persisted as review_source_map in hotel_settings)
- QR tab: add custom location QR codes (Ресепшн, Ресторан, etc.)
  not tied to a room, stored as review_custom_locations
- Backend: GET /api/public/review/:token reads review_source_map from
  hotel_settings instead of hardcoded SOURCE_KEYWORD mapping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 10:44:07 +03:00
47215c7d64 fix: reviews — correct pending/archive logic, rejected_at field, stats, reply email
- Migration 085: add rejected_at to reviews
- pending = !isPublic && !reply && !rejectedAt
- archived = isPublic || reply || rejectedAt
- Stats: avgRating/NPS from all reviews; 'В архиве' = archived count
- sendReply: no longer sets isPublic=true on negative reviews
- reject: sets rejected_at=NOW() (distinct from pending)
- restore: clears rejected_at (now actually works)
- Badge 'Отклонён' for rejected reviews, opacity-60

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 10:33:01 +03:00
89e919bd8d feat: reply email to guest, QR code reviews per room
- PATCH reviews/:id now sends email to guest when manager adds reply
  (sendReviewReplyEmail: hotel-branded, shows original rating, manager's text)
- New public endpoints: GET/POST /api/public/review-qr/:slug/:room
  (anonymous QR review, no token/booking required, source='qr')
- New page GuestReviewQrPage at /review-qr/:slug/:room
- ReviewsPage: new QR-коды tab — grid of QR codes per room with download SVG + print

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 10:23:54 +03:00
67c47a6d01 fix: review email — hotel-branded header, proper first name, 30d guard, brand color
- Email no longer shows HotelSync branding, shows hotel name instead
- Greeting uses second word of guest_name as first name (Фамилия Имя format)
- Footer link changed to hotelsync.ru
- Brand color from hotel settings applied to email button/header
- Job: only process bookings checked out within last 30 days (prevents bulk send on first activation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 10:14:22 +03:00
2de9b9e27d fix: ReviewsPage use useAuth for slug; reviews redirect by booking source
- Replace useParams with useAuth().user.hotelSlug in ReviewsPage
- Backend: filter platforms by booking source when review_redirect_source=true
  (booking_com→Booking.com, yandex_travel→Яндекс, airbnb, expedia, vrbo)
- If source has matching platform — show only that one; otherwise all platforms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 00:21:26 +03:00
6ba591928a fix: use request.user.sub instead of .id in users route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 00:07:16 +03:00
1ab2751854 feat: reviews module — email request after checkout, API, job
- Migration 084: add review_token, review_email_sent_at, review_retry_sent_at to bookings
- Backend route /api/hotels/:slug/reviews (GET list, PATCH reply/status)
- Backend public routes /api/public/review/:token (GET config, POST submit)
- email.ts: sendReviewRequestEmail template
- jobs.ts: runReviewRequestJob — sends email N hours after checkout, retry after 24h
- app.ts: register reviewsRoutes
- api.ts: add ReviewApi, PublicReviewConfig types + reviews/publicReview methods
- ReviewsPage: connect to real API, load/save settings, remove SMS option
- GuestReviewPage: use :token param, fetch config from API, submit to API
- App.tsx: route /review/:slug → /review/:token

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 00:04:59 +03:00
25 changed files with 2864 additions and 845 deletions

View File

@@ -0,0 +1,8 @@
-- Migration 084 — review tokens on bookings
ALTER TABLE bookings
ADD COLUMN IF NOT EXISTS review_token TEXT UNIQUE,
ADD COLUMN IF NOT EXISTS review_email_sent_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS review_retry_sent_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_bookings_review_token ON bookings(review_token);

View File

@@ -0,0 +1,2 @@
-- Migration 085 — add rejected_at to reviews for explicit rejected state
ALTER TABLE reviews ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMPTZ;

View File

@@ -0,0 +1,12 @@
-- Migration 086 — Room Service: settings per hotel
CREATE TABLE room_service_settings (
hotel_id UUID PRIMARY KEY REFERENCES hotels(id) ON DELETE CASCADE,
is_active BOOLEAN NOT NULL DEFAULT true,
service_charge_pct INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Auto-create settings for existing hotels
INSERT INTO room_service_settings (hotel_id)
SELECT id FROM hotels ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,18 @@
-- Migration 087 — Room Service: menu items
CREATE TABLE room_service_menu_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'Основные',
price NUMERIC(10,2) NOT NULL,
emoji TEXT NOT NULL DEFAULT '🍽️',
prep_time_min INTEGER NOT NULL DEFAULT 15,
is_popular BOOLEAN NOT NULL DEFAULT false,
is_stop_list BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON room_service_menu_items(hotel_id, is_active);

View File

@@ -0,0 +1,32 @@
-- Migration 088 — Room Service: orders & order items
CREATE TABLE room_service_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
room_number TEXT NOT NULL,
guest_name TEXT NOT NULL DEFAULT '',
order_type TEXT NOT NULL DEFAULT 'room' CHECK (order_type IN ('room', 'restaurant')),
scheduled_time TEXT,
status TEXT NOT NULL DEFAULT 'new'
CHECK (status IN ('new','preparing','ready','delivered','cancelled')),
payment_method TEXT, -- name from hotel_payment_methods (snapshot)
subtotal NUMERIC(10,2) NOT NULL DEFAULT 0,
service_charge NUMERIC(10,2) NOT NULL DEFAULT 0,
total NUMERIC(10,2) NOT NULL DEFAULT 0,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE room_service_order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES room_service_orders(id) ON DELETE CASCADE,
menu_item_id UUID REFERENCES room_service_menu_items(id) ON DELETE SET NULL,
name TEXT NOT NULL,
emoji TEXT NOT NULL DEFAULT '🍽️',
price NUMERIC(10,2) NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX ON room_service_orders(hotel_id, status);
CREATE INDEX ON room_service_orders(hotel_id, created_at DESC);

View File

@@ -0,0 +1,4 @@
-- Migration 089 — Notification settings per role
ALTER TABLE role_permissions
ADD COLUMN IF NOT EXISTS notification_settings JSONB NOT NULL DEFAULT '{}'::jsonb;

View File

@@ -48,6 +48,8 @@ import paymentGatewaysRoutes from './routes/paymentGateways'
import publicWidgetRoutes from './routes/publicWidget' import publicWidgetRoutes from './routes/publicWidget'
import yookassaWebhookRoutes from './routes/yookassaWebhook' import yookassaWebhookRoutes from './routes/yookassaWebhook'
import rolePermissionsRoutes from './routes/role-permissions' import rolePermissionsRoutes from './routes/role-permissions'
import reviewsRoutes from './routes/reviews'
import roomServiceRoutes from './routes/room-service'
import { setupAgentWsRoute } from './agent-ws' import { setupAgentWsRoute } from './agent-ws'
import { startJobs } from './jobs' import { startJobs } from './jobs'
import { initWebPush } from './push' import { initWebPush } from './push'
@@ -149,6 +151,8 @@ export async function buildApp() {
await fastify.register(publicWidgetRoutes) await fastify.register(publicWidgetRoutes)
await fastify.register(yookassaWebhookRoutes) await fastify.register(yookassaWebhookRoutes)
await fastify.register(rolePermissionsRoutes) await fastify.register(rolePermissionsRoutes)
await fastify.register(reviewsRoutes)
await fastify.register(roomServiceRoutes)
await fastify.register(setupAgentWsRoute) await fastify.register(setupAgentWsRoute)
startJobs() startJobs()

View File

@@ -240,3 +240,127 @@ export async function sendBookingConfirmedEmail(params: {
headers: { 'Content-Language': 'ru' }, headers: { 'Content-Language': 'ru' },
}) })
} }
export async function sendReviewRequestEmail(params: {
to: string
guestFirstName: string
hotelName: string
brandColor: string
reviewUrl: string
}): Promise<void> {
const { to, guestFirstName, hotelName, brandColor, reviewUrl } = params
const siteUrl = process.env.SITE_URL ?? 'https://hotelsync.ru'
const greeting = guestFirstName
? `Здравствуйте, ${guestFirstName}!`
: 'Здравствуйте!'
const html = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body style="font-family:Arial,sans-serif;background:#f1f5f9;margin:0;padding:40px 16px;">
<div style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
<div style="background:${brandColor};padding:28px 40px;text-align:center;">
<h1 style="color:#ffffff;margin:0;font-size:24px;font-weight:700;letter-spacing:-0.5px;">${hotelName}</h1>
</div>
<div style="padding:40px;">
<h2 style="color:#1e293b;font-size:20px;margin:0 0 16px;font-weight:600;">Как вам у нас?</h2>
<p style="color:#334155;line-height:1.7;margin:0 0 8px;font-size:15px;">${greeting}</p>
<p style="color:#334155;line-height:1.7;margin:0 0 28px;font-size:15px;">
Спасибо, что остановились в <strong>${hotelName}</strong>. Ваше мнение очень важно для нас —
это займёт не больше минуты!
</p>
<div style="text-align:center;margin:0 0 28px;">
<a href="${reviewUrl}" style="display:inline-block;background:${brandColor};color:#ffffff;text-decoration:none;padding:14px 36px;border-radius:10px;font-weight:600;font-size:16px;letter-spacing:0.2px;">
⭐ Оставить отзыв
</a>
</div>
<p style="color:#475569;font-size:13px;margin:0;line-height:1.6;text-align:center;">
Будем рады видеть вас снова!
</p>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;">
<p style="color:#64748b;font-size:11px;margin:0;line-height:1.6;text-align:center;">
© 2026 ${hotelName} · <a href="${siteUrl}" style="color:#6366f1;text-decoration:none;">hotelsync.ru</a>
</p>
</div>
</div>
</body>
</html>`
const text = `${hotelName} — оставьте отзыв\n\n${greeting}\n\nСпасибо, что остановились в ${hotelName}. Оставьте отзыв — это займёт не больше минуты:\n${reviewUrl}\n\n© 2026 ${hotelName}`
await transporter.sendMail({
from: `"${hotelName}" <${fromAddr()}>`,
to,
subject: `${hotelName} — как вам у нас?`,
html,
text,
headers: {
'List-Unsubscribe': unsubscribeHeader,
'Content-Language': 'ru',
},
})
}
export async function sendReviewReplyEmail(params: {
to: string
guestFirstName: string
hotelName: string
brandColor: string
replyText: string
originalRating: number
}): Promise<void> {
const { to, guestFirstName, hotelName, brandColor, replyText, originalRating } = params
const siteUrl = process.env.SITE_URL ?? 'https://hotelsync.ru'
const greeting = guestFirstName ? `Здравствуйте, ${guestFirstName}!` : 'Здравствуйте!'
const stars = '⭐'.repeat(originalRating)
const html = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body style="font-family:Arial,sans-serif;background:#f1f5f9;margin:0;padding:40px 16px;">
<div style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
<div style="background:${brandColor};padding:28px 40px;text-align:center;">
<h1 style="color:#ffffff;margin:0;font-size:24px;font-weight:700;letter-spacing:-0.5px;">${hotelName}</h1>
</div>
<div style="padding:40px;">
<h2 style="color:#1e293b;font-size:20px;margin:0 0 16px;font-weight:600;">Ответ на ваш отзыв</h2>
<p style="color:#334155;line-height:1.7;margin:0 0 20px;font-size:15px;">${greeting}</p>
<p style="color:#334155;line-height:1.7;margin:0 0 20px;font-size:15px;">
Спасибо, что поделились своим мнением о <strong>${hotelName}</strong>. Мы получили ваш отзыв ${stars} и хотим ответить.
</p>
<div style="background:#f8fafc;border-left:4px solid ${brandColor};border-radius:0 10px 10px 0;padding:16px 20px;margin:0 0 28px;">
<p style="color:#334155;font-size:14px;line-height:1.7;margin:0;font-style:italic;">${replyText}</p>
</div>
<p style="color:#475569;font-size:13px;margin:0;line-height:1.6;text-align:center;">
Будем рады видеть вас снова!
</p>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;">
<p style="color:#64748b;font-size:11px;margin:0;line-height:1.6;text-align:center;">
© 2026 ${hotelName} · <a href="${siteUrl}" style="color:#6366f1;text-decoration:none;">hotelsync.ru</a>
</p>
</div>
</div>
</body>
</html>`
const text = `${hotelName} — ответ на ваш отзыв\n\n${greeting}\n\nСпасибо за ваш отзыв. Ответ от ${hotelName}:\n\n"${replyText}"\n\удем рады видеть вас снова!\n\n© 2026 ${hotelName}`
await transporter.sendMail({
from: `"${hotelName}" <${fromAddr()}>`,
to,
subject: `${hotelName} — ответ на ваш отзыв`,
html,
text,
headers: {
'List-Unsubscribe': unsubscribeHeader,
'Content-Language': 'ru',
},
})
}

View File

@@ -2,14 +2,19 @@ import { db } from './db'
import { broadcast } from './routes/ws' import { broadcast } from './routes/ws'
import { createNotification } from './routes/notifications' import { createNotification } from './routes/notifications'
import { getHkSettings } from './routes/housekeeping-settings' import { getHkSettings } from './routes/housekeeping-settings'
import { sendReviewRequestEmail } from './email'
import { sendPushForNotification } from './push'
import { randomUUID } from 'crypto'
const JOB_INTERVAL_MS = 60 * 60 * 1000 // every 1 hour const JOB_INTERVAL_MS = 60 * 60 * 1000 // every 1 hour
const PAYMENT_EXPIRY_INTERVAL = 2 * 60 * 1000 // every 2 minutes const PAYMENT_EXPIRY_INTERVAL = 2 * 60 * 1000 // every 2 minutes
const UNREAD_MSG_INTERVAL = 15 * 60 * 1000 // every 15 minutes
async function runAutoJobs(): Promise<void> { async function runAutoJobs(): Promise<void> {
try { try {
await runAutoCancelNoShows() await runAutoCancelNoShows()
await runAutoCheckouts() await runAutoCheckouts()
await runReviewRequestJob()
} catch (err) { } catch (err) {
console.error('[jobs] error running auto jobs:', err) console.error('[jobs] error running auto jobs:', err)
} }
@@ -193,6 +198,192 @@ async function runAutoCheckouts(): Promise<void> {
} }
} }
// Extract first name from "Фамилия Имя" or "Имя Фамилия" or single word
function extractFirstName(fullName: string | null): string {
if (!fullName) return ''
const parts = fullName.trim().split(/\s+/)
// If 2+ words, prefer the second word (typically Имя in Russian "Фамилия Имя" format)
// If only 1 word, return empty so greeting is generic
return parts.length >= 2 ? parts[1] : ''
}
async function runReviewRequestJob(): Promise<void> {
const appUrl = process.env.APP_URL ?? 'https://app.hotelsync.ru'
// Hotels with review_request_enabled = true
const { rows: hotels } = await db.query<{
id: string; name: string; hours: number; brandColor: string
}>(
`SELECT h.id, h.name,
COALESCE(
(SELECT (value::text)::int FROM hotel_settings
WHERE hotel_id = h.id AND key = 'review_request_hours'),
2
) AS hours,
COALESCE(
(SELECT value #>> '{}' FROM hotel_settings
WHERE hotel_id = h.id AND key = 'review_brand_color'),
'#2563eb'
) AS "brandColor"
FROM hotels h
WHERE EXISTS (
SELECT 1 FROM hotel_settings
WHERE hotel_id = h.id AND key = 'review_request_enabled' AND value = 'true'::jsonb
)`,
)
for (const hotel of hotels) {
// Bookings checked out >= hotel.hours ago, within last 30 days, email not yet sent
const { rows: bookings } = await db.query<{
id: string; guest_name: string | null; guest_email: string | null
}>(
`SELECT b.id, b.guest_name, b.guest_email
FROM bookings b
WHERE b.hotel_id = $1
AND b.status = 'checked_out'
AND b.guest_email IS NOT NULL AND b.guest_email <> ''
AND b.review_email_sent_at IS NULL
AND b.updated_at + ($2 || ' hours')::interval < NOW()
AND b.updated_at > NOW() - interval '30 days'`,
[hotel.id, hotel.hours],
)
for (const b of bookings) {
const token = randomUUID()
await db.query(
`UPDATE bookings SET review_token = $1, review_email_sent_at = NOW() WHERE id = $2`,
[token, b.id],
)
await sendReviewRequestEmail({
to: b.guest_email!,
guestFirstName: extractFirstName(b.guest_name),
hotelName: hotel.name,
brandColor: hotel.brandColor,
reviewUrl: `${appUrl}/review/${token}`,
}).catch(err => console.error('[jobs] review email error:', err))
}
// ── Retry send ───────────────────────────────────────────────────────────
const { rows: retrySetting } = await db.query(
`SELECT value FROM hotel_settings
WHERE hotel_id = $1 AND key = 'review_request_retry' LIMIT 1`,
[hotel.id],
)
if (retrySetting[0]?.value !== true) continue
const { rows: retryBookings } = await db.query<{
id: string; guest_name: string | null; guest_email: string | null; review_token: string
}>(
`SELECT b.id, b.guest_name, b.guest_email, b.review_token
FROM bookings b
WHERE b.hotel_id = $1
AND b.status = 'checked_out'
AND b.review_email_sent_at IS NOT NULL
AND b.review_retry_sent_at IS NULL
AND b.review_email_sent_at + interval '24 hours' < NOW()
AND b.updated_at > NOW() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM reviews r WHERE r.booking_id = b.id
)`,
[hotel.id],
)
for (const b of retryBookings) {
await db.query(
`UPDATE bookings SET review_retry_sent_at = NOW() WHERE id = $1`,
[b.id],
)
await sendReviewRequestEmail({
to: b.guest_email!,
guestFirstName: extractFirstName(b.guest_name),
hotelName: hotel.name,
brandColor: hotel.brandColor,
reviewUrl: `${appUrl}/review/${b.review_token}`,
}).catch(err => console.error('[jobs] review retry email error:', err))
}
}
}
// Track which rooms we already notified (in-process, resets on restart — acceptable)
const notifiedRooms = new Set<string>()
async function runUnreadMessagesJob(): Promise<void> {
try {
// Find chat rooms that have unread messages older than 15 minutes
// (messages sent by guests/external, not yet read by any hotel user)
const { rows } = await db.query<{
hotel_id: string; hotel_slug: string; room_id: string; room_name: string; unread_count: number
}>(`
SELECT
h.id AS hotel_id,
h.slug AS hotel_slug,
cr.id AS room_id,
COALESCE(cr.name, 'Чат') AS room_name,
COUNT(cm.id) AS unread_count
FROM chat_messages cm
JOIN chat_rooms cr ON cr.id = cm.room_id
JOIN hotels h ON h.id = cr.hotel_id
WHERE cm.created_at < NOW() - interval '15 minutes'
AND cm.sender_type IN ('guest', 'system')
AND NOT EXISTS (
SELECT 1 FROM chat_message_reads cmr
WHERE cmr.message_id = cm.id
AND cmr.user_id IN (
SELECT id FROM users WHERE hotel_id = h.id
)
)
GROUP BY h.id, h.slug, cr.id, cr.name
HAVING COUNT(cm.id) > 0
`)
for (const row of rows) {
const key = `${row.hotel_id}:${row.room_id}`
if (notifiedRooms.has(key)) continue
notifiedRooms.add(key)
const count = Number(row.unread_count)
const body = `${count} непрочитанн${count === 1 ? 'ое сообщение' : count < 5 ? 'ых сообщения' : 'ых сообщений'} более 15 минут`
void createNotification(row.hotel_id, row.hotel_slug, {
type: 'unread_messages',
title: `Непрочитанные сообщения — ${row.room_name}`,
body,
link: `/${row.hotel_slug}/chat`,
}).catch(() => {})
void sendPushForNotification(row.hotel_id, 'unread_messages', {
title: `Непрочитанные сообщения — ${row.room_name}`,
body,
url: `/${row.hotel_slug}/chat`,
tag: `unread-${row.room_id}`,
})
}
// Clear keys for rooms that have been read (so next cycle they can re-trigger if new unread appear)
for (const key of notifiedRooms) {
const [hotelId, roomId] = key.split(':')
const { rows: unread } = await db.query<{ count: string }>(
`SELECT COUNT(cm.id) AS count
FROM chat_messages cm
JOIN hotels h ON h.id = $1
WHERE cm.room_id = $2
AND cm.sender_type IN ('guest', 'system')
AND NOT EXISTS (
SELECT 1 FROM chat_message_reads cmr
WHERE cmr.message_id = cm.id
AND cmr.user_id IN (SELECT id FROM users WHERE hotel_id = $1)
)`,
[hotelId, roomId],
)
if (Number(unread[0]?.count ?? 0) === 0) {
notifiedRooms.delete(key)
}
}
} catch (err) {
console.error('[jobs] unread messages job error:', err)
}
}
export function startJobs(): void { export function startJobs(): void {
// Small delay to let DB migrations finish on startup // Small delay to let DB migrations finish on startup
setTimeout(() => { setTimeout(() => {
@@ -207,4 +398,8 @@ export function startJobs(): void {
setInterval(() => { setInterval(() => {
runPaymentExpiryJob().catch(console.error) runPaymentExpiryJob().catch(console.error)
}, PAYMENT_EXPIRY_INTERVAL) }, PAYMENT_EXPIRY_INTERVAL)
setInterval(() => {
runUnreadMessagesJob().catch(console.error)
}, UNREAD_MSG_INTERVAL)
} }

View File

@@ -15,7 +15,7 @@ export async function runMigrations() {
`) `)
const files = fs.readdirSync(migrationsDir) const files = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql')) .filter(f => f.endsWith('.sql') && !f.startsWith('._'))
.sort() .sort()
for (const file of files) { for (const file of files) {

View File

@@ -40,6 +40,67 @@ export async function sendPushToUser(userId: string, payload: PushPayload): Prom
} catch { /**/ } } catch { /**/ }
} }
// Send push to hotel users whose role has the given notification type enabled.
// If notification_settings is empty / key missing → defaults to true (send).
export async function sendPushForNotification(
hotelId: string,
notificationType: string,
payload: PushPayload,
): Promise<void> {
if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) return
try {
const { rows } = await db.query(
`SELECT ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN users u ON u.id = ps.user_id
LEFT JOIN role_permissions rp
ON rp.hotel_id = u.hotel_id AND rp.role_key = u.role
WHERE u.hotel_id = $1
AND COALESCE((rp.notification_settings->>$2)::boolean, true) = true`,
[hotelId, notificationType],
)
for (const sub of rows) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload),
)
} catch (err) {
const statusCode = (err as { statusCode?: number }).statusCode
if (statusCode === 410 || statusCode === 404) {
await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint])
}
}
}
} catch { /**/ }
}
export async function sendPushToHotel(hotelId: string, payload: PushPayload): Promise<void> {
if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) return
try {
const { rows } = await db.query(
`SELECT ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN users u ON u.id = ps.user_id
WHERE u.hotel_id = $1`,
[hotelId],
)
for (const sub of rows) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload),
)
} catch (err) {
const statusCode = (err as { statusCode?: number }).statusCode
if (statusCode === 410 || statusCode === 404) {
await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint])
}
}
}
} catch { /**/ }
}
export async function sendPushToRoom(roomId: string, senderUserId: string, payload: PushPayload): Promise<void> { export async function sendPushToRoom(roomId: string, senderUserId: string, payload: PushPayload): Promise<void> {
if (!process.env.VAPID_PUBLIC_KEY) return if (!process.env.VAPID_PUBLIC_KEY) return
try { try {

View File

@@ -3,6 +3,8 @@ import { db } from '../db'
import { getGatewayForModule } from './paymentGateways' import { getGatewayForModule } from './paymentGateways'
import { createCharge } from '../services/yookassa' import { createCharge } from '../services/yookassa'
import { upsertGuestFromBooking } from '../services/guestUpsert' import { upsertGuestFromBooking } from '../services/guestUpsert'
import { createNotification } from './notifications'
import { sendPushForNotification } from '../push'
type SlugParam = { Params: { slug: string } } type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; bookingId: string } } type SlugIdParam = { Params: { slug: string; bookingId: string } }
@@ -466,6 +468,22 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
guestPhone, guestPhone,
}) })
// Notify staff about new widget booking
const nights = Math.ceil((new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
void createNotification(hotel.id, hotel.slug, {
type: 'new_booking',
title: `Новая бронь через сайт — ${guestName}`,
body: `${checkIn}${checkOut}, ${nights} ${nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}`,
bookingId,
link: `/${hotel.slug}/bookings`,
})
void sendPushForNotification(hotel.id, 'new_booking', {
title: `Новая бронь через сайт — ${guestName}`,
body: `${checkIn}${checkOut}`,
url: `/${hotel.slug}/bookings`,
tag: `booking-${bookingId}`,
})
if (paymentMethod === 'yookassa') { if (paymentMethod === 'yookassa') {
// Create YooKassa payment // Create YooKassa payment
try { try {

View File

@@ -0,0 +1,378 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { sendReviewReplyEmail } from '../email'
import { createNotification } from './notifications'
import { sendPushForNotification } from '../push'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
type TokenParam = { Params: { token: string } }
type SlugRoomParam = { Params: { slug: string; room: string } }
function extractFirstName(fullName: string | null): string {
if (!fullName) return ''
const parts = fullName.trim().split(/\s+/)
return parts.length >= 2 ? parts[1] : ''
}
const canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
const getHotelId = async (slug: string): Promise<string | null> => {
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
return rows[0]?.id ?? null
}
const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
// ── GET /api/hotels/:slug/reviews ─────────────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/reviews',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
`SELECT r.id, r.booking_id, r.guest_id, r.guest_name, r.source,
r.rating, r.text, r.reply, r.replied_at, r.is_public, r.rejected_at,
r.created_at, r.updated_at,
b.room_id,
rm.number AS room_number,
b.source AS booking_source
FROM reviews r
LEFT JOIN bookings b ON b.id = r.booking_id
LEFT JOIN rooms rm ON rm.id = b.room_id
WHERE r.hotel_id = $1
ORDER BY r.created_at DESC`,
[hotelId],
)
return rows
},
)
// ── PATCH /api/hotels/:slug/reviews/:id ───────────────────────────────────
fastify.patch<SlugIdParam & { Body: { reply?: string; isPublic?: boolean; rejected?: boolean } }>(
'/api/hotels/:slug/reviews/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { reply: replyText, isPublic, rejected } = request.body
const updates: string[] = []
const vals: unknown[] = [id, hotelId]
if (replyText !== undefined) {
vals.push(replyText)
updates.push(`reply = $${vals.length}, replied_at = NOW()`)
}
if (isPublic !== undefined) {
vals.push(isPublic)
updates.push(`is_public = $${vals.length}`)
}
if (rejected === true) updates.push(`rejected_at = NOW()`)
if (rejected === false) updates.push(`rejected_at = NULL`)
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
const { rows } = await db.query(
`UPDATE reviews SET ${updates.join(', ')}, updated_at = NOW()
WHERE id = $1 AND hotel_id = $2
RETURNING *`,
vals,
)
if (!rows[0]) return reply.code(404).send({ error: 'Review not found' })
// Send email to guest when manager adds a reply
if (replyText && rows[0].booking_id) {
const { rows: bRows } = await db.query<{
guest_email: string | null; guest_name: string | null
}>(
`SELECT guest_email, guest_name FROM bookings WHERE id = $1`,
[rows[0].booking_id],
)
const guestEmail = bRows[0]?.guest_email
if (guestEmail) {
const { rows: hRows } = await db.query<{ name: string }>(
`SELECT h.name,
COALESCE((SELECT value #>> '{}' FROM hotel_settings
WHERE hotel_id = h.id AND key = 'review_brand_color'), '#2563eb') AS color
FROM hotels h WHERE h.id = $1`,
[hotelId],
)
sendReviewReplyEmail({
to: guestEmail,
guestFirstName: extractFirstName(bRows[0]?.guest_name ?? null),
hotelName: (hRows[0] as unknown as { name: string; color: string })?.name ?? '',
brandColor: (hRows[0] as unknown as { name: string; color: string })?.color ?? '#2563eb',
replyText,
originalRating: rows[0].rating,
}).catch(err => console.error('[reviews] reply email error:', err))
}
}
return rows[0]
},
)
// ── GET /api/public/review/:token ─────────────────────────────────────────
// Returns hotel config + guest name for the public review form
fastify.get<TokenParam>(
'/api/public/review/:token',
async (request, reply) => {
const { token } = request.params
const { rows } = await db.query<{
hotel_id: string; slug: string; hotel_name: string
guest_name: string | null; guest_email: string | null
booking_id: string; booking_source: string | null
}>(
`SELECT b.id AS booking_id, b.guest_name, b.guest_email,
b.source AS booking_source,
h.id AS hotel_id, h.slug, h.name AS hotel_name
FROM bookings b
JOIN hotels h ON h.id = b.hotel_id
WHERE b.review_token = $1`,
[token],
)
const row = rows[0]
if (!row) return reply.code(404).send({ error: 'Invalid or expired link' })
// Check if review already submitted
const { rows: existing } = await db.query(
`SELECT id FROM reviews WHERE booking_id = $1 LIMIT 1`,
[row.booking_id],
)
if (existing[0]) return reply.code(410).send({ error: 'Review already submitted' })
// Load review settings for this hotel
const { rows: settings } = await db.query(
`SELECT key, value FROM hotel_settings
WHERE hotel_id = $1
AND key IN (
'review_redirect_threshold','review_platforms',
'review_show_text_field','review_welcome_text',
'review_brand_color','review_redirect_source',
'review_source_map'
)`,
[row.hotel_id],
)
const s: Record<string, unknown> = {}
for (const r of settings) s[r.key] = r.value
const allPlatforms = Array.isArray(s.review_platforms)
? (s.review_platforms as { name: string; url: string; enabled: boolean }[])
.filter(p => p.enabled)
: []
const sourceMap = Array.isArray(s.review_source_map)
? (s.review_source_map as { bookingSource: string; platformName: string }[])
: []
const useSourceRedirect = s.review_redirect_source !== false
const bookingSrc = row.booking_source ?? 'direct'
// If source redirect is on, find matching entry in sourceMap, then find the platform by name
let platforms = allPlatforms.map(p => ({ name: p.name, url: p.url }))
if (useSourceRedirect && sourceMap.length > 0) {
const entry = sourceMap.find(e => e.bookingSource === bookingSrc)
if (entry) {
const match = allPlatforms.find(p =>
p.name.toLowerCase().includes(entry.platformName.toLowerCase()),
)
if (match) platforms = [{ name: match.name, url: match.url }]
}
}
return {
hotelName: row.hotel_name,
logoLetter: (row.hotel_name as string)[0] ?? 'О',
welcomeText: (s.review_welcome_text as string | undefined) ?? 'Как вам у нас?',
color: (s.review_brand_color as string | undefined) ?? '#2563eb',
redirectThreshold: Number(s.review_redirect_threshold ?? 4),
showTextField: s.review_show_text_field !== false,
platforms,
guestName: row.guest_name ?? '',
bookingSource: bookingSrc,
}
},
)
// ── POST /api/public/review/:token ────────────────────────────────────────
fastify.post<TokenParam & { Body: { rating: number; text?: string } }>(
'/api/public/review/:token',
async (request, reply) => {
const { token } = request.params
const { rating, text } = request.body
if (!rating || rating < 1 || rating > 5) {
return reply.code(400).send({ error: 'Rating must be 15' })
}
const { rows } = await db.query<{
id: string; hotel_id: string; guest_name: string | null; guest_id: string | null
}>(
`SELECT b.id, b.hotel_id, b.guest_name, bg.guest_id
FROM bookings b
LEFT JOIN (
SELECT DISTINCT ON (booking_id) booking_id, guest_id
FROM booking_guests ORDER BY booking_id, created_at
) bg ON bg.booking_id = b.id
WHERE b.review_token = $1`,
[token],
)
const booking = rows[0]
if (!booking) return reply.code(404).send({ error: 'Invalid link' })
// Prevent duplicate
const { rows: dup } = await db.query(
`SELECT id FROM reviews WHERE booking_id = $1 LIMIT 1`,
[booking.id],
)
if (dup[0]) return reply.code(409).send({ error: 'Already submitted' })
await db.query(
`INSERT INTO reviews
(hotel_id, booking_id, guest_id, guest_name, source, rating, text, is_public)
VALUES ($1, $2, $3, $4, 'direct', $5, $6, $7)`,
[
booking.hotel_id,
booking.id,
booking.guest_id ?? null,
booking.guest_name ?? 'Гость',
rating,
text ?? null,
rating >= 4, // auto-publish positive reviews
],
)
const hotelSlugRow = await db.query<{ slug: string }>('SELECT slug FROM hotels WHERE id = $1', [booking.hotel_id])
const hotelSlug = hotelSlugRow.rows[0]?.slug ?? ''
void createNotification(booking.hotel_id, hotelSlug, {
type: 'new_review',
title: `Новый отзыв — ${rating}`,
body: `${booking.guest_name ?? 'Гость'}${text ? ': ' + text.slice(0, 80) : ''}`,
link: `/${hotelSlug}/reviews`,
})
void sendPushForNotification(booking.hotel_id, 'new_review', {
title: `Новый отзыв — ${rating}`,
body: `${booking.guest_name ?? 'Гость'}${text ? ': ' + text.slice(0, 80) : ''}`,
url: `/${hotelSlug}/reviews`,
tag: `review-${booking.id}`,
})
return { ok: true }
},
)
// ── GET /api/public/review-qr/:slug/:room ─────────────────────────────────
// Anonymous QR review — returns hotel config, no booking required
fastify.get<SlugRoomParam>(
'/api/public/review-qr/:slug/:room',
async (request, reply) => {
const { slug, room } = request.params
const { rows } = await db.query<{ id: string; name: string }>(
`SELECT id, name FROM hotels WHERE slug = $1 AND is_active = true`,
[slug],
)
const hotel = rows[0]
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { rows: settings } = await db.query(
`SELECT key, value FROM hotel_settings
WHERE hotel_id = $1
AND key IN (
'review_redirect_threshold','review_platforms',
'review_show_text_field','review_welcome_text',
'review_brand_color'
)`,
[hotel.id],
)
const s: Record<string, unknown> = {}
for (const r of settings) s[r.key] = r.value
const allPlatforms = Array.isArray(s.review_platforms)
? (s.review_platforms as { name: string; url: string; enabled: boolean }[])
.filter(p => p.enabled)
.map(p => ({ name: p.name, url: p.url }))
: []
return {
hotelName: hotel.name,
logoLetter: hotel.name[0] ?? 'О',
welcomeText: (s.review_welcome_text as string | undefined) ?? 'Как вам у нас?',
color: (s.review_brand_color as string | undefined) ?? '#2563eb',
redirectThreshold: Number(s.review_redirect_threshold ?? 4),
showTextField: s.review_show_text_field !== false,
platforms: allPlatforms,
roomNumber: room,
}
},
)
// ── POST /api/public/review-qr/:slug/:room ────────────────────────────────
fastify.post<SlugRoomParam & { Body: { rating: number; text?: string } }>(
'/api/public/review-qr/:slug/:room',
async (request, reply) => {
const { slug, room } = request.params
const { rating, text } = request.body
if (!rating || rating < 1 || rating > 5) {
return reply.code(400).send({ error: 'Rating must be 15' })
}
const { rows } = await db.query<{ id: string }>(
`SELECT id FROM hotels WHERE slug = $1`,
[slug],
)
const hotel = rows[0]
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
// Find room_id by number for attribution
const { rows: roomRows } = await db.query<{ id: string }>(
`SELECT id FROM rooms WHERE hotel_id = $1 AND number = $2 LIMIT 1`,
[hotel.id, room],
)
await db.query(
`INSERT INTO reviews
(hotel_id, booking_id, guest_id, guest_name, source, rating, text, is_public)
VALUES ($1, NULL, NULL, $2, 'qr', $3, $4, $5)`,
[
hotel.id,
roomRows[0] ? `Номер ${room}` : `QR (номер ${room})`,
rating,
text ?? null,
rating >= 4,
],
)
void createNotification(hotel.id, slug, {
type: 'new_review',
title: `Новый QR-отзыв — №${room}, ${rating}`,
body: text ? text.slice(0, 80) : `Оценка: ${rating} из 5`,
link: `/${slug}/reviews`,
})
void sendPushForNotification(hotel.id, 'new_review', {
title: `Новый QR-отзыв — №${room}, ${rating}`,
body: text ? text.slice(0, 80) : `Оценка: ${rating} из 5`,
url: `/${slug}/reviews`,
tag: `review-qr-${hotel.id}-${room}`,
})
return { ok: true }
},
)
}
export default reviewsRoutes

View File

@@ -36,21 +36,22 @@ const rolePermissionsRoute: FastifyPluginAsync = async (fastify) => {
[hotelId], [hotelId],
) )
return rows.map(r => ({ return rows.map(r => ({
id: r.id, id: r.id,
hotelId: r.hotel_id, hotelId: r.hotel_id,
roleKey: r.role_key, roleKey: r.role_key,
name: r.name, name: r.name,
color: r.color, color: r.color,
isSystem: r.is_system, isSystem: r.is_system,
permissions: r.permissions, permissions: r.permissions,
homePage: r.home_page, homePage: r.home_page,
notificationSettings: r.notification_settings ?? {},
})) }))
}, },
) )
// ── PUT /api/hotels/:slug/role-permissions/:roleKey ──────────────────────── // ── PUT /api/hotels/:slug/role-permissions/:roleKey ────────────────────────
fastify.put<SlugRoleParam & { fastify.put<SlugRoleParam & {
Body: { name: string; color: string; isSystem?: boolean; permissions: Record<string, boolean>; homePage?: string } Body: { name: string; color: string; isSystem?: boolean; permissions: Record<string, boolean>; homePage?: string; notificationSettings?: Record<string, boolean> }
}>( }>(
'/api/hotels/:slug/role-permissions/:roleKey', '/api/hotels/:slug/role-permissions/:roleKey',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
@@ -65,30 +66,32 @@ const rolePermissionsRoute: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug) const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { name, color, isSystem = false, permissions, homePage = null } = request.body const { name, color, isSystem = false, permissions, homePage = null, notificationSettings = {} } = request.body
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO role_permissions (hotel_id, role_key, name, color, is_system, permissions, home_page) `INSERT INTO role_permissions (hotel_id, role_key, name, color, is_system, permissions, home_page, notification_settings)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (hotel_id, role_key) DO UPDATE ON CONFLICT (hotel_id, role_key) DO UPDATE
SET name = EXCLUDED.name, SET name = EXCLUDED.name,
color = EXCLUDED.color, color = EXCLUDED.color,
permissions = EXCLUDED.permissions, permissions = EXCLUDED.permissions,
home_page = EXCLUDED.home_page, home_page = EXCLUDED.home_page,
notification_settings = EXCLUDED.notification_settings,
updated_at = NOW() updated_at = NOW()
RETURNING *`, RETURNING *`,
[hotelId, roleKey, name, color, isSystem, JSON.stringify(permissions), homePage], [hotelId, roleKey, name, color, isSystem, JSON.stringify(permissions), homePage, JSON.stringify(notificationSettings)],
) )
const r = rows[0] const r = rows[0]
return { return {
id: r.id, id: r.id,
hotelId: r.hotel_id, hotelId: r.hotel_id,
roleKey: r.role_key, roleKey: r.role_key,
name: r.name, name: r.name,
color: r.color, color: r.color,
isSystem: r.is_system, isSystem: r.is_system,
permissions: r.permissions, permissions: r.permissions,
homePage: r.home_page, homePage: r.home_page,
notificationSettings: r.notification_settings ?? {},
} }
}, },
) )

View File

@@ -0,0 +1,435 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { createNotification } from './notifications'
import { sendPushForNotification } from '../push'
type SlugParam = { Params: { slug: string } }
type SlugItemParam = { Params: { slug: string; itemId: string } }
type SlugOrderParam = { Params: { slug: string; orderId: string } }
const getHotelRow = async (slug: string) => {
const { rows } = await db.query('SELECT id, name, slug FROM hotels WHERE slug = $1', [slug])
return rows[0] as { id: string; name: string; slug: string } | undefined
}
const canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
const isManager = (role: string) =>
['super_admin', 'hotel_admin', 'manager'].includes(role)
const roomServiceRoutes: FastifyPluginAsync = async (fastify) => {
// ── GET /api/hotels/:slug/room-service/public-config (no auth) ───────────
// Used by guest page to get hotel info, settings and payment methods
fastify.get<SlugParam>(
'/api/hotels/:slug/room-service/public-config',
async (request, reply) => {
const hotel = await getHotelRow(request.params.slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const [settingsRes, methodsRes] = await Promise.all([
db.query(
`SELECT service_charge_pct FROM room_service_settings WHERE hotel_id = $1`,
[hotel.id],
),
db.query(
`SELECT id, name, type FROM hotel_payment_methods
WHERE hotel_id = $1 AND is_active = true
ORDER BY sort_order`,
[hotel.id],
),
])
return {
hotelName: hotel.name,
hotelSlug: hotel.slug,
serviceChargePct: settingsRes.rows[0]?.service_charge_pct ?? 0,
paymentMethods: methodsRes.rows,
}
},
)
// ── GET /api/hotels/:slug/room-service/menu (no auth) ────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/room-service/menu',
async (request, reply) => {
const hotel = await getHotelRow(request.params.slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
`SELECT id, name, category, price, emoji, prep_time_min,
is_popular, is_stop_list, sort_order
FROM room_service_menu_items
WHERE hotel_id = $1 AND is_active = true
ORDER BY sort_order, name`,
[hotel.id],
)
return rows
},
)
// ── GET /api/hotels/:slug/room-service/menu (all, with inactive) ─────────
fastify.get<SlugParam>(
'/api/hotels/:slug/room-service/menu/all',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotel = await getHotelRow(slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
`SELECT * FROM room_service_menu_items
WHERE hotel_id = $1
ORDER BY sort_order, name`,
[hotel.id],
)
return rows
},
)
// ── POST /api/hotels/:slug/room-service/menu ──────────────────────────────
fastify.post<SlugParam & {
Body: {
name: string; category?: string; price: number; emoji?: string
prep_time_min?: number; is_popular?: boolean; sort_order?: number
}
}>(
'/api/hotels/:slug/room-service/menu',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role))
return reply.code(403).send({ error: 'Forbidden' })
const hotel = await getHotelRow(slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { name, category = 'Основные', price, emoji = '🍽️', prep_time_min = 15, is_popular = false, sort_order = 0 } = request.body
if (!name) return reply.code(400).send({ error: 'Name required' })
const { rows } = await db.query(
`INSERT INTO room_service_menu_items
(hotel_id, name, category, price, emoji, prep_time_min, is_popular, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[hotel.id, name, category, Number(price).toFixed(2), emoji, prep_time_min, is_popular, sort_order],
)
return reply.code(201).send(rows[0])
},
)
// ── PATCH /api/hotels/:slug/room-service/menu/:itemId ────────────────────
fastify.patch<SlugItemParam & {
Body: {
name?: string; category?: string; price?: number; emoji?: string
prep_time_min?: number; is_popular?: boolean; is_stop_list?: boolean
is_active?: boolean; sort_order?: number
}
}>(
'/api/hotels/:slug/room-service/menu/:itemId',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, itemId } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role))
return reply.code(403).send({ error: 'Forbidden' })
const hotel = await getHotelRow(slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const allowed = ['name','category','price','emoji','prep_time_min','is_popular','is_stop_list','is_active','sort_order']
const updates: string[] = []
const values: unknown[] = []
let idx = 1
const body = request.body as Record<string, unknown>
for (const key of allowed) {
if (body[key] !== undefined) {
updates.push(`${key} = $${idx}`)
values.push(key === 'price' ? Number(body[key]).toFixed(2) : body[key])
idx++
}
}
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
values.push(itemId, hotel.id)
const { rows } = await db.query(
`UPDATE room_service_menu_items SET ${updates.join(', ')}
WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`,
values,
)
if (!rows[0]) return reply.code(404).send({ error: 'Item not found' })
return rows[0]
},
)
// ── DELETE /api/hotels/:slug/room-service/menu/:itemId (soft) ────────────
fastify.delete<SlugItemParam>(
'/api/hotels/:slug/room-service/menu/:itemId',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, itemId } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role))
return reply.code(403).send({ error: 'Forbidden' })
const hotel = await getHotelRow(slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { rowCount } = await db.query(
'UPDATE room_service_menu_items SET is_active = false WHERE id = $1 AND hotel_id = $2',
[itemId, hotel.id],
)
if (!rowCount) return reply.code(404).send({ error: 'Item not found' })
return reply.code(204).send()
},
)
// ── GET /api/hotels/:slug/room-service/settings ───────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/room-service/settings',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotel = await getHotelRow(slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
'SELECT * FROM room_service_settings WHERE hotel_id = $1',
[hotel.id],
)
// Auto-create if missing
if (!rows[0]) {
const { rows: ins } = await db.query(
'INSERT INTO room_service_settings (hotel_id) VALUES ($1) RETURNING *',
[hotel.id],
)
return ins[0]
}
return rows[0]
},
)
// ── PATCH /api/hotels/:slug/room-service/settings ─────────────────────────
fastify.patch<SlugParam & { Body: { is_active?: boolean; service_charge_pct?: number } }>(
'/api/hotels/:slug/room-service/settings',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role))
return reply.code(403).send({ error: 'Forbidden' })
const hotel = await getHotelRow(slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { is_active, service_charge_pct } = request.body
const { rows } = await db.query(
`INSERT INTO room_service_settings (hotel_id, is_active, service_charge_pct)
VALUES ($1, $2, $3)
ON CONFLICT (hotel_id) DO UPDATE
SET is_active = COALESCE($2, room_service_settings.is_active),
service_charge_pct = COALESCE($3, room_service_settings.service_charge_pct),
updated_at = NOW()
RETURNING *`,
[hotel.id, is_active ?? null, service_charge_pct ?? null],
)
return rows[0]
},
)
// ── GET /api/hotels/:slug/room-service/orders ─────────────────────────────
fastify.get<SlugParam & { Querystring: { status?: string; date?: string } }>(
'/api/hotels/:slug/room-service/orders',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotel = await getHotelRow(slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { status, date } = request.query as { status?: string; date?: string }
const conditions = ['o.hotel_id = $1']
const values: unknown[] = [hotel.id]
let idx = 2
if (status) { conditions.push(`o.status = $${idx}`); values.push(status); idx++ }
if (date) { conditions.push(`o.created_at::date = $${idx}`); values.push(date); idx++ }
const { rows: orders } = await db.query(
`SELECT o.*,
COALESCE(
json_agg(
json_build_object(
'id', oi.id,
'menu_item_id', oi.menu_item_id,
'name', oi.name,
'emoji', oi.emoji,
'price', oi.price,
'quantity', oi.quantity
) ORDER BY oi.id
) FILTER (WHERE oi.id IS NOT NULL), '[]'
) AS items
FROM room_service_orders o
LEFT JOIN room_service_order_items oi ON oi.order_id = o.id
WHERE ${conditions.join(' AND ')}
GROUP BY o.id
ORDER BY o.created_at DESC
LIMIT 200`,
values,
)
return orders
},
)
// ── POST /api/hotels/:slug/room-service/orders (public — guest submits) ───
fastify.post<SlugParam & {
Body: {
room_number: string
guest_name?: string
order_type?: 'room' | 'restaurant'
scheduled_time?: string
payment_method?: string
notes?: string
items: Array<{ menu_item_id: string; quantity: number }>
}
}>(
'/api/hotels/:slug/room-service/orders',
async (request, reply) => {
const hotel = await getHotelRow(request.params.slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { room_number, guest_name = '', order_type = 'room', scheduled_time, payment_method, notes, items } = request.body
if (!room_number) return reply.code(400).send({ error: 'room_number required' })
if (!items || items.length === 0) return reply.code(400).send({ error: 'items required' })
// Get service charge setting
const { rows: settRows } = await db.query(
'SELECT service_charge_pct FROM room_service_settings WHERE hotel_id = $1',
[hotel.id],
)
const serviceChargePct = settRows[0]?.service_charge_pct ?? 0
// Fetch menu items to compute prices
const itemIds = items.map(i => i.menu_item_id)
const { rows: menuRows } = await db.query(
`SELECT id, name, emoji, price, is_stop_list FROM room_service_menu_items
WHERE hotel_id = $1 AND id = ANY($2) AND is_active = true`,
[hotel.id, itemIds],
)
const menuMap = new Map(menuRows.map((r: { id: string; name: string; emoji: string; price: string; is_stop_list: boolean }) => [r.id, r]))
// Validate items exist and not in stop list
for (const it of items) {
const m = menuMap.get(it.menu_item_id)
if (!m) return reply.code(400).send({ error: `Menu item not found: ${it.menu_item_id}` })
if (m.is_stop_list) return reply.code(400).send({ error: `Item is in stop list: ${m.name}` })
}
// Calculate totals
const subtotal = items.reduce((s, it) => {
const m = menuMap.get(it.menu_item_id)!
return s + Number(m.price) * it.quantity
}, 0)
const serviceCharge = order_type === 'room' ? Math.round(subtotal * serviceChargePct / 100) : 0
const total = subtotal + serviceCharge
// Create order
const { rows: orderRows } = await db.query(
`INSERT INTO room_service_orders
(hotel_id, room_number, guest_name, order_type, scheduled_time, payment_method, subtotal, service_charge, total, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
[hotel.id, room_number, guest_name, order_type,
scheduled_time ?? null, payment_method ?? null,
subtotal.toFixed(2), serviceCharge.toFixed(2), total.toFixed(2),
notes ?? null],
)
const order = orderRows[0]
// Create order items
for (const it of items) {
const m = menuMap.get(it.menu_item_id)!
await db.query(
`INSERT INTO room_service_order_items (order_id, menu_item_id, name, emoji, price, quantity)
VALUES ($1,$2,$3,$4,$5,$6)`,
[order.id, it.menu_item_id, m.name, m.emoji, Number(m.price).toFixed(2), it.quantity],
)
}
// In-app notification
const itemsSummary = items
.map(it => { const m = menuMap.get(it.menu_item_id)!; return `${m.emoji} ${m.name} ×${it.quantity}` })
.join(', ')
void createNotification(hotel.id, hotel.slug, {
type: 'room_service',
title: `Новый заказ Room Service — №${room_number}`,
body: itemsSummary,
link: '/room-service',
})
// Push notification to staff with room_service_order enabled
void sendPushForNotification(hotel.id, 'room_service_order', {
title: `Новый заказ Room Service — №${room_number}`,
body: itemsSummary,
url: '/room-service',
tag: `room-service-order-${order.id}`,
})
return reply.code(201).send(order)
},
)
// ── GET /api/hotels/:slug/room-service/orders/:orderId/status (public) ──────
fastify.get<SlugOrderParam>(
'/api/hotels/:slug/room-service/orders/:orderId/status',
async (request, reply) => {
const hotel = await getHotelRow(request.params.slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
'SELECT status, updated_at FROM room_service_orders WHERE id = $1 AND hotel_id = $2',
[request.params.orderId, hotel.id],
)
if (!rows[0]) return reply.code(404).send({ error: 'Order not found' })
return rows[0]
},
)
// ── PATCH /api/hotels/:slug/room-service/orders/:orderId ──────────────────
fastify.patch<SlugOrderParam & {
Body: { status?: string; payment_method?: string }
}>(
'/api/hotels/:slug/room-service/orders/:orderId',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, orderId } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotel = await getHotelRow(slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const allowed = ['status', 'payment_method']
const updates: string[] = []
const values: unknown[] = []
let idx = 1
const body = request.body as Record<string, unknown>
for (const key of allowed) {
if (body[key] !== undefined) {
updates.push(`${key} = $${idx}`)
values.push(body[key])
idx++
}
}
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
updates.push(`updated_at = NOW()`)
values.push(orderId, hotel.id)
const { rows } = await db.query(
`UPDATE room_service_orders SET ${updates.join(', ')}
WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`,
values,
)
if (!rows[0]) return reply.code(404).send({ error: 'Order not found' })
return rows[0]
},
)
}
export default roomServiceRoutes

View File

@@ -102,7 +102,7 @@ const users: FastifyPluginAsync = async (fastify) => {
// Look up hotel name and inviter name for the email // Look up hotel name and inviter name for the email
const { rows: hotelRows } = await db.query('SELECT name FROM hotels WHERE id = $1', [hotelId]) const { rows: hotelRows } = await db.query('SELECT name FROM hotels WHERE id = $1', [hotelId])
const { rows: inviterRows } = await db.query('SELECT name FROM users WHERE id = $1', [request.user.id]) const { rows: inviterRows } = await db.query('SELECT name FROM users WHERE id = $1', [request.user.sub])
const hotelName = hotelRows[0]?.name ?? 'отель' const hotelName = hotelRows[0]?.name ?? 'отель'
const invitedBy = inviterRows[0]?.name ?? 'Администратор' const invitedBy = inviterRows[0]?.name ?? 'Администратор'

View File

@@ -35,6 +35,7 @@ import { LoyaltyPage } from './pages/LoyaltyPage'
import { MaintenancePage } from './pages/MaintenancePage' import { MaintenancePage } from './pages/MaintenancePage'
import { DiscountsPage } from './pages/DiscountsPage' import { DiscountsPage } from './pages/DiscountsPage'
import { GuestReviewPage } from './pages/GuestReviewPage' import { GuestReviewPage } from './pages/GuestReviewPage'
import { GuestReviewQrPage } from './pages/GuestReviewQrPage'
import { GuestRoomServicePage } from './pages/GuestRoomServicePage' import { GuestRoomServicePage } from './pages/GuestRoomServicePage'
import { ResetPasswordPage } from './pages/ResetPasswordPage' import { ResetPasswordPage } from './pages/ResetPasswordPage'
import { AcceptInvitePage } from './pages/AcceptInvitePage' import { AcceptInvitePage } from './pages/AcceptInvitePage'
@@ -88,7 +89,8 @@ export default function App() {
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} /> <Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/invite/:token" element={<AcceptInvitePage />} /> <Route path="/invite/:token" element={<AcceptInvitePage />} />
<Route path="/review/:slug" element={<GuestReviewPage />} /> <Route path="/review/:token" element={<GuestReviewPage />} />
<Route path="/review-qr/:slug/:room" element={<GuestReviewQrPage />} />
<Route path="/room-service/:slug" element={<GuestRoomServicePage />} /> <Route path="/room-service/:slug" element={<GuestRoomServicePage />} />
<Route path="/:slug/pay" element={<PayDepositPage />} /> <Route path="/:slug/pay" element={<PayDepositPage />} />
<Route path="/:slug/book" element={<BookingWidgetStandalonePage />} /> <Route path="/:slug/book" element={<BookingWidgetStandalonePage />} />

View File

@@ -41,6 +41,7 @@ export interface SavedRolePermission {
isSystem: boolean isSystem: boolean
permissions: Record<string, boolean> permissions: Record<string, boolean>
homePage?: string | null homePage?: string | null
notificationSettings?: Record<string, boolean>
} }
interface RolePermissionsContextValue { interface RolePermissionsContextValue {

View File

@@ -313,7 +313,7 @@ export const api = {
'GET', `/api/hotels/${slug}/role-permissions`), 'GET', `/api/hotels/${slug}/role-permissions`),
save: (slug: string, roleKey: string, data: { save: (slug: string, roleKey: string, data: {
name: string; color: string; isSystem: boolean; permissions: Record<string, boolean>; homePage?: string | null name: string; color: string; isSystem: boolean; permissions: Record<string, boolean>; homePage?: string | null; notificationSettings?: Record<string, boolean>
}) => }) =>
req<import('../contexts/RolePermissionsContext').SavedRolePermission>( req<import('../contexts/RolePermissionsContext').SavedRolePermission>(
'PUT', `/api/hotels/${slug}/role-permissions/${roleKey}`, data), 'PUT', `/api/hotels/${slug}/role-permissions/${roleKey}`, data),
@@ -477,6 +477,24 @@ export const api = {
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/hotel-settings`, data), req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/hotel-settings`, data),
}, },
// ── Reviews ───────────────────────────────────────────────────────────────
reviews: {
list: (slug: string) =>
req<ReviewApi[]>('GET', `/api/hotels/${slug}/reviews`),
update: (slug: string, id: string, data: { reply?: string; isPublic?: boolean; rejected?: boolean }) =>
req<ReviewApi>('PATCH', `/api/hotels/${slug}/reviews/${id}`, data),
},
// ── Public review (no auth) ───────────────────────────────────────────────
publicReview: {
getConfig: (token: string) =>
req<PublicReviewConfig>('GET', `/api/public/review/${token}`),
submit: (token: string, data: { rating: number; text?: string }) =>
req<{ ok: boolean }>('POST', `/api/public/review/${token}`, data),
},
// ── Guests ──────────────────────────────────────────────────────────────── // ── Guests ────────────────────────────────────────────────────────────────
guests: { guests: {
list: (slug: string, q?: string, passport?: string) => { list: (slug: string, q?: string, passport?: string) => {
@@ -964,6 +982,118 @@ export const api = {
return json as { bookingId: string; status: string } return json as { bookingId: string; status: string }
}), }),
}, },
// ── Room Service ──────────────────────────────────────────────────────────
roomService: {
getPublicConfig: (slug: string) =>
req<RoomServicePublicConfig>('GET', `/api/hotels/${slug}/room-service/public-config`),
getMenu: (slug: string) =>
req<RoomServiceMenuItem[]>('GET', `/api/hotels/${slug}/room-service/menu`),
getMenuAll: (slug: string) =>
req<RoomServiceMenuItem[]>('GET', `/api/hotels/${slug}/room-service/menu/all`),
createMenuItem: (slug: string, data: {
name: string; category?: string; price: number; emoji?: string
prepTimeMin?: number; isPopular?: boolean; sortOrder?: number
}) =>
req<RoomServiceMenuItem>('POST', `/api/hotels/${slug}/room-service/menu`, data),
updateMenuItem: (slug: string, itemId: string, data: Partial<{
name: string; category: string; price: number; emoji: string
prepTimeMin: number; isPopular: boolean; isStopList: boolean
isActive: boolean; sortOrder: number
}>) =>
req<RoomServiceMenuItem>('PATCH', `/api/hotels/${slug}/room-service/menu/${itemId}`, data),
deleteMenuItem: (slug: string, itemId: string) =>
req<void>('DELETE', `/api/hotels/${slug}/room-service/menu/${itemId}`),
getSettings: (slug: string) =>
req<RoomServiceSettings>('GET', `/api/hotels/${slug}/room-service/settings`),
updateSettings: (slug: string, data: { isActive?: boolean; serviceChargePct?: number }) =>
req<RoomServiceSettings>('PATCH', `/api/hotels/${slug}/room-service/settings`, data),
listOrders: (slug: string, params?: { status?: string; date?: string }) => {
const q = new URLSearchParams()
if (params?.status) q.set('status', params.status)
if (params?.date) q.set('date', params.date)
const qs = q.toString() ? `?${q.toString()}` : ''
return req<RoomServiceOrder[]>('GET', `/api/hotels/${slug}/room-service/orders${qs}`)
},
createOrder: (slug: string, data: {
roomNumber: string; guestName?: string; orderType?: 'room' | 'restaurant'
scheduledTime?: string; paymentMethod?: string; notes?: string
items: Array<{ menuItemId: string; quantity: number }>
}) =>
req<RoomServiceOrder>('POST', `/api/hotels/${slug}/room-service/orders`, data),
updateOrder: (slug: string, orderId: string, data: { status?: string; paymentMethod?: string }) =>
req<RoomServiceOrder>('PATCH', `/api/hotels/${slug}/room-service/orders/${orderId}`, data),
},
}
// ── Room Service types ────────────────────────────────────────────────────────
export interface RoomServicePublicConfig {
hotelName: string
hotelSlug: string
serviceChargePct: number
paymentMethods: Array<{ id: string; name: string; type: string }>
}
export interface RoomServiceMenuItem {
id: string
hotelId: string
name: string
category: string
price: number
emoji: string
prepTimeMin: number
isPopular: boolean
isStopList: boolean
isActive: boolean
sortOrder: number
createdAt: string
}
export interface RoomServiceSettings {
hotelId: string
isActive: boolean
serviceChargePct: number
updatedAt: string
}
export type RoomServiceOrderStatus = 'new' | 'preparing' | 'ready' | 'delivered' | 'cancelled'
export interface RoomServiceOrderItem {
id: string
menuItemId: string | null
name: string
emoji: string
price: number
quantity: number
}
export interface RoomServiceOrder {
id: string
hotelId: string
roomNumber: string
guestName: string
orderType: 'room' | 'restaurant'
scheduledTime: string | null
status: RoomServiceOrderStatus
paymentMethod: string | null
subtotal: number
serviceCharge: number
total: number
notes: string | null
items: RoomServiceOrderItem[]
createdAt: string
updatedAt: string
} }
// ── Schedule ───────────────────────────────────────────────────────────────── // ── Schedule ─────────────────────────────────────────────────────────────────
@@ -1843,3 +1973,34 @@ function toHotelPayload(h: HotelPayload): Record<string, unknown> {
if (h.checkOutTime !== undefined) out.check_out_time = h.checkOutTime if (h.checkOutTime !== undefined) out.check_out_time = h.checkOutTime
return out return out
} }
export interface ReviewApi {
id: string
bookingId: string | null
guestId: string | null
guestName: string
source: string
rating: number
text: string | null
reply: string | null
repliedAt: string | null
isPublic: boolean
rejectedAt: string | null
roomNumber: string | null
bookingSource: string | null
createdAt: string
updatedAt: string
}
export interface PublicReviewConfig {
hotelName: string
logoLetter: string
welcomeText: string
color: string
redirectThreshold: number
showTextField: boolean
platforms: { name: string; url: string }[]
guestName: string
bookingSource: string
}

View File

@@ -1,100 +1,117 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { Star, ExternalLink, CheckCircle2, Heart } from 'lucide-react' import { Star, ExternalLink, CheckCircle2, Heart, Loader2 } from 'lucide-react'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import { api } from '../lib/api'
import type { PublicReviewConfig } from '../lib/api'
// ── Mock hotel config (in real app — fetched by slug from API) ──────────────── const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru'
interface HotelConfig {
name: string
color: string
logoLetter: string
redirectThreshold: number // min rating (15) to show redirect buttons
platforms: { name: string; url: string }[]
welcomeText: string
showTextField: boolean
}
const MOCK_HOTELS: Record<string, HotelConfig> = {
'grand-palace': {
name: 'Гранд Палас',
color: '#2563eb',
logoLetter: 'Г',
redirectThreshold: 4,
welcomeText: 'Как вам у нас?',
showTextField: true,
platforms: [
{ name: 'Booking.com', url: 'https://www.booking.com/hotel/ru/' },
{ name: 'Яндекс Путешествия', url: 'https://travel.yandex.ru/hotels/' },
{ name: 'Google', url: 'https://g.page/r/' },
],
},
}
const DEFAULT_CONFIG: HotelConfig = {
name: 'Отель',
color: '#2563eb',
logoLetter: 'О',
redirectThreshold: 4,
welcomeText: 'Как вам у нас?',
showTextField: true,
platforms: [
{ name: 'Booking.com', url: 'https://www.booking.com' },
{ name: 'Google', url: 'https://google.com' },
],
}
// ── Component ─────────────────────────────────────────────────────────────────
export function GuestReviewPage() { export function GuestReviewPage() {
const { slug } = useParams<{ slug: string }>() const { token } = useParams<{ token: string }>()
const hotel: HotelConfig = (slug ? MOCK_HOTELS[slug] : null) ?? DEFAULT_CONFIG
const [config, setConfig] = useState<PublicReviewConfig | null>(null)
const [status, setStatus] = useState<'loading' | 'ready' | 'already' | 'invalid'>('loading')
const [rating, setRating] = useState(0) const [rating, setRating] = useState(0)
const [hover, setHover] = useState(0) const [hover, setHover] = useState(0)
const [text, setText] = useState('') const [text, setText] = useState('')
const [submitted, setSubmitted] = useState(false) const [submitted, setSubmitted] = useState(false)
const [submitting, setSubmitting] = useState(false)
const isPositive = rating >= hotel.redirectThreshold useEffect(() => {
if (!token) { setStatus('invalid'); return }
fetch(`${BASE}/api/public/review/${token}`)
.then(async res => {
if (res.status === 410) { setStatus('already'); return }
if (!res.ok) { setStatus('invalid'); return }
const data = await res.json() as PublicReviewConfig
setConfig(data)
setStatus('ready')
})
.catch(() => setStatus('invalid'))
}, [token])
const handleSubmit = () => { const handleSubmit = async () => {
if (rating === 0) return if (rating === 0 || !token) return
setSubmitted(true) setSubmitting(true)
// In real app: POST /api/reviews { slug, rating, text } try {
await api.publicReview.submit(token, { rating, text: text.trim() || undefined })
setSubmitted(true)
} catch {
// ignore — show submitted anyway to avoid confusion
setSubmitted(true)
} finally {
setSubmitting(false)
}
}
const color = config?.color ?? '#2563eb'
const threshold = config?.redirectThreshold ?? 4
const isPositive = rating >= threshold
// ── Loading ─────────────────────────────────────────────────────────────────
if (status === 'loading') {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center">
<Loader2 size={32} className="text-slate-400 animate-spin" />
</div>
)
}
// ── Invalid / expired ───────────────────────────────────────────────────────
if (status === 'invalid') {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="bg-white rounded-3xl shadow-2xl p-10 text-center max-w-sm w-full">
<div className="text-5xl mb-4">🔗</div>
<p className="font-bold text-slate-900 text-lg">Ссылка недействительна</p>
<p className="text-slate-500 text-sm mt-2">Ссылка устарела или была использована.</p>
</div>
</div>
)
}
// ── Already submitted ───────────────────────────────────────────────────────
if (status === 'already') {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="bg-white rounded-3xl shadow-2xl p-10 text-center max-w-sm w-full">
<CheckCircle2 size={48} className="text-emerald-500 mx-auto mb-4" />
<p className="font-bold text-slate-900 text-lg">Отзыв уже получен</p>
<p className="text-slate-500 text-sm mt-2">Вы уже оставляли отзыв. Спасибо!</p>
</div>
</div>
)
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
{/* Card */}
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden"> <div className="bg-white rounded-3xl shadow-2xl overflow-hidden">
<div className="h-2" style={{ backgroundColor: color }} />
{/* Top color bar */}
<div className="h-2" style={{ backgroundColor: hotel.color }} />
<div className="px-8 py-8 text-center space-y-6"> <div className="px-8 py-8 text-center space-y-6">
{/* Logo */} {/* Logo */}
<div <div
className="w-20 h-20 rounded-2xl mx-auto flex items-center justify-center text-3xl font-bold text-white shadow-lg" className="w-20 h-20 rounded-2xl mx-auto flex items-center justify-center text-3xl font-bold text-white shadow-lg"
style={{ backgroundColor: hotel.color }} style={{ backgroundColor: color }}
> >
{hotel.logoLetter} {config?.logoLetter ?? 'О'}
</div> </div>
{/* Hotel name */}
<div> <div>
<h1 className="text-xl font-bold text-slate-900">{hotel.name}</h1> <h1 className="text-xl font-bold text-slate-900">{config?.hotelName}</h1>
{!submitted && config?.guestName && (
<p className="text-slate-400 text-sm mt-0.5">Привет, {config.guestName.split(' ')[0]}!</p>
)}
{!submitted && ( {!submitted && (
<p className="text-slate-500 mt-1 text-base">{hotel.welcomeText}</p> <p className="text-slate-500 mt-1 text-base">{config?.welcomeText ?? 'Как вам у нас?'}</p>
)} )}
</div> </div>
{/* ── NOT YET SUBMITTED ── */} {/* ── NOT YET SUBMITTED ── */}
{!submitted && ( {!submitted && (
<> <>
{/* Stars */}
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
{Array.from({ length: 5 }, (_, i) => ( {Array.from({ length: 5 }, (_, i) => (
<button <button
@@ -117,15 +134,13 @@ export function GuestReviewPage() {
))} ))}
</div> </div>
{/* Star label */}
{(hover || rating) > 0 && ( {(hover || rating) > 0 && (
<p className="text-sm font-medium text-slate-600 -mt-2"> <p className="text-sm font-medium text-slate-600 -mt-2">
{['', 'Очень плохо', 'Плохо', 'Нормально', 'Хорошо', 'Отлично!'][hover || rating]} {['', 'Очень плохо', 'Плохо', 'Нормально', 'Хорошо', 'Отлично!'][hover || rating]}
</p> </p>
)} )}
{/* Text field */} {rating > 0 && config?.showTextField && (
{rating > 0 && hotel.showTextField && (
<textarea <textarea
className="w-full border border-slate-200 rounded-2xl p-3.5 text-sm text-slate-700 bg-slate-50 resize-none focus:outline-none focus:border-blue-400 transition-colors placeholder:text-slate-400" className="w-full border border-slate-200 rounded-2xl p-3.5 text-sm text-slate-700 bg-slate-50 resize-none focus:outline-none focus:border-blue-400 transition-colors placeholder:text-slate-400"
rows={3} rows={3}
@@ -135,13 +150,13 @@ export function GuestReviewPage() {
/> />
)} )}
{/* Submit */}
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={rating === 0} disabled={rating === 0 || submitting}
className="w-full py-3.5 rounded-2xl text-white font-semibold text-base transition-all hover:opacity-90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed shadow-lg" className="w-full py-3.5 rounded-2xl text-white font-semibold text-base transition-all hover:opacity-90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed shadow-lg flex items-center justify-center gap-2"
style={{ backgroundColor: hotel.color }} style={{ backgroundColor: color }}
> >
{submitting && <Loader2 size={16} className="animate-spin" />}
Отправить отзыв Отправить отзыв
</button> </button>
</> </>
@@ -174,9 +189,8 @@ export function GuestReviewPage() {
Если вам не сложно поделитесь впечатлениями на одной из площадок: Если вам не сложно поделитесь впечатлениями на одной из площадок:
</p> </p>
</div> </div>
<div className="space-y-2.5"> <div className="space-y-2.5">
{hotel.platforms.map(p => ( {(config?.platforms ?? []).map(p => (
<a <a
key={p.name} key={p.name}
href={p.url} href={p.url}
@@ -193,7 +207,6 @@ export function GuestReviewPage() {
)} )}
</div> </div>
{/* Footer */}
<div className="px-8 pb-6 text-center"> <div className="px-8 pb-6 text-center">
<p className="text-xs text-slate-300">HotelSync · система сбора отзывов</p> <p className="text-xs text-slate-300">HotelSync · система сбора отзывов</p>
</div> </div>

View File

@@ -0,0 +1,209 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { Star, ExternalLink, CheckCircle2, Heart, Loader2 } from 'lucide-react'
import { cn } from '../lib/utils'
const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru'
interface QrConfig {
hotelName: string
logoLetter: string
welcomeText: string
color: string
redirectThreshold: number
showTextField: boolean
platforms: { name: string; url: string }[]
roomNumber: string
}
export function GuestReviewQrPage() {
const { slug, room } = useParams<{ slug: string; room: string }>()
const [config, setConfig] = useState<QrConfig | null>(null)
const [status, setStatus] = useState<'loading' | 'ready' | 'invalid'>('loading')
const [rating, setRating] = useState(0)
const [hover, setHover] = useState(0)
const [text, setText] = useState('')
const [submitted, setSubmitted] = useState(false)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!slug || !room) { setStatus('invalid'); return }
fetch(`${BASE}/api/public/review-qr/${slug}/${encodeURIComponent(room)}`)
.then(async res => {
if (!res.ok) { setStatus('invalid'); return }
setConfig(await res.json() as QrConfig)
setStatus('ready')
})
.catch(() => setStatus('invalid'))
}, [slug, room])
const handleSubmit = async () => {
if (rating === 0 || !slug || !room) return
setSubmitting(true)
try {
await fetch(`${BASE}/api/public/review-qr/${slug}/${encodeURIComponent(room)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating, text: text.trim() || undefined }),
})
setSubmitted(true)
} catch {
setSubmitted(true)
} finally {
setSubmitting(false)
}
}
const color = config?.color ?? '#2563eb'
const threshold = config?.redirectThreshold ?? 4
const isPositive = rating >= threshold
if (status === 'loading') {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center">
<Loader2 size={32} className="text-slate-400 animate-spin" />
</div>
)
}
if (status === 'invalid') {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="bg-white rounded-3xl shadow-2xl p-10 text-center max-w-sm w-full">
<div className="text-5xl mb-4">🔗</div>
<p className="font-bold text-slate-900 text-lg">Страница не найдена</p>
<p className="text-slate-500 text-sm mt-2">Проверьте QR-код.</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden">
<div className="h-2" style={{ backgroundColor: color }} />
<div className="px-8 py-8 text-center space-y-6">
{/* Logo */}
<div
className="w-20 h-20 rounded-2xl mx-auto flex items-center justify-center text-3xl font-bold text-white shadow-lg"
style={{ backgroundColor: color }}
>
{config?.logoLetter ?? 'О'}
</div>
<div>
<h1 className="text-xl font-bold text-slate-900">{config?.hotelName}</h1>
{!submitted && (
<p className="text-slate-500 mt-1 text-base">{config?.welcomeText ?? 'Как вам у нас?'}</p>
)}
</div>
{/* ── NOT SUBMITTED ── */}
{!submitted && (
<>
<div className="flex justify-center gap-2">
{Array.from({ length: 5 }, (_, i) => (
<button
key={i}
onMouseEnter={() => setHover(i + 1)}
onMouseLeave={() => setHover(0)}
onClick={() => setRating(i + 1)}
className="transition-transform hover:scale-110 active:scale-95"
>
<Star
size={40}
className={cn(
'transition-colors drop-shadow-sm',
(hover || rating) > i
? 'text-amber-400 fill-amber-400'
: 'text-slate-200 fill-slate-200',
)}
/>
</button>
))}
</div>
{(hover || rating) > 0 && (
<p className="text-sm font-medium text-slate-600 -mt-2">
{['', 'Очень плохо', 'Плохо', 'Нормально', 'Хорошо', 'Отлично!'][hover || rating]}
</p>
)}
{rating > 0 && config?.showTextField && (
<textarea
className="w-full border border-slate-200 rounded-2xl p-3.5 text-sm text-slate-700 bg-slate-50 resize-none focus:outline-none focus:border-blue-400 transition-colors placeholder:text-slate-400"
rows={3}
placeholder="Расскажите подробнее... (необязательно)"
value={text}
onChange={e => setText(e.target.value)}
/>
)}
<button
onClick={handleSubmit}
disabled={rating === 0 || submitting}
className="w-full py-3.5 rounded-2xl text-white font-semibold text-base transition-all hover:opacity-90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed shadow-lg flex items-center justify-center gap-2"
style={{ backgroundColor: color }}
>
{submitting && <Loader2 size={16} className="animate-spin" />}
Отправить отзыв
</button>
</>
)}
{/* ── SUBMITTED: NEGATIVE ── */}
{submitted && !isPositive && (
<div className="space-y-4 py-2">
<div className="w-16 h-16 rounded-full bg-slate-100 flex items-center justify-center mx-auto">
<Heart size={28} className="text-slate-400" />
</div>
<div>
<p className="font-bold text-slate-900 text-lg">Спасибо за честность</p>
<p className="text-slate-500 text-sm mt-1.5 leading-relaxed">
Мы сожалеем, что что-то пошло не так. Ваш отзыв поможет нам стать лучше.
</p>
</div>
</div>
)}
{/* ── SUBMITTED: POSITIVE ── */}
{submitted && isPositive && (
<div className="space-y-4 py-2">
<div className="w-16 h-16 rounded-full bg-emerald-50 flex items-center justify-center mx-auto">
<CheckCircle2 size={32} className="text-emerald-500" />
</div>
<div>
<p className="font-bold text-slate-900 text-lg">Спасибо за отзыв!</p>
<p className="text-slate-500 text-sm mt-1.5 leading-relaxed">
Если вам не сложно поделитесь впечатлениями на одной из площадок:
</p>
</div>
<div className="space-y-2.5">
{(config?.platforms ?? []).map(p => (
<a
key={p.name}
href={p.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between w-full px-4 py-3 rounded-2xl border-2 border-slate-100 hover:border-blue-200 bg-slate-50 hover:bg-blue-50 text-slate-700 font-medium text-sm transition-all group"
>
<span>{p.name}</span>
<ExternalLink size={14} className="text-slate-400 group-hover:text-blue-500 transition-colors" />
</a>
))}
</div>
</div>
)}
</div>
<div className="px-8 pb-6 text-center">
<p className="text-xs text-slate-300">HotelSync · система сбора отзывов</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,58 +1,18 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { import {
ShoppingCart, Plus, Minus, X, Clock, CheckCircle2, ShoppingCart, Plus, Minus, X, Clock, CheckCircle2,
ChefHat, Truck, CreditCard, Receipt, ArrowLeft, ChefHat, Truck, Receipt, ArrowLeft, AlertCircle, Loader2,
} from 'lucide-react' } from 'lucide-react'
import { cn, formatCurrency } from '../lib/utils' import { cn, formatCurrency } from '../lib/utils'
import { api } from '../lib/api'
import type { RoomServiceMenuItem, RoomServicePublicConfig } from '../lib/api'
// ── Types ───────────────────────────────────────────────────────────────────── const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru'
interface MenuItem {
id: string
name: string
category: string
price: number
emoji: string
time: number
popular?: boolean
stopList?: boolean
}
type Step = 'menu' | 'cart' | 'payment' | 'status' type Step = 'menu' | 'cart' | 'payment' | 'status'
type OrderStatus = 'new' | 'preparing' | 'ready' | 'delivered' type OrderStatus = 'new' | 'preparing' | 'ready' | 'delivered'
// ── Mock data (same menu as staff side) ───────────────────────────────────────
const MENU: MenuItem[] = [
{ id: 'm1', name: 'Яичница с беконом', category: 'Завтраки', price: 450, emoji: '🍳', time: 15, popular: true },
{ id: 'm2', name: 'Омлет с овощами', category: 'Завтраки', price: 380, emoji: '🥚', time: 12 },
{ id: 'm3', name: 'Сырники', category: 'Завтраки', price: 320, emoji: '🧀', time: 10 },
{ id: 'm4', name: 'Овсяная каша', category: 'Завтраки', price: 250, emoji: '🥣', time: 8 },
{ id: 'm5', name: 'Стейк рибай', category: 'Основные', price: 1800, emoji: '🥩', time: 25, popular: true },
{ id: 'm6', name: 'Куриная грудка', category: 'Основные', price: 750, emoji: '🍗', time: 20 },
{ id: 'm7', name: 'Паста карбонара', category: 'Основные', price: 650, emoji: '🍝', time: 18 },
{ id: 'm8', name: 'Лосось на гриле', category: 'Основные', price: 1200, emoji: '🐟', time: 22 },
{ id: 'm9', name: 'Цезарь с курицей', category: 'Закуски', price: 550, emoji: '🥗', time: 10, popular: true },
{ id: 'm10', name: 'Сэндвич клуб', category: 'Закуски', price: 420, emoji: '🥪', time: 8 },
{ id: 'm11', name: 'Сырная тарелка', category: 'Закуски', price: 680, emoji: '🧀', time: 5 },
{ id: 'm12', name: 'Кофе американо', category: 'Напитки', price: 180, emoji: '☕', time: 5, popular: true },
{ id: 'm13', name: 'Капучино', category: 'Напитки', price: 220, emoji: '☕', time: 6 },
{ id: 'm14', name: 'Свежевыжатый сок', category: 'Напитки', price: 280, emoji: '🍊', time: 5 },
{ id: 'm15', name: 'Вино красное бокал', category: 'Напитки', price: 450, emoji: '🍷', time: 3 },
{ id: 'm16', name: 'Тирамису', category: 'Десерты', price: 380, emoji: '🍮', time: 3 },
{ id: 'm17', name: 'Чизкейк', category: 'Десерты', price: 350, emoji: '🍰', time: 3 },
]
const CATEGORIES = [...new Set(MENU.map(i => i.category))]
const HOTEL_CONFIG: Record<string, { name: string; color: string; serviceChargePct: number }> = {
'grand-palace': { name: 'Гранд Палас', color: '#2563eb', serviceChargePct: 15 },
}
const DEFAULT_CONFIG = { name: 'Отель', color: '#2563eb', serviceChargePct: 15 }
// ── Status display ─────────────────────────────────────────────────────────────
const STATUS_STEPS: { key: OrderStatus; label: string; icon: React.ElementType; desc: string }[] = [ const STATUS_STEPS: { key: OrderStatus; label: string; icon: React.ElementType; desc: string }[] = [
{ key: 'new', label: 'Принят', icon: Receipt, desc: 'Заказ отправлен на кухню' }, { key: 'new', label: 'Принят', icon: Receipt, desc: 'Заказ отправлен на кухню' },
{ key: 'preparing', label: 'Готовится', icon: ChefHat, desc: 'Повар готовит ваш заказ' }, { key: 'preparing', label: 'Готовится', icon: ChefHat, desc: 'Повар готовит ваш заказ' },
@@ -60,46 +20,124 @@ const STATUS_STEPS: { key: OrderStatus; label: string; icon: React.ElementType;
{ key: 'delivered', label: 'Доставлен', icon: Truck, desc: 'Курьер в пути к вашему номеру' }, { key: 'delivered', label: 'Доставлен', icon: Truck, desc: 'Курьер в пути к вашему номеру' },
] ]
// ── Component ─────────────────────────────────────────────────────────────────
export function GuestRoomServicePage() { export function GuestRoomServicePage() {
const { slug } = useParams<{ slug: string }>() const { slug } = useParams<{ slug: string }>()
const config = (slug ? HOTEL_CONFIG[slug] : null) ?? DEFAULT_CONFIG
const [category, setCategory] = useState(CATEGORIES[0]) const [config, setConfig] = useState<RoomServicePublicConfig | null>(null)
const [cart, setCart] = useState<Record<string, number>>({}) const [menu, setMenu] = useState<RoomServiceMenuItem[]>([])
const [step, setStep] = useState<Step>('menu') const [loadErr, setLoadErr] = useState('')
const [category, setCategory] = useState('')
const [cart, setCart] = useState<Record<string, number>>({})
const [step, setStep] = useState<Step>('menu')
const [roomNumber, setRoomNumber] = useState('') const [roomNumber, setRoomNumber] = useState('')
const [notes, setNotes] = useState('') const [guestName, setGuestName] = useState('')
const [payMode, setPayMode] = useState<'online' | 'bill'>('online') const [notes, setNotes] = useState('')
const [payMethod, setPayMethod] = useState('')
const [submitting, setSubmitting] = useState(false)
const [submitErr, setSubmitErr] = useState('')
const [orderId, setOrderId] = useState('')
const [orderStatus, setOrderStatus] = useState<OrderStatus>('new') const [orderStatus, setOrderStatus] = useState<OrderStatus>('new')
const cartItems = MENU.filter(i => (cart[i.id] ?? 0) > 0) // ── Load config + menu ────────────────────────────────────────────────────
const subtotal = cartItems.reduce((s, i) => s + i.price * cart[i.id], 0)
const svcCharge = Math.round(subtotal * config.serviceChargePct / 100)
const total = subtotal + svcCharge
const cartCount = Object.values(cart).reduce((s, n) => s + n, 0)
const add = (id: string) => setCart(p => ({ ...p, [id]: (p[id] ?? 0) + 1 })) useEffect(() => {
const sub = (id: string) => setCart(p => { const n = Math.max(0, (p[id] ?? 0) - 1); return { ...p, [id]: n } }) if (!slug) return
const canOrder = cartCount > 0 && roomNumber.trim().length > 0 Promise.all([
api.roomService.getPublicConfig(slug),
api.roomService.getMenu(slug),
]).then(([cfg, items]) => {
setConfig(cfg)
setMenu(items)
if (items.length > 0) setCategory(items[0].category)
if (cfg.paymentMethods.length > 0) setPayMethod(cfg.paymentMethods[0].name)
}).catch(() => setLoadErr('Не удалось загрузить меню. Попробуйте позже.'))
}, [slug])
const placeOrder = () => { // ── Poll order status ─────────────────────────────────────────────────────
// Simulate status progression
setStep('status') useEffect(() => {
setOrderStatus('new') if (!orderId || !slug || orderStatus === 'delivered') return
setTimeout(() => setOrderStatus('preparing'), 3000) const poll = async () => {
setTimeout(() => setOrderStatus('ready'), 8000) try {
setTimeout(() => setOrderStatus('delivered'), 13000) const res = await fetch(`${BASE}/api/hotels/${slug}/room-service/orders/${orderId}/status`)
if (res.ok) {
const data = await res.json() as { status: string }
if (['new','preparing','ready','delivered'].includes(data.status)) {
setOrderStatus(data.status as OrderStatus)
}
}
} catch { /* ignore */ }
}
const id = setInterval(poll, 15000)
return () => clearInterval(id)
}, [orderId, slug, orderStatus])
// ── Cart logic ────────────────────────────────────────────────────────────
const add = (id: string) => setCart(p => ({ ...p, [id]: (p[id] ?? 0) + 1 }))
const sub = (id: string) => setCart(p => ({ ...p, [id]: Math.max(0, (p[id] ?? 0) - 1) }))
const cartItems = menu.filter(i => (cart[i.id] ?? 0) > 0)
const subtotal = cartItems.reduce((s, i) => s + Number(i.price) * cart[i.id], 0)
const svcCharge = Math.round(subtotal * (config?.serviceChargePct ?? 0) / 100)
const total = subtotal + svcCharge
const cartCount = Object.values(cart).reduce((s, n) => s + n, 0)
const canOrder = cartCount > 0 && roomNumber.trim().length > 0
// ── Submit order ──────────────────────────────────────────────────────────
const placeOrder = async () => {
if (!slug || !canOrder) return
setSubmitting(true)
setSubmitErr('')
try {
const order = await api.roomService.createOrder(slug, {
roomNumber: roomNumber.trim(),
guestName: guestName.trim() || undefined,
orderType: 'room',
paymentMethod: payMethod || undefined,
notes: notes.trim() || undefined,
items: cartItems.map(i => ({ menuItemId: i.id, quantity: cart[i.id] })),
})
setOrderId(order.id)
setOrderStatus('new')
setStep('status')
} catch {
setSubmitErr('Не удалось отправить заказ. Попробуйте ещё раз.')
} finally {
setSubmitting(false)
}
} }
const color = config ? '#2563eb' : '#2563eb' // hotel color if available in future
const currentStatusIdx = STATUS_STEPS.findIndex(s => s.key === orderStatus) const currentStatusIdx = STATUS_STEPS.findIndex(s => s.key === orderStatus)
const categories = [...new Set(menu.map(i => i.category))]
// ── Loading ───────────────────────────────────────────────────────────────
if (loadErr) return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 p-4">
<div className="text-center">
<AlertCircle size={40} className="mx-auto mb-3 text-red-400" />
<p className="font-semibold text-slate-800">{loadErr}</p>
</div>
</div>
)
if (!config) return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<Loader2 size={32} className="animate-spin text-slate-400" />
</div>
)
return ( return (
<div className="min-h-screen bg-slate-50" style={{ fontFamily: 'system-ui, sans-serif' }}> <div className="min-h-screen bg-slate-50" style={{ fontFamily: 'system-ui, sans-serif' }}>
{/* Header */} {/* Header */}
<div className="sticky top-0 z-20 shadow-sm" style={{ backgroundColor: config.color }}> <div className="sticky top-0 z-20 shadow-sm" style={{ backgroundColor: color }}>
<div className="max-w-lg mx-auto px-4 py-3 flex items-center gap-3"> <div className="max-w-lg mx-auto px-4 py-3 flex items-center gap-3">
{step !== 'menu' && step !== 'status' && ( {step !== 'menu' && step !== 'status' && (
<button onClick={() => setStep(step === 'payment' ? 'cart' : 'menu')} className="text-white/80 hover:text-white"> <button onClick={() => setStep(step === 'payment' ? 'cart' : 'menu')} className="text-white/80 hover:text-white">
@@ -107,7 +145,7 @@ export function GuestRoomServicePage() {
</button> </button>
)} )}
<div className="flex-1"> <div className="flex-1">
<p className="text-white font-bold text-base leading-tight">{config.name}</p> <p className="text-white font-bold text-base leading-tight">{config.hotelName}</p>
<p className="text-white/70 text-xs">Room Service</p> <p className="text-white/70 text-xs">Room Service</p>
</div> </div>
{step === 'menu' && cartCount > 0 && ( {step === 'menu' && cartCount > 0 && (
@@ -128,9 +166,8 @@ export function GuestRoomServicePage() {
{/* ── MENU ── */} {/* ── MENU ── */}
{step === 'menu' && ( {step === 'menu' && (
<div className="space-y-4"> <div className="space-y-4">
{/* Category tabs */}
<div className="flex gap-2 overflow-x-auto pb-1 -mx-4 px-4"> <div className="flex gap-2 overflow-x-auto pb-1 -mx-4 px-4">
{CATEGORIES.map(cat => ( {categories.map(cat => (
<button <button
key={cat} key={cat}
onClick={() => setCategory(cat)} onClick={() => setCategory(cat)}
@@ -140,36 +177,34 @@ export function GuestRoomServicePage() {
? 'text-white border-transparent' ? 'text-white border-transparent'
: 'bg-white text-slate-700 border-slate-200', : 'bg-white text-slate-700 border-slate-200',
)} )}
style={category === cat ? { backgroundColor: config.color, borderColor: config.color } : {}} style={category === cat ? { backgroundColor: color, borderColor: color } : {}}
> >
{cat} {cat}
</button> </button>
))} ))}
</div> </div>
{/* Items */}
<div className="space-y-2"> <div className="space-y-2">
{MENU.filter(i => i.category === category && !i.stopList).map(item => ( {menu.filter(i => i.category === category && !i.isStopList).map(item => (
<div key={item.id} className="bg-white rounded-2xl p-4 shadow-sm flex items-center gap-4"> <div key={item.id} className="bg-white rounded-2xl p-4 shadow-sm flex items-center gap-4">
<span className="text-3xl shrink-0">{item.emoji}</span> <span className="text-3xl shrink-0">{item.emoji}</span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<p className="font-semibold text-slate-900 text-sm">{item.name}</p> <p className="font-semibold text-slate-900 text-sm">{item.name}</p>
{item.popular && ( {item.isPopular && (
<span className="text-[10px] bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded-full font-medium">Хит</span> <span className="text-[10px] bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded-full font-medium">Хит</span>
)} )}
</div> </div>
<p className="text-xs text-slate-400 flex items-center gap-1 mt-0.5"> <p className="text-xs text-slate-400 flex items-center gap-1 mt-0.5">
<Clock size={10} />{item.time} мин <Clock size={10} />{item.prepTimeMin} мин
</p> </p>
<p className="font-bold text-slate-900 mt-1">{formatCurrency(item.price)}</p> <p className="font-bold text-slate-900 mt-1">{formatCurrency(Number(item.price))}</p>
</div> </div>
{/* Counter */}
{(cart[item.id] ?? 0) === 0 ? ( {(cart[item.id] ?? 0) === 0 ? (
<button <button
onClick={() => add(item.id)} onClick={() => add(item.id)}
className="w-9 h-9 rounded-full flex items-center justify-center text-white shadow-md transition-transform active:scale-90" className="w-9 h-9 rounded-full flex items-center justify-center text-white shadow-md transition-transform active:scale-90"
style={{ backgroundColor: config.color }} style={{ backgroundColor: color }}
> >
<Plus size={18} /> <Plus size={18} />
</button> </button>
@@ -179,11 +214,7 @@ export function GuestRoomServicePage() {
<Minus size={14} /> <Minus size={14} />
</button> </button>
<span className="text-sm font-bold w-4 text-center">{cart[item.id]}</span> <span className="text-sm font-bold w-4 text-center">{cart[item.id]}</span>
<button <button onClick={() => add(item.id)} className="w-8 h-8 rounded-full flex items-center justify-center text-white" style={{ backgroundColor: color }}>
onClick={() => add(item.id)}
className="w-8 h-8 rounded-full flex items-center justify-center text-white"
style={{ backgroundColor: config.color }}
>
<Plus size={14} /> <Plus size={14} />
</button> </button>
</div> </div>
@@ -192,13 +223,12 @@ export function GuestRoomServicePage() {
))} ))}
</div> </div>
{/* Sticky cart button */}
{cartCount > 0 && ( {cartCount > 0 && (
<div className="sticky bottom-4"> <div className="sticky bottom-4">
<button <button
onClick={() => setStep('cart')} onClick={() => setStep('cart')}
className="w-full py-4 rounded-2xl text-white font-bold text-base shadow-xl transition-opacity hover:opacity-90 active:scale-95" className="w-full py-4 rounded-2xl text-white font-bold text-base shadow-xl transition-opacity hover:opacity-90 active:scale-95"
style={{ backgroundColor: config.color }} style={{ backgroundColor: color }}
> >
Перейти к заказу · {formatCurrency(total)} Перейти к заказу · {formatCurrency(total)}
</button> </button>
@@ -218,42 +248,37 @@ export function GuestRoomServicePage() {
<span className="text-2xl">{item.emoji}</span> <span className="text-2xl">{item.emoji}</span>
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-slate-800">{item.name}</p> <p className="text-sm font-medium text-slate-800">{item.name}</p>
<p className="text-xs text-slate-500">{formatCurrency(item.price)} × {cart[item.id]}</p> <p className="text-xs text-slate-500">{formatCurrency(Number(item.price))} × {cart[item.id]}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={() => sub(item.id)} className="w-7 h-7 rounded-full bg-slate-100 flex items-center justify-center text-slate-600"> <button onClick={() => sub(item.id)} className="w-7 h-7 rounded-full bg-slate-100 flex items-center justify-center text-slate-600">
{cart[item.id] === 1 ? <X size={12} /> : <Minus size={12} />} {cart[item.id] === 1 ? <X size={12} /> : <Minus size={12} />}
</button> </button>
<span className="text-sm font-bold w-4 text-center">{cart[item.id]}</span> <span className="text-sm font-bold w-4 text-center">{cart[item.id]}</span>
<button <button onClick={() => add(item.id)} className="w-7 h-7 rounded-full flex items-center justify-center text-white" style={{ backgroundColor: color }}>
onClick={() => add(item.id)}
className="w-7 h-7 rounded-full flex items-center justify-center text-white"
style={{ backgroundColor: config.color }}
>
<Plus size={12} /> <Plus size={12} />
</button> </button>
</div> </div>
<span className="text-sm font-semibold text-slate-800 w-16 text-right">{formatCurrency(item.price * cart[item.id])}</span> <span className="text-sm font-semibold text-slate-800 w-16 text-right">{formatCurrency(Number(item.price) * cart[item.id])}</span>
</div> </div>
))} ))}
<div className="px-4 py-3 bg-slate-50 space-y-1.5"> <div className="px-4 py-3 bg-slate-50 space-y-1.5">
<div className="flex justify-between text-sm text-slate-600"> <div className="flex justify-between text-sm text-slate-600">
<span>Сумма заказа</span> <span>Сумма заказа</span><span>{formatCurrency(subtotal)}</span>
<span>{formatCurrency(subtotal)}</span>
</div>
<div className="flex justify-between text-sm text-slate-600">
<span>Обслуживание {config.serviceChargePct}%</span>
<span>+{formatCurrency(svcCharge)}</span>
</div> </div>
{svcCharge > 0 && (
<div className="flex justify-between text-sm text-slate-600">
<span>Обслуживание {config.serviceChargePct}%</span>
<span>+{formatCurrency(svcCharge)}</span>
</div>
)}
<div className="flex justify-between font-bold text-slate-900 text-base pt-1 border-t border-slate-200"> <div className="flex justify-between font-bold text-slate-900 text-base pt-1 border-t border-slate-200">
<span>Итого</span> <span>Итого</span><span>{formatCurrency(total)}</span>
<span>{formatCurrency(total)}</span>
</div> </div>
</div> </div>
</div> </div>
{/* Room number */}
<div className="bg-white rounded-2xl shadow-sm p-4 space-y-3"> <div className="bg-white rounded-2xl shadow-sm p-4 space-y-3">
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Номер комнаты *</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">Номер комнаты *</label>
@@ -265,6 +290,16 @@ export function GuestRoomServicePage() {
onChange={e => setRoomNumber(e.target.value)} onChange={e => setRoomNumber(e.target.value)}
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Ваше имя (необязательно)</label>
<input
type="text"
className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:border-blue-400"
placeholder="Иван"
value={guestName}
onChange={e => setGuestName(e.target.value)}
/>
</div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Пожелания (необязательно)</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">Пожелания (необязательно)</label>
<textarea <textarea
@@ -278,12 +313,12 @@ export function GuestRoomServicePage() {
</div> </div>
<button <button
onClick={() => setStep('payment')} onClick={() => config.paymentMethods.length > 0 ? setStep('payment') : placeOrder()}
disabled={!canOrder} disabled={!canOrder}
className="w-full py-4 rounded-2xl text-white font-bold text-base disabled:opacity-40 transition-opacity hover:opacity-90" className="w-full py-4 rounded-2xl text-white font-bold text-base disabled:opacity-40 transition-opacity hover:opacity-90"
style={{ backgroundColor: config.color }} style={{ backgroundColor: color }}
> >
К оплате {config.paymentMethods.length > 0 ? 'К оплате' : 'Оформить заказ'}
</button> </button>
</div> </div>
)} )}
@@ -293,61 +328,61 @@ export function GuestRoomServicePage() {
<div className="space-y-4"> <div className="space-y-4">
<h2 className="font-bold text-slate-900 text-lg">Способ оплаты</h2> <h2 className="font-bold text-slate-900 text-lg">Способ оплаты</h2>
{[ {config.paymentMethods.map(method => (
{ key: 'online' as const, icon: CreditCard, label: 'Оплатить онлайн сейчас', desc: 'Банковской картой через защищённую форму' },
{ key: 'bill' as const, icon: Receipt, label: 'Включить в счёт при выезде', desc: 'Оплата при расчёте в конце проживания' },
].map(opt => (
<button <button
key={opt.key} key={method.id}
onClick={() => setPayMode(opt.key)} onClick={() => setPayMethod(method.name)}
className={cn( className={cn(
'w-full flex items-center gap-4 p-4 rounded-2xl border-2 text-left bg-white transition-colors', 'w-full flex items-center gap-4 p-4 rounded-2xl border-2 text-left bg-white transition-colors',
payMode === opt.key ? 'border-blue-500' : 'border-slate-200', payMethod === method.name ? 'border-blue-500' : 'border-slate-200',
)} )}
style={payMode === opt.key ? { borderColor: config.color } : {}} style={payMethod === method.name ? { borderColor: color } : {}}
> >
<div <div
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 text-lg"
style={payMode === opt.key ? { backgroundColor: `${config.color}20` } : { backgroundColor: '#f1f5f9' }} style={payMethod === method.name ? { backgroundColor: `${color}20` } : { backgroundColor: '#f1f5f9' }}
> >
<opt.icon size={18} style={payMode === opt.key ? { color: config.color } : { color: '#64748b' }} /> {method.type === 'cash' ? '💵' : method.type === 'card' ? '💳' : method.type === 'transfer' ? '📲' : '🏦'}
</div> </div>
<div> <div className="flex-1">
<p className="font-semibold text-slate-900 text-sm">{opt.label}</p> <p className="font-semibold text-slate-900 text-sm">{method.name}</p>
<p className="text-xs text-slate-500 mt-0.5">{opt.desc}</p>
</div> </div>
<div className={cn( <div
'ml-auto w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0', className={cn('w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0', payMethod === method.name ? 'border-blue-500' : 'border-slate-300')}
payMode === opt.key ? 'border-blue-500' : 'border-slate-300', style={payMethod === method.name ? { borderColor: color } : {}}
)} style={payMode === opt.key ? { borderColor: config.color } : {}}> >
{payMode === opt.key && ( {payMethod === method.name && <div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: color }} />}
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: config.color }} />
)}
</div> </div>
</button> </button>
))} ))}
{/* Summary */}
<div className="bg-white rounded-2xl shadow-sm p-4"> <div className="bg-white rounded-2xl shadow-sm p-4">
<p className="text-sm font-semibold text-slate-700 mb-2">Номер {roomNumber}</p> <p className="text-sm font-semibold text-slate-700 mb-2">Номер {roomNumber}</p>
{cartItems.map(i => ( {cartItems.map(i => (
<div key={i.id} className="flex justify-between text-sm text-slate-600 py-0.5"> <div key={i.id} className="flex justify-between text-sm text-slate-600 py-0.5">
<span>{i.emoji} {i.name} ×{cart[i.id]}</span> <span>{i.emoji} {i.name} ×{cart[i.id]}</span>
<span>{formatCurrency(i.price * cart[i.id])}</span> <span>{formatCurrency(Number(i.price) * cart[i.id])}</span>
</div> </div>
))} ))}
<div className="flex justify-between font-bold text-slate-900 text-base mt-2 pt-2 border-t border-slate-100"> <div className="flex justify-between font-bold text-slate-900 text-base mt-2 pt-2 border-t border-slate-100">
<span>Итого</span> <span>Итого</span><span>{formatCurrency(total)}</span>
<span>{formatCurrency(total)}</span>
</div> </div>
</div> </div>
{submitErr && (
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-50 border border-red-200 text-sm text-red-700">
<AlertCircle size={14} />{submitErr}
</div>
)}
<button <button
onClick={placeOrder} onClick={placeOrder}
className="w-full py-4 rounded-2xl text-white font-bold text-base transition-opacity hover:opacity-90" disabled={!payMethod || submitting}
style={{ backgroundColor: config.color }} className="w-full py-4 rounded-2xl text-white font-bold text-base transition-opacity hover:opacity-90 disabled:opacity-40 flex items-center justify-center gap-2"
style={{ backgroundColor: color }}
> >
{payMode === 'online' ? `Оплатить ${formatCurrency(total)}` : 'Оформить заказ'} {submitting && <Loader2 size={16} className="animate-spin" />}
Оформить заказ {formatCurrency(total)}
</button> </button>
</div> </div>
)} )}
@@ -358,15 +393,15 @@ export function GuestRoomServicePage() {
<div className="text-center"> <div className="text-center">
<div <div
className="w-20 h-20 rounded-full mx-auto flex items-center justify-center text-4xl mb-4" className="w-20 h-20 rounded-full mx-auto flex items-center justify-center text-4xl mb-4"
style={{ backgroundColor: `${config.color}15` }} style={{ backgroundColor: `${color}15` }}
> >
🎉 🎉
</div> </div>
<h2 className="text-xl font-bold text-slate-900">Заказ принят!</h2> <h2 className="text-xl font-bold text-slate-900">Заказ принят!</h2>
<p className="text-slate-500 text-sm mt-1">Номер {roomNumber} · {formatCurrency(total)}</p> <p className="text-slate-500 text-sm mt-1">Номер {roomNumber} · {formatCurrency(total)}</p>
{payMethod && <p className="text-slate-400 text-xs mt-0.5">Оплата: {payMethod}</p>}
</div> </div>
{/* Progress */}
<div className="bg-white rounded-2xl shadow-sm p-5"> <div className="bg-white rounded-2xl shadow-sm p-5">
<p className="text-sm font-semibold text-slate-700 mb-4">Статус заказа</p> <p className="text-sm font-semibold text-slate-700 mb-4">Статус заказа</p>
<div className="space-y-4"> <div className="space-y-4">
@@ -376,25 +411,23 @@ export function GuestRoomServicePage() {
const Icon = s.icon const Icon = s.icon
return ( return (
<div key={s.key} className="flex items-center gap-3"> <div key={s.key} className="flex items-center gap-3">
<div className={cn( <div
'w-9 h-9 rounded-full flex items-center justify-center shrink-0 transition-colors', className={cn('w-9 h-9 rounded-full flex items-center justify-center shrink-0 transition-colors', done ? 'text-white' : 'bg-slate-100 text-slate-400')}
done ? 'text-white' : 'bg-slate-100 text-slate-400', style={done ? { backgroundColor: color } : {}}
)} style={done ? { backgroundColor: config.color } : {}}> >
<Icon size={16} /> <Icon size={16} />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<p className={cn('text-sm font-semibold', done ? 'text-slate-900' : 'text-slate-400')}> <p className={cn('text-sm font-semibold', done ? 'text-slate-900' : 'text-slate-400')}>{s.label}</p>
{s.label}
</p>
{current && <p className="text-xs text-slate-500 mt-0.5">{s.desc}</p>} {current && <p className="text-xs text-slate-500 mt-0.5">{s.desc}</p>}
</div> </div>
{current && ( {current && orderStatus !== 'delivered' && (
<div className="flex gap-0.5"> <div className="flex gap-0.5">
{[0,1,2].map(i => ( {[0,1,2].map(i => (
<div <div
key={i} key={i}
className="w-1.5 h-1.5 rounded-full animate-bounce" className="w-1.5 h-1.5 rounded-full animate-bounce"
style={{ backgroundColor: config.color, animationDelay: `${i * 0.15}s` }} style={{ backgroundColor: color, animationDelay: `${i * 0.15}s` }}
/> />
))} ))}
</div> </div>
@@ -403,12 +436,15 @@ export function GuestRoomServicePage() {
) )
})} })}
</div> </div>
{orderStatus !== 'delivered' && (
<p className="text-xs text-slate-400 mt-4 text-center">Статус обновляется автоматически</p>
)}
</div> </div>
<button <button
onClick={() => { setCart({}); setStep('menu'); setRoomNumber(''); setNotes('') }} onClick={() => { setCart({}); setStep('menu'); setRoomNumber(''); setGuestName(''); setNotes(''); setOrderId('') }}
className="w-full py-3 rounded-2xl border-2 text-sm font-semibold transition-colors" className="w-full py-3 rounded-2xl border-2 text-sm font-semibold transition-colors"
style={{ borderColor: config.color, color: config.color }} style={{ borderColor: color, color: color }}
> >
Сделать ещё заказ Сделать ещё заказ
</button> </button>

File diff suppressed because it is too large Load Diff

View File

@@ -1,106 +1,23 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { import {
UtensilsCrossed, Clock, CheckCircle2, ChefHat, Bell, Plus, UtensilsCrossed, Clock, CheckCircle2, ChefHat, Bell, Plus,
XCircle, Flame, CreditCard, Percent, Home, Store, XCircle, Flame, Percent, Home, Store,
QrCode, Copy, CheckCheck, ExternalLink, QrCode, Copy, CheckCheck, ExternalLink, Save, AlertCircle,
Pencil, Trash2, ToggleLeft, ToggleRight,
} from 'lucide-react' } from 'lucide-react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { cn, formatCurrency } from '../lib/utils' import { cn, formatCurrency } from '../lib/utils'
import { Badge } from '../components/ui/Badge' import { Badge } from '../components/ui/Badge'
import { useNotifications } from '../contexts/NotificationsContext' import { useNotifications } from '../contexts/NotificationsContext'
import { useAuth } from '../contexts/AuthContext'
// ── Types ───────────────────────────────────────────────────────────────────── import { api } from '../lib/api'
import type { RoomServiceOrder, RoomServiceMenuItem, RoomServiceSettings } from '../lib/api'
type OrderStatus = 'new' | 'preparing' | 'ready' | 'delivered' | 'cancelled'
type OrderType = 'room' | 'restaurant'
type PaymentStatus = 'pending' | 'online_paid' | 'on_delivery'
interface MenuItem {
id: string
name: string
category: string
price: number
emoji: string
time: number
popular?: boolean
stopList?: boolean
}
interface OrderItem { item: MenuItem; qty: number }
interface RoomOrder {
id: string
roomNumber: string
guestName: string
items: OrderItem[]
subtotal: number
serviceCharge: number
total: number
status: OrderStatus
orderType: OrderType
scheduledTime?: string // restaurant only
paymentStatus: PaymentStatus
notes?: string
createdAt: Date
updatedAt: Date
}
// ── Mock menu ─────────────────────────────────────────────────────────────────
const MENU_ITEMS: MenuItem[] = [
{ id: 'm1', name: 'Яичница с беконом', category: 'Завтраки', price: 450, emoji: '🍳', time: 15, popular: true },
{ id: 'm2', name: 'Омлет с овощами', category: 'Завтраки', price: 380, emoji: '🥚', time: 12 },
{ id: 'm3', name: 'Сырники', category: 'Завтраки', price: 320, emoji: '🧀', time: 10 },
{ id: 'm4', name: 'Овсяная каша', category: 'Завтраки', price: 250, emoji: '🥣', time: 8 },
{ id: 'm5', name: 'Стейк рибай', category: 'Основные', price: 1800, emoji: '🥩', time: 25, popular: true },
{ id: 'm6', name: 'Куриная грудка', category: 'Основные', price: 750, emoji: '🍗', time: 20 },
{ id: 'm7', name: 'Паста карбонара', category: 'Основные', price: 650, emoji: '🍝', time: 18 },
{ id: 'm8', name: 'Лосось на гриле', category: 'Основные', price: 1200, emoji: '🐟', time: 22 },
{ id: 'm9', name: 'Цезарь с курицей', category: 'Закуски', price: 550, emoji: '🥗', time: 10, popular: true },
{ id: 'm10', name: 'Сэндвич клуб', category: 'Закуски', price: 420, emoji: '🥪', time: 8 },
{ id: 'm11', name: 'Сырная тарелка', category: 'Закуски', price: 680, emoji: '🧀', time: 5 },
{ id: 'm12', name: 'Кофе американо', category: 'Напитки', price: 180, emoji: '☕', time: 5, popular: true },
{ id: 'm13', name: 'Капучино', category: 'Напитки', price: 220, emoji: '☕', time: 6 },
{ id: 'm14', name: 'Свежевыжатый сок', category: 'Напитки', price: 280, emoji: '🍊', time: 5 },
{ id: 'm15', name: 'Вино красное бокал', category: 'Напитки', price: 450, emoji: '🍷', time: 3 },
{ id: 'm16', name: 'Тирамису', category: 'Десерты', price: 380, emoji: '🍮', time: 3 },
{ id: 'm17', name: 'Чизкейк', category: 'Десерты', price: 350, emoji: '🍰', time: 3 },
{ id: 'm18', name: 'Мороженое 3 шара', category: 'Десерты', price: 280, emoji: '🍨', time: 2, stopList: true },
]
const CATEGORIES = [...new Set(MENU_ITEMS.map(i => i.category))]
const d = (ms: number) => new Date(Date.now() - ms)
function makeOrder(
id: string,
roomNumber: string,
guestName: string,
items: OrderItem[],
status: OrderStatus,
orderType: OrderType,
serviceChargePct: number,
extra?: Partial<RoomOrder>,
): RoomOrder {
const subtotal = items.reduce((s, oi) => s + oi.item.price * oi.qty, 0)
const serviceCharge = orderType === 'room' ? Math.round(subtotal * serviceChargePct / 100) : 0
return {
id, roomNumber, guestName, items, subtotal,
serviceCharge,
total: subtotal + serviceCharge,
status,
orderType,
paymentStatus: orderType === 'room' ? 'pending' : 'on_delivery',
createdAt: d(300000),
updatedAt: d(300000),
...extra,
}
}
const DEFAULT_SERVICE_CHARGE_PCT = 15
// ── Status config ───────────────────────────────────────────────────────────── // ── Status config ─────────────────────────────────────────────────────────────
type OrderStatus = RoomServiceOrder['status']
type PageTab = 'orders' | 'menu' | 'settings'
const STATUS_CFG: Record<OrderStatus, { label: string; cls: string; next?: OrderStatus; nextLabel?: string }> = { const STATUS_CFG: Record<OrderStatus, { label: string; cls: string; next?: OrderStatus; nextLabel?: string }> = {
new: { label: 'Новый', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300', next: 'preparing', nextLabel: 'Принять в работу' }, new: { label: 'Новый', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300', next: 'preparing', nextLabel: 'Принять в работу' },
preparing: { label: 'Готовится', cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', next: 'ready', nextLabel: 'Готово' }, preparing: { label: 'Готовится', cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', next: 'ready', nextLabel: 'Готово' },
@@ -109,54 +26,182 @@ const STATUS_CFG: Record<OrderStatus, { label: string; cls: string; next?: Order
cancelled: { label: 'Отменён', cls: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400' }, cancelled: { label: 'Отменён', cls: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400' },
} }
function elapsed(from: Date): string { function elapsed(from: string): string {
const s = Math.floor((Date.now() - from.getTime()) / 1000) const s = Math.floor((Date.now() - new Date(from).getTime()) / 1000)
if (s < 60) return `${s} сек` if (s < 60) return `${s} сек`
if (s < 3600) return `${Math.floor(s / 60)} мин` if (s < 3600) return `${Math.floor(s / 60)} мин`
return `${Math.floor(s / 3600)} ч` return `${Math.floor(s / 3600)} ч`
} }
// ── Add menu item modal ────────────────────────────────────────────────────────
interface AddMenuItemModalProps {
slug: string
item?: RoomServiceMenuItem | null
onClose: () => void
onSaved: (item: RoomServiceMenuItem) => void
}
function AddMenuItemModal({ slug, item, onClose, onSaved }: AddMenuItemModalProps) {
const [name, setName] = useState(item?.name ?? '')
const [category, setCategory] = useState(item?.category ?? 'Основные')
const [price, setPrice] = useState(item ? String(item.price) : '')
const [emoji, setEmoji] = useState(item?.emoji ?? '🍽️')
const [prepTime, setPrepTime] = useState(item ? String(item.prepTimeMin) : '15')
const [isPopular, setIsPopular] = useState(item?.isPopular ?? false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const CATEGORIES = ['Завтраки', 'Основные', 'Закуски', 'Напитки', 'Десерты', 'Прочее']
const save = async () => {
if (!name.trim()) { setError('Введите название'); return }
if (!price || isNaN(Number(price))) { setError('Введите цену'); return }
setSaving(true)
try {
let saved: RoomServiceMenuItem
if (item) {
saved = await api.roomService.updateMenuItem(slug, item.id, {
name: name.trim(), category, price: Number(price), emoji,
prepTimeMin: Number(prepTime), isPopular,
})
} else {
saved = await api.roomService.createMenuItem(slug, {
name: name.trim(), category, price: Number(price), emoji,
prepTimeMin: Number(prepTime), isPopular,
})
}
onSaved(saved)
} catch {
setError('Ошибка сохранения')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-md p-6 space-y-4">
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{item ? 'Редактировать блюдо' : 'Новое блюдо'}
</h3>
<div className="grid grid-cols-4 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Эмодзи</label>
<input
className="input text-center text-xl"
value={emoji}
onChange={e => setEmoji(e.target.value)}
maxLength={2}
/>
</div>
<div className="col-span-3">
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Название *</label>
<input className="input" value={name} onChange={e => setName(e.target.value)} placeholder="Стейк рибай" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Категория</label>
<select className="input" value={category} onChange={e => setCategory(e.target.value)}>
{CATEGORIES.map(c => <option key={c}>{c}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Цена, *</label>
<input className="input" type="number" min={0} value={price} onChange={e => setPrice(e.target.value)} placeholder="0" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Время готовки, мин</label>
<input className="input" type="number" min={1} value={prepTime} onChange={e => setPrepTime(e.target.value)} />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300 cursor-pointer">
<input type="checkbox" checked={isPopular} onChange={e => setIsPopular(e.target.checked)} className="rounded" />
Хит продаж
</label>
</div>
</div>
{error && <p className="text-sm text-red-600 flex items-center gap-1"><AlertCircle size={13} />{error}</p>}
<div className="flex gap-2 pt-2">
<button onClick={onClose} className="btn-ghost flex-1">Отмена</button>
<button onClick={save} disabled={saving} className="btn-primary flex-1">
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
</div>
)
}
// ── Component ───────────────────────────────────────────────────────────────── // ── Component ─────────────────────────────────────────────────────────────────
type PageTab = 'orders' | 'menu' | 'settings'
const GUEST_LINK = 'https://guest.hotelsync.ru/room-service/grand-palace'
export function RoomServicePage() { export function RoomServicePage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const { addNotification } = useNotifications() const { addNotification } = useNotifications()
const [tab, setTab] = useState<PageTab>('orders')
const [serviceChargePct, setServiceChargePct] = useState(DEFAULT_SERVICE_CHARGE_PCT)
const [menuCat, setMenuCat] = useState(CATEGORIES[0])
const [, setTick] = useState(0)
const [payOnline, setPayOnline] = useState(true)
const [payOnBill, setPayOnBill] = useState(true)
const [linkCopied, setLinkCopied] = useState(false)
const prevNewCount = useRef(0)
const [orders, setOrders] = useState<RoomOrder[]>(() => [ const [tab, setTab] = useState<PageTab>('orders')
makeOrder('o1', '301', 'Наталья Александрова', const [orders, setOrders] = useState<RoomServiceOrder[]>([])
[{ item: MENU_ITEMS[4], qty: 1 }, { item: MENU_ITEMS[11], qty: 2 }], const [menu, setMenu] = useState<RoomServiceMenuItem[]>([])
'new', 'room', DEFAULT_SERVICE_CHARGE_PCT, const [settings, setSettings] = useState<RoomServiceSettings | null>(null)
{ notes: 'Без лука', createdAt: d(300000), updatedAt: d(300000) }), const [loading, setLoading] = useState(true)
makeOrder('o2', '101', 'Дмитрий Волков',
[{ item: MENU_ITEMS[0], qty: 1 }, { item: MENU_ITEMS[12], qty: 1 }], // settings edit
'preparing', 'room', DEFAULT_SERVICE_CHARGE_PCT, const [serviceChargePct, setServiceChargePct] = useState(0)
{ createdAt: d(900000), updatedAt: d(600000), paymentStatus: 'online_paid' }), const [settSaving, setSettSaving] = useState(false)
makeOrder('o3', '201', 'Игорь Соколов',
[{ item: MENU_ITEMS[8], qty: 2 }, { item: MENU_ITEMS[13], qty: 2 }], // menu UI
'ready', 'restaurant', DEFAULT_SERVICE_CHARGE_PCT, const [menuCat, setMenuCat] = useState('')
{ scheduledTime: '19:30', createdAt: d(1800000), updatedAt: d(300000), paymentStatus: 'on_delivery' }), const [showAddMenu, setShowAddMenu] = useState(false)
makeOrder('o4', '402', 'Виктор Громов', const [editItem, setEditItem] = useState<RoomServiceMenuItem | null>(null)
[{ item: MENU_ITEMS[6], qty: 2 }, { item: MENU_ITEMS[14], qty: 2 }],
'delivered', 'room', DEFAULT_SERVICE_CHARGE_PCT, // link copy
{ createdAt: d(3600000), updatedAt: d(1800000), paymentStatus: 'online_paid' }), const [linkCopied, setLinkCopied] = useState(false)
])
const prevNewCount = useRef(0)
const guestLink = slug ? `${window.location.origin}/room-service/${slug}` : ''
// ── Load orders & settings ─────────────────────────────────────────────────
const loadOrders = useCallback(async () => {
if (!slug) return
try {
const fresh = await api.roomService.listOrders(slug)
setOrders(fresh)
} catch { /* ignore */ }
}, [slug])
// Live timer
useEffect(() => { useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 10000) if (!slug) return
setLoading(true)
Promise.all([
api.roomService.listOrders(slug),
api.roomService.getMenuAll(slug),
api.roomService.getSettings(slug),
]).then(([ords, menuItems, sett]) => {
setOrders(ords)
setMenu(menuItems)
setSettings(sett)
setServiceChargePct(sett.serviceChargePct)
if (menuItems.length > 0) setMenuCat(menuItems[0].category)
}).finally(() => setLoading(false))
}, [slug])
// Poll orders every 30s
useEffect(() => {
if (!slug) return
const id = setInterval(loadOrders, 30000)
return () => clearInterval(id) return () => clearInterval(id)
}, []) }, [slug, loadOrders])
// Notify on new orders // Notify on new orders
useEffect(() => { useEffect(() => {
@@ -167,7 +212,7 @@ export function RoomServicePage() {
addNotification({ addNotification({
type: 'system', type: 'system',
title: `Новый заказ Room Service — №${newest.roomNumber}`, title: `Новый заказ Room Service — №${newest.roomNumber}`,
body: newest.items.map(i => `${i.item.emoji} ${i.item.name} ×${i.qty}`).join(', '), body: newest.items.map(i => `${i.emoji} ${i.name} ×${i.quantity}`).join(', '),
link: '/room-service', link: '/room-service',
}) })
} }
@@ -175,30 +220,73 @@ export function RoomServicePage() {
prevNewCount.current = newCount prevNewCount.current = newCount
}, [orders, addNotification]) }, [orders, addNotification])
// ── Actions ────────────────────────────────────────────────────────────────
const advance = async (id: string) => {
const order = orders.find(o => o.id === id)
if (!order) return
const next = STATUS_CFG[order.status].next
if (!next) return
try {
const updated = await api.roomService.updateOrder(slug, id, { status: next })
setOrders(prev => prev.map(o => o.id === id ? { ...o, ...updated } : o))
} catch { /* ignore */ }
}
const cancel = async (id: string) => {
try {
const updated = await api.roomService.updateOrder(slug, id, { status: 'cancelled' })
setOrders(prev => prev.map(o => o.id === id ? { ...o, ...updated } : o))
} catch { /* ignore */ }
}
const saveSettings = async () => {
if (!slug) return
setSettSaving(true)
try {
const saved = await api.roomService.updateSettings(slug, { serviceChargePct })
setSettings(saved)
} catch { /* ignore */ }
finally { setSettSaving(false) }
}
const toggleStopList = async (item: RoomServiceMenuItem) => {
try {
const updated = await api.roomService.updateMenuItem(slug, item.id, { isStopList: !item.isStopList })
setMenu(prev => prev.map(m => m.id === item.id ? { ...m, ...updated } : m))
} catch { /* ignore */ }
}
const deleteMenuItem = async (item: RoomServiceMenuItem) => {
if (!confirm(`Удалить "${item.name}"?`)) return
try {
await api.roomService.deleteMenuItem(slug, item.id)
setMenu(prev => prev.filter(m => m.id !== item.id))
} catch { /* ignore */ }
}
const copyLink = () => { const copyLink = () => {
navigator.clipboard.writeText(GUEST_LINK).catch(() => {}) navigator.clipboard.writeText(guestLink).catch(() => {})
setLinkCopied(true) setLinkCopied(true)
setTimeout(() => setLinkCopied(false), 2000) setTimeout(() => setLinkCopied(false), 2000)
} }
// ── Derived ────────────────────────────────────────────────────────────────
const activeOrders = orders.filter(o => o.status !== 'delivered' && o.status !== 'cancelled') const activeOrders = orders.filter(o => o.status !== 'delivered' && o.status !== 'cancelled')
const doneOrders = orders.filter(o => o.status === 'delivered' || o.status === 'cancelled') const doneOrders = orders.filter(o => o.status === 'delivered' || o.status === 'cancelled')
const advance = (id: string) => {
setOrders(prev => prev.map(o => {
if (o.id !== id) return o
const cfg = STATUS_CFG[o.status]
if (!cfg.next) return o
return { ...o, status: cfg.next, updatedAt: new Date() }
}))
}
const cancel = (id: string) =>
setOrders(prev => prev.map(o => o.id === id ? { ...o, status: 'cancelled', updatedAt: new Date() } : o))
const todayRevenue = doneOrders const todayRevenue = doneOrders
.filter(o => o.status === 'delivered') .filter(o => o.status === 'delivered')
.reduce((s, o) => s + o.total, 0) .reduce((s, o) => s + Number(o.total), 0)
const categories = [...new Set(menu.map(i => i.category))]
const activeCat = menuCat || categories[0] || ''
if (loading) return (
<div className="p-6 flex items-center justify-center text-slate-400">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600" />
</div>
)
return ( return (
<div className="p-4 md:p-6 space-y-5"> <div className="p-4 md:p-6 space-y-5">
@@ -259,6 +347,14 @@ export function RoomServicePage() {
{/* ── Orders tab ── */} {/* ── Orders tab ── */}
{tab === 'orders' && ( {tab === 'orders' && (
<div className="space-y-4"> <div className="space-y-4">
{activeOrders.length === 0 && doneOrders.length === 0 && (
<div className="card p-10 text-center text-slate-400">
<UtensilsCrossed size={40} className="mx-auto mb-3 opacity-30" />
<p className="font-medium">Заказов пока нет</p>
<p className="text-sm mt-1">Новые заказы с гостевой страницы появятся здесь</p>
</div>
)}
{activeOrders.length > 0 && ( {activeOrders.length > 0 && (
<div> <div>
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Активные</p> <p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Активные</p>
@@ -273,7 +369,6 @@ export function RoomServicePage() {
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">{order.roomNumber}</span> <span className="text-lg font-bold text-slate-900 dark:text-slate-100">{order.roomNumber}</span>
{isNew && <Flame size={14} className="text-red-500 animate-bounce" />} {isNew && <Flame size={14} className="text-red-500 animate-bounce" />}
{/* Order type badge */}
<span className={cn( <span className={cn(
'flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full font-medium', 'flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full font-medium',
order.orderType === 'room' order.orderType === 'room'
@@ -283,7 +378,7 @@ export function RoomServicePage() {
{order.orderType === 'room' ? <><Home size={9} />В номер</> : <><Store size={9} />Ресторан</>} {order.orderType === 'room' ? <><Home size={9} />В номер</> : <><Store size={9} />Ресторан</>}
</span> </span>
</div> </div>
<p className="text-xs text-slate-500 dark:text-slate-400">{order.guestName}</p> {order.guestName && <p className="text-xs text-slate-500 dark:text-slate-400">{order.guestName}</p>}
{order.scheduledTime && ( {order.scheduledTime && (
<p className="text-xs text-orange-600 dark:text-orange-400 mt-0.5 flex items-center gap-1"> <p className="text-xs text-orange-600 dark:text-orange-400 mt-0.5 flex items-center gap-1">
<Clock size={10} />К столику в {order.scheduledTime} <Clock size={10} />К столику в {order.scheduledTime}
@@ -301,11 +396,11 @@ export function RoomServicePage() {
{/* Items */} {/* Items */}
<div className="space-y-1 mb-3"> <div className="space-y-1 mb-3">
{order.items.map(oi => ( {order.items.map(oi => (
<div key={oi.item.id} className="flex items-center justify-between text-sm"> <div key={oi.id} className="flex items-center justify-between text-sm">
<span className="text-slate-700 dark:text-slate-300"> <span className="text-slate-700 dark:text-slate-300">
{oi.item.emoji} {oi.item.name} ×{oi.qty} {oi.emoji} {oi.name} ×{oi.quantity}
</span> </span>
<span className="text-slate-500 dark:text-slate-400">{formatCurrency(oi.item.price * oi.qty)}</span> <span className="text-slate-500 dark:text-slate-400">{formatCurrency(Number(oi.price) * oi.quantity)}</span>
</div> </div>
))} ))}
{order.notes && ( {order.notes && (
@@ -317,24 +412,18 @@ export function RoomServicePage() {
{/* Totals */} {/* Totals */}
<div className="border-t border-slate-100 dark:border-slate-700 pt-2.5 space-y-1"> <div className="border-t border-slate-100 dark:border-slate-700 pt-2.5 space-y-1">
{order.serviceCharge > 0 && ( {Number(order.serviceCharge) > 0 && (
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400"> <div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
<span className="flex items-center gap-1"><Percent size={10} />Обслуживание {serviceChargePct}%</span> <span className="flex items-center gap-1"><Percent size={10} />Обслуживание {settings?.serviceChargePct ?? 0}%</span>
<span>+{formatCurrency(order.serviceCharge)}</span> <span>+{formatCurrency(Number(order.serviceCharge))}</span>
</div> </div>
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-bold text-slate-900 dark:text-slate-100">{formatCurrency(order.total)}</span> <span className="font-bold text-slate-900 dark:text-slate-100">{formatCurrency(Number(order.total))}</span>
{/* Payment status */} {order.paymentMethod && (
{order.paymentStatus === 'online_paid' && ( <span className="text-[10px] bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400 px-1.5 py-0.5 rounded-full font-medium">
<span className="flex items-center gap-1 text-[10px] bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 px-1.5 py-0.5 rounded-full font-medium"> {order.paymentMethod}
<CreditCard size={9} />Оплачен онлайн
</span>
)}
{order.paymentStatus === 'pending' && order.orderType === 'room' && (
<span className="text-[10px] bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 px-1.5 py-0.5 rounded-full font-medium">
Эквайринг
</span> </span>
)} )}
</div> </div>
@@ -354,8 +443,7 @@ export function RoomServicePage() {
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-xs font-medium hover:bg-brand-700 transition-colors" className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-xs font-medium hover:bg-brand-700 transition-colors"
> >
{cfg.next === 'preparing' && <ChefHat size={12} />} {cfg.next === 'preparing' && <ChefHat size={12} />}
{cfg.next === 'ready' && <CheckCircle2 size={12} />} {(cfg.next === 'ready' || cfg.next === 'delivered') && <CheckCircle2 size={12} />}
{cfg.next === 'delivered' && <CheckCircle2 size={12} />}
{cfg.nextLabel} {cfg.nextLabel}
</button> </button>
)} )}
@@ -396,16 +484,10 @@ export function RoomServicePage() {
{order.orderType === 'room' ? 'В номер' : 'Ресторан'} {order.orderType === 'room' ? 'В номер' : 'Ресторан'}
</span> </span>
</td> </td>
<td className="px-4 py-2.5 text-slate-600 dark:text-slate-400">{order.items.map(i => `${i.item.emoji}×${i.qty}`).join(' ')}</td> <td className="px-4 py-2.5 text-slate-600 dark:text-slate-400">{order.items.map(i => `${i.emoji}×${i.quantity}`).join(' ')}</td>
<td className="px-4 py-2.5 font-semibold text-slate-900 dark:text-slate-100">{formatCurrency(order.total)}</td> <td className="px-4 py-2.5 font-semibold text-slate-900 dark:text-slate-100">{formatCurrency(Number(order.total))}</td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5 text-slate-500 dark:text-slate-400 text-xs">{order.paymentMethod ?? '—'}</td>
<Badge className={order.paymentStatus === 'online_paid' <td className="px-4 py-2.5 text-slate-400 text-xs">{format(new Date(order.updatedAt), 'HH:mm')}</td>
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400'}>
{order.paymentStatus === 'online_paid' ? 'Онлайн' : 'При получении'}
</Badge>
</td>
<td className="px-4 py-2.5 text-slate-400 text-xs">{format(order.updatedAt, 'HH:mm')}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -419,38 +501,75 @@ export function RoomServicePage() {
{/* ── Menu tab ── */} {/* ── Menu tab ── */}
{tab === 'menu' && ( {tab === 'menu' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex gap-1 flex-wrap"> <div className="flex items-center justify-between">
{CATEGORIES.map(cat => ( <div className="flex gap-1 flex-wrap">
<button {categories.map(cat => (
key={cat} <button
onClick={() => setMenuCat(cat)} key={cat}
className={cn( onClick={() => setMenuCat(cat)}
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors', className={cn(
menuCat === cat 'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
? 'bg-brand-600 text-white border-brand-600' activeCat === cat
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400', ? 'bg-brand-600 text-white border-brand-600'
)} : 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
> )}
{cat} >
</button> {cat}
))} </button>
))}
</div>
<button
onClick={() => { setEditItem(null); setShowAddMenu(true) }}
className="btn-primary flex items-center gap-1.5 shrink-0"
>
<Plus size={14} />Добавить блюдо
</button>
</div> </div>
{menu.filter(i => i.category === activeCat).length === 0 && (
<div className="card p-8 text-center text-slate-400">
<p>В этой категории нет блюд</p>
</div>
)}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{MENU_ITEMS.filter(i => i.category === menuCat).map(item => ( {menu.filter(i => i.category === activeCat).map(item => (
<div key={item.id} className={cn('card p-4', item.stopList && 'opacity-50')}> <div key={item.id} className={cn('card p-4', !item.isActive && 'opacity-40', item.isStopList && 'opacity-50')}>
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<span className="text-2xl">{item.emoji}</span> <span className="text-2xl">{item.emoji}</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{item.popular && <Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">Хит</Badge>} {item.isPopular && <Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">Хит</Badge>}
{item.stopList && <Badge className="bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">Стоп</Badge>} {item.isStopList && <Badge className="bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">Стоп</Badge>}
{!item.isActive && <Badge className="bg-slate-100 text-slate-500">Скрыт</Badge>}
</div> </div>
</div> </div>
<p className="font-semibold text-slate-900 dark:text-slate-100">{item.name}</p> <p className="font-semibold text-slate-900 dark:text-slate-100">{item.name}</p>
<p className="text-xs text-slate-400 mt-0.5 flex items-center gap-1"> <p className="text-xs text-slate-400 mt-0.5 flex items-center gap-1">
<Clock size={10} />{item.time} мин <Clock size={10} />{item.prepTimeMin} мин
</p> </p>
<div className="flex items-center justify-between mt-3"> <div className="flex items-center justify-between mt-3">
<span className="text-base font-bold text-brand-600 dark:text-brand-400">{formatCurrency(item.price)}</span> <span className="text-base font-bold text-brand-600 dark:text-brand-400">{formatCurrency(Number(item.price))}</span>
<div className="flex items-center gap-1">
<button
onClick={() => toggleStopList(item)}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 transition-colors"
title={item.isStopList ? 'Убрать из стоп-листа' : 'В стоп-лист'}
>
{item.isStopList ? <ToggleRight size={15} className="text-red-500" /> : <ToggleLeft size={15} />}
</button>
<button
onClick={() => { setEditItem(item); setShowAddMenu(true) }}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-600 transition-colors"
>
<Pencil size={13} />
</button>
<button
onClick={() => deleteMenuItem(item)}
className="p-1.5 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-600 transition-colors"
>
<Trash2 size={13} />
</button>
</div>
</div> </div>
</div> </div>
))} ))}
@@ -469,12 +588,12 @@ export function RoomServicePage() {
Страница гостя (QR-код) Страница гостя (QR-код)
</h3> </h3>
<p className="text-sm text-slate-500 dark:text-slate-400"> <p className="text-sm text-slate-500 dark:text-slate-400">
Распечатайте QR-код и разместите в номерах. Гость откроет страницу, выберет блюда, оплатит и будет следить за статусом заказа. Распечатайте QR-код и разместите в номерах. Гость откроет страницу, выберет блюда и отправит заказ вы получите уведомление.
</p> </p>
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600"> <div className="flex items-center gap-2 p-2.5 rounded-xl bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600">
<span className="text-xs font-mono text-slate-700 dark:text-slate-300 flex-1 truncate">{GUEST_LINK}</span> <span className="text-xs font-mono text-slate-700 dark:text-slate-300 flex-1 truncate">{guestLink}</span>
<a <a
href={GUEST_LINK} href={guestLink}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="shrink-0 p-1.5 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-500 transition-colors" className="shrink-0 p-1.5 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-500 transition-colors"
@@ -489,21 +608,9 @@ export function RoomServicePage() {
{linkCopied ? 'Скопировано' : 'Копировать'} {linkCopied ? 'Скопировано' : 'Копировать'}
</button> </button>
</div> </div>
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-100 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600">
<QrCode size={64} className="text-slate-700 dark:text-slate-300 shrink-0" />
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">QR-код для печати</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Распечатайте и ламинируйте для каждого номера
</p>
<button className="mt-2 text-xs text-brand-600 dark:text-brand-400 hover:underline font-medium">
Скачать PNG
</button>
</div>
</div>
<p className="text-xs text-slate-400 flex items-center gap-1"> <p className="text-xs text-slate-400 flex items-center gap-1">
<Bell size={10} /> <Bell size={10} />
При каждом новом заказе с этой страницы уведомление в центре уведомлений При каждом новом заказе уведомление в системе и push на телефон
</p> </p>
</div> </div>
@@ -511,7 +618,6 @@ export function RoomServicePage() {
<div className="card p-5 space-y-4"> <div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Настройки обслуживания</h3> <h3 className="font-semibold text-slate-900 dark:text-slate-100">Настройки обслуживания</h3>
{/* Service charge */}
<div> <div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Процент за обслуживание (доставка в номер) Процент за обслуживание (доставка в номер)
@@ -527,70 +633,42 @@ export function RoomServicePage() {
</div> </div>
</div> </div>
{/* Payment modes */} {/* Payment methods note */}
<div> <div className="p-3 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700/50">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"> <p className="text-xs text-blue-700 dark:text-blue-300">
Способы оплаты для гостя Способы оплаты настраиваются в разделе <strong>Настройки Оплата</strong> и автоматически доступны гостю при оформлении заказа.
</label> </p>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 rounded-xl border border-slate-200 dark:border-slate-600">
<div className="flex items-center gap-2">
<CreditCard size={16} className="text-emerald-600" />
<div>
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">Оплата онлайн сразу</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Гость оплачивает при заказе через эквайринг</p>
</div>
</div>
<button
onClick={() => setPayOnline(v => !v)}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', payOnline ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
>
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform', payOnline ? 'left-[22px]' : 'left-1')} />
</button>
</div>
<div className="flex items-center justify-between p-3 rounded-xl border border-slate-200 dark:border-slate-600">
<div className="flex items-center gap-2">
<UtensilsCrossed size={16} className="text-violet-600" />
<div>
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">Включить в счёт при выезде</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Заказ добавляется к итоговому счёту гостя</p>
</div>
</div>
<button
onClick={() => setPayOnBill(v => !v)}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', payOnBill ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
>
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform', payOnBill ? 'left-[22px]' : 'left-1')} />
</button>
</div>
</div>
{!payOnline && !payOnBill && (
<p className="text-xs text-red-500 mt-2">Включите хотя бы один способ оплаты</p>
)}
</div> </div>
{/* Restaurant scheduling */} <button
<div> onClick={saveSettings}
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Ресторан</label> disabled={settSaving}
<div className="space-y-2"> className="btn-primary flex items-center gap-1.5"
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300"> >
<input type="checkbox" defaultChecked className="rounded" /> <Save size={14} />
Принимать заказы к определённому времени {settSaving ? 'Сохранение...' : 'Сохранить настройки'}
</label> </button>
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" defaultChecked className="rounded" />
Разрешить предзаказ за сутки
</label>
</div>
</div>
<p className="text-xs text-slate-400 border-t border-slate-100 dark:border-slate-700 pt-3">
В будущих версиях: интеграция с термопринтером на кухне и кухонный экран на планшете
</p>
<button className="btn-primary">Сохранить настройки</button>
</div> </div>
</div> </div>
)} )}
{/* Add/edit menu item modal */}
{showAddMenu && (
<AddMenuItemModal
slug={slug}
item={editItem}
onClose={() => { setShowAddMenu(false); setEditItem(null) }}
onSaved={saved => {
setMenu(prev => {
const idx = prev.findIndex(m => m.id === saved.id)
if (idx >= 0) return prev.map(m => m.id === saved.id ? saved : m)
return [...prev, saved]
})
setShowAddMenu(false)
setEditItem(null)
}}
/>
)}
</div> </div>
) )
} }

View File

@@ -42,6 +42,7 @@ interface RolePermissions {
isSystem: boolean isSystem: boolean
permissions: Record<string, boolean> permissions: Record<string, boolean>
homePage?: string | null homePage?: string | null
notificationSettings: Record<string, boolean>
} }
// ── Constants ────────────────────────────────────────────────────────────────── // ── Constants ──────────────────────────────────────────────────────────────────
@@ -135,6 +136,28 @@ const ALL_MODULE_KEYS = MODULE_GROUPS.flatMap(g => g.modules.map(m => m.key))
const allPerms = (v: boolean) => const allPerms = (v: boolean) =>
Object.fromEntries(ALL_MODULE_KEYS.map(k => [k, v])) Object.fromEntries(ALL_MODULE_KEYS.map(k => [k, v]))
const NOTIFICATION_GROUPS: { group: string; items: { key: string; label: string; description: string }[] }[] = [
{
group: 'Операции',
items: [
{ key: 'room_service_order', label: 'Новый заказ Room Service', description: 'Уведомление о новом заказе в номер' },
{ key: 'new_booking', label: 'Новая бронь через сайт', description: 'Бронирование через онлайн-виджет' },
],
},
{
group: 'Общение',
items: [
{ key: 'unread_messages', label: 'Непрочитанные сообщения', description: 'Чат без ответа более 15 минут' },
],
},
{
group: 'Отзывы',
items: [
{ key: 'new_review', label: 'Новый отзыв гостя', description: 'Прямой отзыв, QR или email-ссылка' },
],
},
]
// Permission key → route path (for home page selector) // Permission key → route path (for home page selector)
const PERM_TO_ROUTE: Record<string, string> = { const PERM_TO_ROUTE: Record<string, string> = {
calendar: '/calendar', calendar: '/calendar',
@@ -172,6 +195,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
color: '#7C3AED', color: '#7C3AED',
isSystem: true, isSystem: true,
permissions: allPerms(true), permissions: allPerms(true),
notificationSettings: {},
}, },
{ {
id: 'rp_manager', id: 'rp_manager',
@@ -179,6 +203,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
color: '#4F46E5', color: '#4F46E5',
isSystem: true, isSystem: true,
permissions: allPerms(true), permissions: allPerms(true),
notificationSettings: {},
}, },
{ {
id: 'rp_receptionist', id: 'rp_receptionist',
@@ -192,6 +217,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
pos: true, reviews: true, reports: true, pos: true, reviews: true, reports: true,
documents: true, website: true, documents: true, website: true,
}, },
notificationSettings: {},
}, },
{ {
id: 'rp_housekeeper', id: 'rp_housekeeper',
@@ -202,6 +228,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
...allPerms(false), ...allPerms(false),
calendar: true, housekeeping: true, rooms: true, maintenance: true, calendar: true, housekeeping: true, rooms: true, maintenance: true,
}, },
notificationSettings: {},
}, },
{ {
id: 'rp_accountant', id: 'rp_accountant',
@@ -214,6 +241,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
reports: true, pos: true, discounts: true, tariffs: true, pricing: true, loyalty: true, reports: true, pos: true, discounts: true, tariffs: true, pricing: true, loyalty: true,
documents: true, documents: true,
}, },
notificationSettings: {},
}, },
{ {
id: 'rp_security', id: 'rp_security',
@@ -224,6 +252,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
...allPerms(false), ...allPerms(false),
calendar: true, bookings: true, calendar: true, bookings: true,
}, },
notificationSettings: {},
}, },
{ {
id: 'rp_technician', id: 'rp_technician',
@@ -236,6 +265,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
housekeeping: true, rooms: true, maintenance: true, housekeeping: true, rooms: true, maintenance: true,
floor_map: true, equipment: true, ttlock: true, floor_map: true, equipment: true, ttlock: true,
}, },
notificationSettings: {},
}, },
] ]
@@ -553,7 +583,7 @@ function RolesTab() {
if (!s) return def if (!s) return def
// Ensure all module keys are present (new modules default to false) // Ensure all module keys are present (new modules default to false)
const fullPerms = { ...allPerms(false), ...s.permissions } const fullPerms = { ...allPerms(false), ...s.permissions }
return { ...def, permissions: fullPerms, homePage: s.homePage ?? def.homePage } return { ...def, permissions: fullPerms, homePage: s.homePage ?? def.homePage, notificationSettings: s.notificationSettings ?? {} }
}) })
// Custom roles (not in system list) // Custom roles (not in system list)
@@ -567,6 +597,7 @@ function RolesTab() {
isSystem: false, isSystem: false,
permissions: { ...allPerms(false), ...r.permissions }, permissions: { ...allPerms(false), ...r.permissions },
homePage: r.homePage ?? null, homePage: r.homePage ?? null,
notificationSettings: r.notificationSettings ?? {},
})) }))
setRoles([...merged, ...custom]) setRoles([...merged, ...custom])
@@ -602,6 +633,14 @@ function RolesTab() {
)) ))
} }
const toggleNotif = (key: string) => {
setRoles(prev => prev.map(r => {
if (r.id !== selectedRoleId) return r
const current = r.notificationSettings[key] ?? true
return { ...r, notificationSettings: { ...r.notificationSettings, [key]: !current } }
}))
}
// ── Save to API ──────────────────────────────────────────────────────────── // ── Save to API ────────────────────────────────────────────────────────────
const handleSave = async () => { const handleSave = async () => {
if (!slug || saving) return if (!slug || saving) return
@@ -615,6 +654,7 @@ function RolesTab() {
isSystem: role.isSystem, isSystem: role.isSystem,
permissions: role.permissions, permissions: role.permissions,
homePage: role.homePage ?? null, homePage: role.homePage ?? null,
notificationSettings: role.notificationSettings,
}) })
})) }))
setSaveOk(true) setSaveOk(true)
@@ -639,6 +679,7 @@ function RolesTab() {
color: colors[roles.length % colors.length], color: colors[roles.length % colors.length],
isSystem: false, isSystem: false,
permissions: allPerms(false), permissions: allPerms(false),
notificationSettings: {},
} }
setRoles(prev => [...prev, newRole]) setRoles(prev => [...prev, newRole])
setSelectedRoleId(newRole.id) setSelectedRoleId(newRole.id)
@@ -653,6 +694,7 @@ function RolesTab() {
isSystem: false, isSystem: false,
permissions: newRole.permissions, permissions: newRole.permissions,
homePage: null, homePage: null,
notificationSettings: {},
}) })
await reloadContext() await reloadContext()
} catch (err) { } catch (err) {
@@ -902,6 +944,65 @@ function RolesTab() {
</select> </select>
</div> </div>
{/* Notification settings */}
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-700 pt-4 space-y-4">
<div>
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1">
Настройки уведомлений
</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-3">
Какие push и in-app уведомления получают сотрудники с этой ролью
</p>
</div>
{NOTIFICATION_GROUPS.map(group => (
<div key={group.group}>
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wide mb-2">
{group.group}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{group.items.map(item => {
const enabled = selectedRole.notificationSettings[item.key] ?? true
return (
<button
key={item.key}
type="button"
onClick={() => toggleNotif(item.key)}
className={cn(
'flex items-start gap-3 p-3 rounded-xl border text-left transition-all',
enabled
? 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600',
)}
>
<div className={cn(
'w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 mt-0.5 transition-colors',
enabled
? 'bg-emerald-600 border-emerald-600'
: 'border-slate-300 dark:border-slate-600',
)}>
{enabled && <Check size={11} className="text-white" />}
</div>
<div>
<p className={cn(
'text-sm font-medium',
enabled
? 'text-emerald-700 dark:text-emerald-300'
: 'text-slate-700 dark:text-slate-300',
)}>
{item.label}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 leading-relaxed">
{item.description}
</p>
</div>
</button>
)
})}
</div>
</div>
))}
</div>
{selectedRole.isSystem && ( {selectedRole.isSystem && (
<div className="px-4 pb-4"> <div className="px-4 pb-4">
<div className="flex items-center gap-2 p-3 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800"> <div className="flex items-center gap-2 p-3 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">