Replace entire repo content with code from /root/shahikitchen-google/

This commit is contained in:
root
2026-07-03 02:51:15 +00:00
parent c8b7dc95e4
commit 8ccf033329
79 changed files with 7611 additions and 439 deletions
+306 -52
View File
@@ -19,7 +19,6 @@ import type { MenuItem } from '@/domain/menu/entities';
import {
getMenuPosterSrc,
getMenuPosterCandidates,
logoUrl,
applyNextImageFallback,
SITE_ASSETS,
} from '@/lib/assets';
@@ -31,11 +30,49 @@ import {
getMenuItemName,
localizeOrderLineName,
} from '@/application/i18n/menu-localization';
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { motion, AnimatePresence } from "framer-motion";
import { MapPin, Calendar, Clock, Users, User, Phone, Mail, MessageCircle, Plus, Minus, X, ArrowRight, Search, CheckCircle } from "lucide-react";
import { menuCategories } from "@/lib/menu-data";
import { MapPin, Calendar, Clock, Users, User, Phone, Mail, MessageCircle, Plus, Minus, X, ArrowRight, Search, CheckCircle, UtensilsCrossed, PartyPopper, Building2, Sparkles } from "lucide-react";
import { useMenu } from "@/presentation/providers/menu-provider";
import { playAddSound, playHoverSound, playSuccessSound } from "@/lib/sounds";
import type { BookingMode, EventTypeId } from '@/domain/booking/event-types';
import { EVENT_GUEST_OPTIONS, TABLE_GUEST_OPTIONS } from '@/domain/booking/event-types';
import {
getBookingEventsCopy,
getEventTypeLabel,
toBookingMessageCopy,
} from '@/presentation/i18n/booking-events';
import {
getWhatsAppInquiryLanguage,
getWhatsAppInquiryTranslation,
} from '@/application/messaging/inquiry-language';
import EventTypePicker from '@/components/reserve/EventTypePicker';
import { useCustomerAuth } from '@/presentation/providers/customer-auth-provider';
const RESERVE_DRAFT_STORAGE_KEY = 'shahi-reserve-draft';
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
);
}
/**
* =============================================================================
@@ -55,20 +92,30 @@ import { playAddSound, playHoverSound, playSuccessSound } from "@/lib/sounds";
* Linked from navbar (after swap) and locations.
*/
const EMPTY_BOOKING: BookingDetails = {
bookingMode: 'table',
eventType: '',
eventTypeOther: '',
location: '',
date: '',
time: '',
guests: '',
name: '',
phone: '',
email: '',
notes: '',
};
export default function ReservePage() {
const { categories: menuCategories } = useMenu();
const { language } = useLanguage();
const t = getTranslation(language);
const eventsCopy = getBookingEventsCopy(language);
const { email: customerEmail, name: customerName, isAuthenticated, isLoading: isAuthLoading } =
useCustomerAuth();
const [booking, setBooking] = useState<BookingDetails>({
location: '',
date: '',
time: '',
guests: '',
name: '',
phone: '',
email: '',
notes: '',
});
const [booking, setBooking] = useState<BookingDetails>(EMPTY_BOOKING);
const [draftRestored, setDraftRestored] = useState(false);
const [preOrder, setPreOrder] = useState<PreOrderLine[]>([]);
const [showMenu, setShowMenu] = useState(false);
const [search, setSearch] = useState('');
@@ -79,13 +126,93 @@ export default function ReservePage() {
setBooking((prev) => ({ ...prev, [name]: value }));
};
const persistReserveDraft = useCallback((draft: BookingDetails) => {
try {
sessionStorage.setItem(RESERVE_DRAFT_STORAGE_KEY, JSON.stringify(draft));
} catch {
// Ignore storage errors.
}
}, []);
const handleGoogleSignInForBooking = () => {
persistReserveDraft(booking);
window.location.href = '/api/auth/google?returnTo=%2Freserve';
};
useEffect(() => {
if (draftRestored) return;
try {
const raw = sessionStorage.getItem(RESERVE_DRAFT_STORAGE_KEY);
if (!raw) {
setDraftRestored(true);
return;
}
const parsed = JSON.parse(raw) as Partial<BookingDetails>;
setBooking((prev) => ({
...prev,
bookingMode: parsed.bookingMode === 'event' ? 'event' : 'table',
eventType: typeof parsed.eventType === 'string' ? parsed.eventType : '',
eventTypeOther: typeof parsed.eventTypeOther === 'string' ? parsed.eventTypeOther : '',
location: typeof parsed.location === 'string' ? parsed.location : '',
date: typeof parsed.date === 'string' ? parsed.date : '',
time: typeof parsed.time === 'string' ? parsed.time : '',
guests: typeof parsed.guests === 'string' ? parsed.guests : '',
name: typeof parsed.name === 'string' ? parsed.name : '',
phone: typeof parsed.phone === 'string' ? parsed.phone : '',
notes: typeof parsed.notes === 'string' ? parsed.notes : '',
}));
sessionStorage.removeItem(RESERVE_DRAFT_STORAGE_KEY);
} catch {
sessionStorage.removeItem(RESERVE_DRAFT_STORAGE_KEY);
} finally {
setDraftRestored(true);
}
}, [draftRestored]);
useEffect(() => {
if (!isAuthenticated || !customerEmail) return;
setBooking((prev) => ({
...prev,
email: customerEmail,
name: prev.name.trim() ? prev.name : customerName?.trim() ?? prev.name,
}));
}, [isAuthenticated, customerEmail, customerName]);
const validateForm = (): boolean => isBookingComplete(booking);
const setBookingMode = (mode: BookingMode) => {
setBooking((prev) => ({
...prev,
bookingMode: mode,
eventType: mode === 'table' ? '' : prev.eventType,
eventTypeOther: mode === 'table' ? '' : prev.eventTypeOther,
guests: '',
}));
};
const selectEventType = (eventType: EventTypeId) => {
setBooking((prev) => ({
...prev,
eventType,
eventTypeOther: eventType === 'other' ? prev.eventTypeOther : '',
}));
};
const handleContinue = (e: React.FormEvent) => {
e.preventDefault();
if (booking.bookingMode === 'event' && !booking.eventType) {
alert(eventsCopy.eventTypeRequired);
return;
}
if (booking.bookingMode === 'event' && booking.eventType === 'other' && !booking.eventTypeOther.trim()) {
alert(eventsCopy.eventTypeOtherRequired);
return;
}
if (validateForm()) {
setShowMenu(true);
// Smooth scroll to menu on mobile
setTimeout(() => {
const menuEl = document.getElementById('preorder-menu');
if (menuEl) menuEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -131,23 +258,35 @@ export default function ReservePage() {
playSuccessSound();
const message = buildBookingWhatsAppMessage(booking, preOrder, {
askim: t.booking.askim,
backaplan: t.booking.backaplan,
});
const inquiryLang = getWhatsAppInquiryLanguage(language);
const inquiryT = getWhatsAppInquiryTranslation(language);
const inquiryEventsCopy = getBookingEventsCopy(inquiryLang);
const message = buildBookingWhatsAppMessage(
booking,
preOrder,
toBookingMessageCopy(inquiryEventsCopy, {
askim: inquiryT.booking.askim,
backaplan: inquiryT.booking.backaplan,
}),
booking.eventType ? getEventTypeLabel(inquiryLang, booking.eventType) : '',
inquiryLang,
);
if (!message) return;
container.messagingGateway.openWhatsApp(message);
setSent(true);
};
const resetAll = () => {
setBooking({ location: '', date: '', time: '', guests: '', name: '', phone: '', email: '', notes: '' });
setBooking(EMPTY_BOOKING);
setPreOrder([]);
setShowMenu(false);
setSearch('');
setSent(false);
};
const guestOptions =
booking.bookingMode === 'event' ? EVENT_GUEST_OPTIONS : TABLE_GUEST_OPTIONS;
const editBooking = () => {
setShowMenu(false);
setSent(false);
@@ -164,42 +303,148 @@ export default function ReservePage() {
<Navbar />
<main className="pb-20">
{/* Elegant Header */}
<div className="max-w-5xl mx-auto px-6 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-[#B38B4D]/10 px-4 py-1 text-[#B38B4D] text-[10px] tracking-[3.5px] font-medium mb-3">
EXCLUSIVE EXPERIENCE
{/* Hero */}
<div className="relative overflow-hidden">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(201,154,46,0.12),transparent_55%)]" />
<div className="relative mx-auto max-w-5xl px-4 py-8 text-center sm:px-6 sm:py-14">
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-[#c99a2e]/25 bg-[#fff6dc] px-4 py-1.5 text-[10px] font-bold uppercase tracking-[0.28em] text-[#8a6a25]">
<Sparkles className="h-3.5 w-3.5" />
{eventsCopy.heroBadge}
</div>
<h1 className="mb-4 font-serif text-3xl leading-[1.1] tracking-[-0.8px] text-[#101724] sm:text-4xl md:text-5xl">
{eventsCopy.heroTitle}
</h1>
<p className="mx-auto max-w-2xl text-sm leading-relaxed text-[#6B665F] md:text-base">
{eventsCopy.heroSubtitle}
</p>
</div>
<h1 className="text-2xl sm:text-[1.75rem] md:text-3xl tracking-[-0.8px] leading-[1.15] mb-2 text-[#101724] break-words">
{t.booking.title}
</h1>
<p className="mx-auto max-w-xl text-sm md:text-[15px] text-[#6B665F] leading-relaxed">
{t.booking.subtitle}
</p>
</div>
<div className="max-w-5xl mx-auto px-6 mt-10">
{/* Decorative header info - now ABOVE the form (not on side) so form can be wider */}
<div className="mb-10 flex justify-center">
<div className="w-full max-w-2xl rounded-3xl border border-[#c99a2e]/20 bg-gradient-to-br from-[#101724] via-[#1a1816] to-[#2C2A26] p-6 sm:p-8 text-white shadow-2xl text-center">
<div className="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-2xl border border-[#c99a2e]/30 bg-white/5 p-3 backdrop-blur mx-auto">
<img src={logoUrl()} alt="Shahi Kitchen" className="h-full w-full object-contain" />
<div className="mx-auto max-w-5xl px-4 sm:px-6">
{/* Experience pillars */}
<div className="mb-10 grid gap-4 sm:grid-cols-3">
{[
{ icon: UtensilsCrossed, title: eventsCopy.experienceDiningTitle, desc: eventsCopy.experienceDiningDesc, accent: 'from-[#0f5a4a]/10 to-[#0f5a4a]/5' },
{ icon: PartyPopper, title: eventsCopy.experienceCelebrateTitle, desc: eventsCopy.experienceCelebrateDesc, accent: 'from-[#c99a2e]/15 to-[#fff6dc]' },
{ icon: Building2, title: eventsCopy.experienceCorporateTitle, desc: eventsCopy.experienceCorporateDesc, accent: 'from-[#101724]/8 to-[#101724]/3' },
].map((item) => (
<div
key={item.title}
className={`rounded-3xl border border-[#EDE6D9] bg-gradient-to-br ${item.accent} p-5 shadow-sm`}
>
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-2xl bg-white shadow-sm">
<item.icon className="h-5 w-5 text-[#B38B4D]" />
</div>
<h2 className="mb-1 font-serif text-lg tracking-tight text-[#101724]">{item.title}</h2>
<p className="text-sm leading-relaxed text-[#6B665F]">{item.desc}</p>
</div>
<div className="font-serif text-2xl sm:text-3xl md:text-4xl tracking-[-1.5px] mb-1">Shahi Kitchen</div>
<div className="text-sm font-medium tracking-[2.5px] text-[#c99a2e] mb-4">TABLE RESERVATIONS</div>
<p className="text-sm leading-relaxed text-white/70 max-w-xs mx-auto mb-4">
Two locations. Unforgettable evenings. Pre-order your favorites for a perfect arrival.
</p>
<div className="text-[10px] uppercase tracking-[2.5px] text-[#c99a2e]/60">ASKIM BACKAPLAN</div>
</div>
))}
</div>
<AnimatePresence mode="wait">
{!sent ? (
<div className="max-w-4xl mx-auto space-y-8">
{/* Booking Form Card - now wider (full in max-w-4xl) */}
<div className="rounded-3xl border border-[#EDE6D9] bg-white p-6 sm:p-8 shadow-xl md:p-10">
<h2 className="text-2xl sm:text-3xl tracking-tight font-serif mb-2">{t.booking.formTitle}</h2>
<p className="text-[#6B665F] mb-8">Fill in your details to secure your table.</p>
<div className="rounded-3xl border border-[#EDE6D9] bg-white p-4 shadow-xl sm:p-8 md:p-10">
{/* Booking mode toggle */}
<div className="mb-6 grid grid-cols-1 gap-3 sm:mb-8 sm:grid-cols-2">
{([
{ mode: 'table' as const, label: eventsCopy.modeTable, desc: eventsCopy.modeTableDesc, icon: UtensilsCrossed },
{ mode: 'event' as const, label: eventsCopy.modeEvent, desc: eventsCopy.modeEventDesc, icon: PartyPopper },
]).map((option) => {
const active = booking.bookingMode === option.mode;
return (
<button
key={option.mode}
type="button"
onClick={() => setBookingMode(option.mode)}
className={`flex min-h-[72px] touch-manipulation items-start gap-3 rounded-2xl border p-3.5 text-left transition-all active:scale-[0.99] sm:gap-4 sm:p-4 ${
active
? 'border-[#c99a2e] bg-gradient-to-br from-[#fff6dc] to-white shadow-md ring-2 ring-[#c99a2e]/25'
: 'border-[#EDE6D9] bg-[#FFFCF7] hover:border-[#c99a2e]/40'
}`}
>
<div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl ${active ? 'bg-[#c99a2e] text-[#241806]' : 'bg-[#F8F5F0] text-[#B38B4D]'}`}>
<option.icon className="h-5 w-5" />
</div>
<div>
<div className="font-semibold text-[#101724]">{option.label}</div>
<div className="text-sm text-[#6B665F]">{option.desc}</div>
</div>
</button>
);
})}
</div>
{booking.bookingMode === 'event' && (
<>
<EventTypePicker
language={language}
selected={booking.eventType}
onSelect={selectEventType}
title={eventsCopy.eventPickerTitle}
subtitle={eventsCopy.eventPickerSubtitle}
/>
{booking.eventType === 'other' && (
<div className="mb-8">
<label htmlFor="eventTypeOther" className="mb-2 block text-sm font-semibold tracking-wide text-[#6B665F]">
{eventsCopy.eventTypeOtherLabel}
</label>
<input
id="eventTypeOther"
name="eventTypeOther"
type="text"
value={booking.eventTypeOther}
onChange={handleBookingChange}
placeholder={eventsCopy.eventTypeOtherPlaceholder}
className="w-full rounded-2xl border border-[#EDE6D9] bg-[#F8F5F0] px-4 py-4 text-[17px] placeholder:text-[#8A8478] transition-all outline-none focus:border-[#B38B4D] focus:bg-white focus:ring-1 focus:ring-[#B38B4D]/20"
/>
</div>
)}
</>
)}
{isAuthLoading ? (
<div className="rounded-2xl border border-[#EDE6D9] bg-[#FFFCF7] px-4 py-8 text-center text-sm text-[#6B665F] sm:px-6 sm:py-10">
Checking your account
</div>
) : !isAuthenticated ? (
<div className="rounded-2xl border border-[#EDE6D9] bg-gradient-to-br from-[#FFFCF7] to-[#FFF6DC]/40 px-4 py-8 text-center sm:px-8 sm:py-10">
<h2 className="mb-2 font-serif text-xl tracking-tight text-[#101724] sm:text-3xl">
{t.booking.signInRequiredTitle}
</h2>
<p className="mx-auto mb-6 max-w-md text-sm leading-relaxed text-[#6B665F] sm:mb-8 sm:text-base">
{t.booking.signInRequiredSubtitle}
</p>
<button
type="button"
onClick={handleGoogleSignInForBooking}
className="mx-auto flex w-full max-w-md min-h-[52px] touch-manipulation items-center justify-center gap-3 rounded-2xl border border-[#EDE6D9] bg-white px-4 py-4 text-base font-semibold text-[#101724] shadow-sm transition hover:border-[#c99a2e]/40 hover:bg-[#FFFCF7] active:scale-[0.985] sm:px-5"
>
<GoogleIcon className="h-5 w-5 shrink-0" />
<span className="text-left leading-snug">{t.auth.customer.signInWithGoogle}</span>
</button>
</div>
) : (
<>
<div className="mb-5 flex items-center gap-3 rounded-2xl border border-[#c99a2e]/25 bg-gradient-to-r from-[#FFF6DC] to-[#FFFCF7] px-3 py-3 sm:mb-6 sm:px-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#101724] text-sm font-bold text-white">
{(customerEmail ?? '?').charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-[#8f6b22]">
{t.booking.signedInAs}
</p>
<p className="truncate text-sm font-medium text-[#101724]">{customerEmail ?? ''}</p>
</div>
</div>
<h2 className="mb-2 font-serif text-xl tracking-tight sm:text-3xl">{t.booking.formTitle}</h2>
<p className="mb-6 text-sm text-[#6B665F] sm:mb-8 sm:text-base">
{booking.bookingMode === 'event'
? eventsCopy.formSubtitleEvent
: eventsCopy.formSubtitleTable}
</p>
<form onSubmit={handleContinue} className="space-y-6">
{/* Location */}
@@ -275,7 +520,11 @@ export default function ReservePage() {
className="w-full cursor-pointer appearance-none rounded-2xl border border-[#EDE6D9] bg-[#F8F5F0] py-4 ps-12 pe-10 text-[17px] transition-all outline-none focus:border-[#B38B4D] focus:bg-white focus:ring-1 focus:ring-[#B38B4D]/20"
>
<option value="">Select</option>
{[1,2,3,4,5,6,7,8,9,10].map(n => <option key={n} value={n}>{n}</option>)}
{guestOptions.map((n) => (
<option key={n} value={n}>
{booking.bookingMode === 'event' ? `${n} guests` : n}
</option>
))}
</select>
<div className="pointer-events-none absolute end-4 top-4 text-[#B38B4D]"></div>
</div>
@@ -329,7 +578,10 @@ export default function ReservePage() {
value={booking.email}
onChange={handleBookingChange}
placeholder={t.booking.emailPlaceholder}
className="w-full rounded-2xl border border-[#EDE6D9] bg-[#F8F5F0] py-4 ps-12 pe-4 text-[17px] placeholder:text-[#8A8478] transition-all outline-none focus:border-[#B38B4D] focus:bg-white focus:ring-1 focus:ring-[#B38B4D]/20"
readOnly={Boolean(customerEmail)}
className={`w-full rounded-2xl border border-[#EDE6D9] py-4 ps-12 pe-4 text-[17px] placeholder:text-[#8A8478] transition-all outline-none focus:border-[#B38B4D] focus:bg-white focus:ring-1 focus:ring-[#B38B4D]/20 ${
customerEmail ? 'bg-[#F8F5F0]/80 text-[#6B665F] cursor-default' : 'bg-[#F8F5F0]'
}`}
/>
</div>
</div>
@@ -359,11 +611,13 @@ export default function ReservePage() {
<ArrowRight className="h-5 w-5" />
</button>
</form>
</>
)}
</div>
{/* Pre-Order Menu Section - revealed after form */}
<AnimatePresence>
{showMenu && (
{showMenu && isAuthenticated && (
<motion.div
id="preorder-menu"
initial={{ opacity: 0, y: 20 }}
@@ -445,7 +699,7 @@ export default function ReservePage() {
playAddSound();
addToPreOrder(dish);
}}
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold shadow-sm active:scale-[0.96] transition-all ${
className={`flex items-center gap-1.5 rounded-full px-4 py-2.5 text-xs font-semibold shadow-sm active:scale-[0.96] transition-all min-h-[44px] touch-manipulation ${
isAdded
? "bg-[#3F5C4A] text-white hover:bg-[#2a4033]"
: "bg-[#B38B4D] text-white hover:bg-[#8f6b22] hover:shadow-md"
@@ -492,11 +746,11 @@ export default function ReservePage() {
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button onClick={() => updatePreOrderQty(item.id, item.quantity - 1)} className="w-7 h-7 flex items-center justify-center border border-[#EDE6D9] rounded text-sm hover:bg-white active:bg-white active:scale-95">-</button>
<button onClick={() => updatePreOrderQty(item.id, item.quantity - 1)} className="min-w-11 min-h-11 flex items-center justify-center border border-[#EDE6D9] rounded-lg text-sm hover:bg-white active:bg-white active:scale-95 touch-manipulation">-</button>
<span className="min-w-10 text-center font-medium text-sm tabular-nums">
{formatLineQuantityLabel(item)}
</span>
<button onClick={() => updatePreOrderQty(item.id, item.quantity + 1)} className="w-7 h-7 flex items-center justify-center border border-[#EDE6D9] rounded text-sm hover:bg-white active:bg-white active:scale-95">+</button>
<button onClick={() => updatePreOrderQty(item.id, item.quantity + 1)} className="min-w-11 min-h-11 flex items-center justify-center border border-[#EDE6D9] rounded-lg text-sm hover:bg-white active:bg-white active:scale-95 touch-manipulation">+</button>
<button onClick={() => removeFromPreOrder(item.id)} className="ml-1 text-[#B38B4D] hover:text-red-600 p-0.5 active:scale-95"><X className="h-4 w-4" /></button>
</div>
</div>