Harat's Irish Pub
-
ООО «Хэрат'с»
+
ООО «Бар-С»
Настоящий ирландский паб с атмосферой "cozy chaos". Место, где нет правил, кроме одного — отдыхать душой.
@@ -21,11 +21,11 @@ export const Footer: React.FC = () => {
Контакты
- - 📞 8 (800) 775-03-39 (Customer Service)
+ - 📞 8 (800) 775-03-39
- ✉️ info@harats.com
-
📍 Главный офис:
- 664033, Россия, Иркутская область, Иркутск, ул. Майская, 20
+ 660075, г.Красноярск, ул.Красной Гвардии 24, помещение 28
@@ -33,8 +33,7 @@ export const Footer: React.FC = () => {
Информация
diff --git a/client/src/components/LocalizedDateInput.tsx b/client/src/components/LocalizedDateInput.tsx
new file mode 100644
index 0000000..29895e1
--- /dev/null
+++ b/client/src/components/LocalizedDateInput.tsx
@@ -0,0 +1,47 @@
+import React, { useId, useRef } from 'react';
+import { isoDateToDisplay } from '@/utils/dateTimeFormat';
+
+interface LocalizedDateInputProps {
+ label: string;
+ value: string;
+ min?: string;
+ onChange: (value: string) => void;
+}
+
+export const LocalizedDateInput: React.FC
= ({
+ label,
+ value,
+ min,
+ onChange,
+}) => {
+ const inputId = useId();
+ const inputRef = useRef(null);
+ const displayValue = value ? isoDateToDisplay(value) : 'дд/мм/гггг';
+
+ return (
+
+
+
inputRef.current?.showPicker?.()}
+ >
+
+ {displayValue}
+
+ onChange(e.target.value)}
+ />
+
+
+ );
+};
diff --git a/client/src/components/LocalizedTimeInput.tsx b/client/src/components/LocalizedTimeInput.tsx
new file mode 100644
index 0000000..85a947c
--- /dev/null
+++ b/client/src/components/LocalizedTimeInput.tsx
@@ -0,0 +1,129 @@
+import React, { useEffect, useId, useRef, useState } from 'react';
+import { formatTime24 } from '@/utils/dateTimeFormat';
+
+interface LocalizedTimeInputProps {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+}
+
+const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
+const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
+
+function parseTime(value: string): { hours: string; minutes: string } {
+ if (!/^\d{2}:\d{2}$/.test(value)) {
+ return { hours: '12', minutes: '00' };
+ }
+ const [hours, minutes] = value.split(':');
+ return { hours, minutes };
+}
+
+export const LocalizedTimeInput: React.FC = ({
+ label,
+ value,
+ onChange,
+}) => {
+ const inputId = useId();
+ const containerRef = useRef(null);
+ const hoursRef = useRef(null);
+ const minutesRef = useRef(null);
+ const [open, setOpen] = useState(false);
+ const parsed = parseTime(value);
+ const [draftHours, setDraftHours] = useState(parsed.hours);
+ const [draftMinutes, setDraftMinutes] = useState(parsed.minutes);
+
+ useEffect(() => {
+ const next = parseTime(value);
+ setDraftHours(next.hours);
+ setDraftMinutes(next.minutes);
+ }, [value]);
+
+ useEffect(() => {
+ if (!open) return;
+
+ const handleClickOutside = (event: MouseEvent) => {
+ if (!containerRef.current?.contains(event.target as Node)) {
+ setOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) return;
+
+ requestAnimationFrame(() => {
+ hoursRef.current?.querySelector('.time-picker__option.is-selected')?.scrollIntoView({ block: 'center' });
+ minutesRef.current?.querySelector('.time-picker__option.is-selected')?.scrollIntoView({ block: 'center' });
+ });
+ }, [open, draftHours, draftMinutes]);
+
+ const commit = (hours: string, minutes: string) => {
+ onChange(`${hours}:${minutes}`);
+ };
+
+ const selectHour = (hours: string) => {
+ setDraftHours(hours);
+ commit(hours, draftMinutes);
+ };
+
+ const selectMinute = (minutes: string) => {
+ setDraftMinutes(minutes);
+ commit(draftHours, minutes);
+ };
+
+ const displayValue = value ? formatTime24(value) : 'чч:мм';
+
+ return (
+
+
+
+
+
+ {open && (
+
+
+ {HOURS.map((hour) => (
+
+ ))}
+
+
+ {MINUTES.map((minute) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+};
diff --git a/client/src/index.css b/client/src/index.css
index 30dbd7b..4242c4e 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -320,6 +320,122 @@ select.input {
padding-right: 40px;
}
+.localized-picker-input {
+ position: relative;
+ cursor: pointer;
+}
+
+.localized-picker-input__overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ padding: 12px 16px;
+ pointer-events: none;
+ color: var(--color-text-primary);
+ font-size: 0.9375rem;
+}
+
+.localized-picker-input__overlay.is-placeholder {
+ color: var(--color-text-muted);
+}
+
+.localized-picker-input__native {
+ position: relative;
+ z-index: 1;
+ color: transparent;
+ caret-color: transparent;
+}
+
+.localized-picker-input__native::-webkit-datetime-edit,
+.localized-picker-input__native::-webkit-datetime-edit-fields-wrapper,
+.localized-picker-input__native::-webkit-datetime-edit-text,
+.localized-picker-input__native::-webkit-datetime-edit-month-field,
+.localized-picker-input__native::-webkit-datetime-edit-day-field,
+.localized-picker-input__native::-webkit-datetime-edit-year-field,
+.localized-picker-input__native::-webkit-datetime-edit-hour-field,
+.localized-picker-input__native::-webkit-datetime-edit-minute-field,
+.localized-picker-input__native::-webkit-datetime-edit-second-field,
+.localized-picker-input__native::-webkit-datetime-edit-ampm-field {
+ color: transparent;
+}
+
+.localized-picker-input__native::-webkit-calendar-picker-indicator {
+ cursor: pointer;
+ opacity: 1;
+}
+
+.time-picker {
+ position: relative;
+}
+
+.time-picker__trigger {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ text-align: left;
+ cursor: pointer;
+ padding-right: 40px;
+ background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='12' cy='12' r='9' stroke='%23a09e94' stroke-width='1.5'/%3E%3Cpath d='M12 7v5l3 2' stroke='%23a09e94' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 14px center;
+}
+
+.time-picker__placeholder {
+ color: var(--color-text-muted);
+}
+
+.time-picker__dropdown {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ z-index: 100;
+ display: flex;
+ min-width: 160px;
+ max-width: 100%;
+ background: #ffffff;
+ border-radius: var(--radius-md);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
+ overflow: hidden;
+}
+
+.time-picker__column {
+ flex: 1;
+ max-height: 220px;
+ overflow-y: auto;
+ scroll-snap-type: y mandatory;
+ overscroll-behavior: contain;
+}
+
+.time-picker__column:first-child {
+ border-right: 1px solid #e5e5e5;
+}
+
+.time-picker__option {
+ display: block;
+ width: 100%;
+ padding: 8px 12px;
+ text-align: center;
+ font-size: 0.9375rem;
+ color: #1a1a1a;
+ scroll-snap-align: center;
+ transition: background-color var(--transition-base);
+}
+
+.time-picker__option:hover {
+ background: #f0f0f0;
+}
+
+.time-picker__option.is-selected {
+ background: #0b74de;
+ color: #ffffff;
+}
+
+.time-picker__option.is-selected:hover {
+ background: #0b74de;
+}
+
/* ─── Card ───────────────────────────────── */
.card {
background: var(--color-bg-card);
diff --git a/client/src/types/html2pdf.d.ts b/client/src/types/html2pdf.d.ts
new file mode 100644
index 0000000..f84c251
--- /dev/null
+++ b/client/src/types/html2pdf.d.ts
@@ -0,0 +1,18 @@
+declare module 'html2pdf.js' {
+ interface Html2PdfOptions {
+ margin?: number | number[];
+ filename?: string;
+ image?: { type?: string; quality?: number };
+ html2canvas?: Record;
+ jsPDF?: Record;
+ }
+
+ interface Html2PdfWorker {
+ set(options: Html2PdfOptions): Html2PdfWorker;
+ from(element: HTMLElement): Html2PdfWorker;
+ save(): Promise;
+ }
+
+ function html2pdf(): Html2PdfWorker;
+ export default html2pdf;
+}
diff --git a/client/src/utils/dateTimeFormat.ts b/client/src/utils/dateTimeFormat.ts
new file mode 100644
index 0000000..aee9c7b
--- /dev/null
+++ b/client/src/utils/dateTimeFormat.ts
@@ -0,0 +1,25 @@
+export function isoDateToDisplay(isoDate: string): string {
+ if (!isoDate) return '';
+ const [year, month, day] = isoDate.split('-');
+ if (!year || !month || !day) return '';
+ return `${day.padStart(2, '0')}/${month.padStart(2, '0')}/${year}`;
+}
+
+export function formatTime24(time: string): string {
+ if (!time) return '';
+ const [hours, minutes] = time.split(':');
+ if (!hours || minutes === undefined) return '';
+ return `${hours.padStart(2, '0')}:${minutes.padStart(2, '0')}`;
+}
+
+export function formatTimeInput(raw: string): string {
+ const digits = raw.replace(/\D/g, '').slice(0, 4);
+ if (digits.length <= 2) return digits;
+ return `${digits.slice(0, 2)}:${digits.slice(2)}`;
+}
+
+export function isValidTime24(time: string): boolean {
+ if (!/^\d{2}:\d{2}$/.test(time)) return false;
+ const [hours, minutes] = time.split(':').map(Number);
+ return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59;
+}
diff --git a/client/src/utils/receiptPdf.ts b/client/src/utils/receiptPdf.ts
new file mode 100644
index 0000000..948ce4a
--- /dev/null
+++ b/client/src/utils/receiptPdf.ts
@@ -0,0 +1,188 @@
+import html2pdf from 'html2pdf.js';
+
+export interface ReceiptDish {
+ title: string;
+ quantity: number;
+ price: number;
+}
+
+export interface ReceiptData {
+ reservationId: number;
+ paymentId?: string;
+ pubName: string;
+ pubAddress: string;
+ date: string;
+ time: string;
+ tableNumber: number;
+ numPeople: number;
+ customerName: string;
+ dishes: ReceiptDish[];
+ totalCost: number;
+ prepaid: number;
+}
+
+function formatDate(dateStr: string): string {
+ const [year, month, day] = dateStr.split('-').map(Number);
+ return new Intl.DateTimeFormat('ru-RU', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ }).format(new Date(year, month - 1, day));
+}
+
+function formatPrice(amount: number): string {
+ return `${amount.toLocaleString('ru-RU')} ₽`;
+}
+
+function buildReceiptHtml(data: ReceiptData): string {
+ const dishRows = data.dishes
+ .map(
+ (d) => `
+
+ | ${d.title} |
+ ${d.quantity} |
+ ${formatPrice(d.price)} |
+ ${formatPrice(d.price * d.quantity)} |
+
`
+ )
+ .join('');
+
+ const issuedAt = new Intl.DateTimeFormat('ru-RU', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(new Date());
+
+ return `
+
+
+
Harat's Irish Pub
+
Чек об оплате предзаказа
+
+
+
+
Номер брони: #${data.reservationId}
+ ${data.paymentId ? `
ID платежа: ${data.paymentId}
` : ''}
+
Дата выдачи чека: ${issuedAt}
+
+
+
+
Информация о бронировании
+
+ | Паб: | ${data.pubName} |
+ | Адрес: | ${data.pubAddress} |
+ | Дата: | ${formatDate(data.date)} |
+ | Время: | ${data.time} |
+ | Стол: | №${data.tableNumber} |
+ | Гостей: | ${data.numPeople} |
+ | Гость: | ${data.customerName} |
+
+
+
+ ${
+ data.dishes.length > 0
+ ? `
+
+
Предзаказ блюд
+
+
+
+ | Блюдо |
+ Кол-во |
+ Цена |
+ Сумма |
+
+
+ ${dishRows}
+
+
+
+
+
+
+ | Стоимость предзаказа: |
+ ${formatPrice(data.totalCost)} |
+
+
+ | Оплачено (предоплата 50%): |
+ ${formatPrice(data.prepaid)} |
+
+
+ | К оплате в пабе: |
+ ${formatPrice(data.totalCost - data.prepaid)} |
+
+
+
`
+ : ''
+ }
+
+
+ Спасибо за бронирование! По вопросам: 8 (800) 775-03-39
+
+
+ `;
+}
+
+export async function downloadReceiptPdf(data: ReceiptData): Promise {
+ const container = document.createElement('div');
+ container.style.cssText =
+ 'position:fixed;left:-9999px;top:0;width:595px;padding:40px;background:#ffffff;';
+ container.innerHTML = buildReceiptHtml(data);
+ document.body.appendChild(container);
+
+ try {
+ await html2pdf()
+ .set({
+ margin: [10, 10, 10, 10],
+ filename: `receipt-${data.reservationId}.pdf`,
+ image: { type: 'jpeg', quality: 0.98 },
+ html2canvas: { scale: 2, useCORS: true },
+ jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
+ })
+ .from(container)
+ .save();
+ } finally {
+ document.body.removeChild(container);
+ }
+}
+
+export function reservationToReceiptData(
+ reservation: {
+ id: number;
+ pub: { name: string; address: string };
+ table: { number: number };
+ date: string;
+ time: string;
+ numPeople: number;
+ totalCost: number | null;
+ prepaid: number | null;
+ dishes: Array<{
+ dish?: { title: string };
+ quantity: number;
+ priceAtOrder: number;
+ }>;
+ },
+ customerName: string,
+ paymentId?: string
+): ReceiptData {
+ return {
+ reservationId: reservation.id,
+ paymentId,
+ pubName: reservation.pub.name,
+ pubAddress: reservation.pub.address,
+ date: reservation.date,
+ time: reservation.time,
+ tableNumber: reservation.table.number,
+ numPeople: reservation.numPeople,
+ customerName,
+ dishes: reservation.dishes.map((d) => ({
+ title: d.dish?.title ?? 'Блюдо',
+ quantity: d.quantity,
+ price: d.priceAtOrder,
+ })),
+ totalCost: reservation.totalCost ?? 0,
+ prepaid: reservation.prepaid ?? 0,
+ };
+}
diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts
index c277f59..c44c575 100644
--- a/server/prisma/seed.ts
+++ b/server/prisma/seed.ts
@@ -25,32 +25,32 @@ async function main() {
// ─── Pubs ────────────────────────────────────────────
const pub1 = await prisma.pub.upsert({
- where: { address: 'ул. Карла Маркса, 26, Иркутск' },
+ where: { address: 'пр. Мира, 7А, Красноярск' },
update: {},
create: {
- name: "Harat's Irish Pub Иркутск",
- address: 'ул. Карла Маркса, 26, Иркутск',
- phone: '+7 (3952) 48-68-76',
+ name: "Harat's Irish Pub Красноярск",
+ address: 'пр. Мира, 7А, Красноярск',
+ phone: '+7 (391) 227-78-18',
},
});
const pub2 = await prisma.pub.upsert({
- where: { address: 'ул. Ленина, 15, Новосибирск' },
+ where: { address: 'ул. Красной Гвардии, 24, Красноярск' },
update: {},
create: {
- name: "Harat's Irish Pub Новосибирск",
- address: 'ул. Ленина, 15, Новосибирск',
- phone: '+7 (383) 230-01-20',
+ name: "Harat's Irish Pub Красноярск",
+ address: 'ул. Красной Гвардии, 24, Красноярск',
+ phone: '+7 (391) 214-68-17',
},
});
const pub3 = await prisma.pub.upsert({
- where: { address: 'Невский проспект, 88, Санкт-Петербург' },
+ where: { address: 'Влетная ул., 6А, Красноярск' },
update: {},
create: {
name: "Harat's Irish Pub Санкт-Петербург",
- address: 'Невский проспект, 88, Санкт-Петербург',
- phone: '+7 (812) 640-16-16',
+ address: 'Влетная ул., 6А, Красноярск',
+ phone: '+7 (391) 271-77-54',
},
});
@@ -64,6 +64,16 @@ async function main() {
},
});
+ const pub5 = await prisma.pub.upsert({
+ where: { address: 'ул. Перенсона, 26, Красноярск' },
+ update: {},
+ create: {
+ name: "Harat's Irish Pub Красноярск",
+ address: 'ул. Перенсона, 26, Красноярск',
+ phone: '+7 (391) 214-07-99',
+ },
+ });
+
// ─── Tables for Pub 1 (main pub with full layout) ───
const tablesData = [
// Main hall
@@ -95,7 +105,7 @@ async function main() {
}
// Simple tables for pub2, pub3, and pub4
- for (const pub of [pub2, pub3, pub4]) {
+ for (const pub of [pub2, pub3, pub4, pub5]) {
for (let i = 1; i <= 8; i++) {
await prisma.table.upsert({
where: { pubId_number: { pubId: pub.id, number: i } },
@@ -191,7 +201,7 @@ async function main() {
}
// ─── Menu (link dishes to pubs, except lunch category) ──
- for (const pub of [pub1, pub2, pub3, pub4]) {
+ for (const pub of [pub1, pub2, pub3, pub4, pub5]) {
for (const dish of createdDishes) {
if (dish.categoryId === lunch.id) continue; // lunch is separate
await prisma.menu.upsert({
@@ -211,7 +221,7 @@ async function main() {
sunday.setDate(monday.getDate() + 6);
const lunchDishes = createdDishes.filter(d => d.categoryId === lunch.id);
- for (const pub of [pub1, pub2, pub3, pub4]) {
+ for (const pub of [pub1, pub2, pub3, pub4, pub5]) {
for (const dish of lunchDishes) {
await prisma.lunchMenu.upsert({
where: { pubId_dishId_weekStart: { pubId: pub.id, dishId: dish.id, weekStart: monday } },