Replace entire repo content with code from /root/shahikitchen-google/
This commit is contained in:
+185
-46
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { MessageCircle } from 'lucide-react';
|
||||
import {
|
||||
@@ -19,14 +19,46 @@ import { useCart } from '@/presentation/providers/cart-provider';
|
||||
import { useLanguage } from '@/presentation/providers/language-provider';
|
||||
import { getTranslation } from '@/presentation/i18n/translations';
|
||||
import { getMenuItemName } from '@/application/i18n/menu-localization';
|
||||
import { getMenuItemById } from '@/lib/menu-data';
|
||||
import { useMenu } from '@/presentation/providers/menu-provider';
|
||||
import CartInquiryModal from '@/components/CartInquiryModal';
|
||||
import {
|
||||
buildCartInquiryCopy,
|
||||
getWhatsAppInquiryLanguage,
|
||||
getWhatsAppInquiryTranslation,
|
||||
} from '@/application/messaging/inquiry-language';
|
||||
import { useCustomerAuth } from '@/presentation/providers/customer-auth-provider';
|
||||
|
||||
const CART_INQUIRY_INTENT_KEY = 'shahi-cart-inquiry-intent';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CartDrawer() {
|
||||
const {
|
||||
items,
|
||||
isOpen,
|
||||
closeCart,
|
||||
openCart,
|
||||
totalPrice,
|
||||
removeFromCart,
|
||||
updateQuantity,
|
||||
@@ -34,19 +66,28 @@ export default function CartDrawer() {
|
||||
} = useCart();
|
||||
|
||||
const { language, isRtl } = useLanguage();
|
||||
const { getItemById } = useMenu();
|
||||
const {
|
||||
email: customerEmail,
|
||||
name: customerName,
|
||||
isAuthenticated,
|
||||
isLoading: isAuthLoading,
|
||||
} = useCustomerAuth();
|
||||
const t = getTranslation(language);
|
||||
const [inquiryMode, setInquiryMode] = useState<FulfillmentMode | null>(null);
|
||||
const [authGateMode, setAuthGateMode] = useState<FulfillmentMode | null>(null);
|
||||
|
||||
const inquiryCopy = {
|
||||
pickupIntro: t.cartDrawer.inquiryPickupIntro,
|
||||
deliveryIntro: t.cartDrawer.inquiryDeliveryIntro,
|
||||
messageTotal: t.cartDrawer.messageTotal,
|
||||
messageName: t.cartDrawer.messageName,
|
||||
messagePhone: t.cartDrawer.messagePhone,
|
||||
messageAddress: t.cartDrawer.messageAddress,
|
||||
messagePreferredTime: t.cartDrawer.messagePreferredTime,
|
||||
deliverySwishNote: t.cartDrawer.deliverySwishNote,
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isAuthLoading || !isAuthenticated) return;
|
||||
|
||||
const intent = sessionStorage.getItem(CART_INQUIRY_INTENT_KEY) as FulfillmentMode | null;
|
||||
if (intent !== 'pickup' && intent !== 'delivery') return;
|
||||
|
||||
sessionStorage.removeItem(CART_INQUIRY_INTENT_KEY);
|
||||
openCart();
|
||||
setAuthGateMode(null);
|
||||
setInquiryMode(intent);
|
||||
}, [isAuthenticated, isAuthLoading, openCart]);
|
||||
|
||||
const openWhatsApp = (message: string | null) => {
|
||||
if (!message) return;
|
||||
@@ -54,12 +95,42 @@ export default function CartDrawer() {
|
||||
setInquiryMode(null);
|
||||
};
|
||||
|
||||
const startInquiry = (mode: FulfillmentMode) => {
|
||||
if (isAuthLoading) return;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
setAuthGateMode(mode);
|
||||
setInquiryMode(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthGateMode(null);
|
||||
setInquiryMode(mode);
|
||||
};
|
||||
|
||||
const handleGoogleSignInForInquiry = () => {
|
||||
if (!authGateMode) return;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(CART_INQUIRY_INTENT_KEY, authGateMode);
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
|
||||
const returnTo = encodeURIComponent(`${window.location.pathname}${window.location.search}`);
|
||||
window.location.href = `/api/auth/google?returnTo=${returnTo}`;
|
||||
};
|
||||
|
||||
const handlePickupSubmit = (details: PickupInquiryDetails) => {
|
||||
openWhatsApp(buildPickupInquiryMessage(items, details, inquiryCopy, language));
|
||||
const inquiryLang = getWhatsAppInquiryLanguage(language);
|
||||
const inquiryCopy = buildCartInquiryCopy(getWhatsAppInquiryTranslation(language));
|
||||
openWhatsApp(buildPickupInquiryMessage(items, details, inquiryCopy, inquiryLang));
|
||||
};
|
||||
|
||||
const handleDeliverySubmit = (details: DeliveryInquiryDetails) => {
|
||||
openWhatsApp(buildDeliveryInquiryMessage(items, details, inquiryCopy, language));
|
||||
const inquiryLang = getWhatsAppInquiryLanguage(language);
|
||||
const inquiryCopy = buildCartInquiryCopy(getWhatsAppInquiryTranslation(language));
|
||||
openWhatsApp(buildDeliveryInquiryMessage(items, details, inquiryCopy, inquiryLang));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
@@ -72,11 +143,11 @@ export default function CartDrawer() {
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`fixed top-0 h-full w-full max-w-md bg-[#F8F5F0] z-[990] shadow-2xl flex flex-col ${
|
||||
className={`fixed top-0 h-[100dvh] w-full max-w-md bg-[#F8F5F0] z-[990] shadow-2xl flex flex-col ${
|
||||
isRtl ? 'left-0 border-r border-[#EDE6D9]' : 'right-0 border-l border-[#EDE6D9]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 border-b border-[#EDE6D9]">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[#EDE6D9] px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl tracking-[-0.5px]">{t.cartDrawer.title}</h2>
|
||||
{items.length > 0 && (
|
||||
@@ -96,14 +167,15 @@ export default function CartDrawer() {
|
||||
)}
|
||||
<button
|
||||
onClick={closeCart}
|
||||
className="text-[#6B665F] hover:text-[#2C2A26] text-2xl leading-none pl-1"
|
||||
aria-label="Close cart"
|
||||
className="flex min-h-11 min-w-11 items-center justify-center text-[#6B665F] hover:text-[#2C2A26] text-2xl leading-none touch-manipulation"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain px-4 py-4 sm:px-6 sm:py-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="text-6xl mb-4">🛒</div>
|
||||
@@ -126,7 +198,7 @@ export default function CartDrawer() {
|
||||
<h4 className="font-medium tracking-[-0.3px]">
|
||||
{getMenuItemName(language, {
|
||||
id: item.id,
|
||||
name: getMenuItemById(item.id)?.name ?? item.name,
|
||||
name: getItemById(item.id)?.name ?? item.name,
|
||||
})}
|
||||
</h4>
|
||||
<p className="text-sm text-[#6B665F]">
|
||||
@@ -172,7 +244,7 @@ export default function CartDrawer() {
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] border-t border-[#EDE6D9] bg-white">
|
||||
<div className="shrink-0 border-t border-[#EDE6D9] bg-white px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-6">
|
||||
<button
|
||||
onClick={clearCart}
|
||||
className="text-xs text-[#8A8478] hover:text-[#B38B4D] mb-3 underline"
|
||||
@@ -185,27 +257,57 @@ export default function CartDrawer() {
|
||||
<span>{totalPrice.toFixed(0)} kr</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-[#6B665F] mb-4">
|
||||
{t.cartDrawer.inquiryHint}
|
||||
</p>
|
||||
{authGateMode ? (
|
||||
<div className="mb-3 rounded-2xl border border-[#EDE6D9] bg-gradient-to-br from-[#FFFCF7] to-[#FFF6DC]/50 p-4 text-center sm:p-5">
|
||||
<h3 className="mb-2 font-serif text-base tracking-tight text-[#101724] sm:text-lg">
|
||||
{t.cartDrawer.signInRequiredTitle}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm leading-relaxed text-[#6B665F]">
|
||||
{t.cartDrawer.signInRequiredSubtitle}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignInForInquiry}
|
||||
className="mb-3 flex w-full items-center justify-center gap-3 rounded-2xl border border-[#EDE6D9] bg-white px-4 py-3.5 text-sm font-semibold text-[#101724] shadow-sm transition hover:border-[#c99a2e]/40 hover:bg-[#FFFCF7] active:scale-[0.985] min-h-[52px] touch-manipulation"
|
||||
>
|
||||
<GoogleIcon className="h-5 w-5 shrink-0" />
|
||||
<span className="text-left leading-snug">{t.auth.customer.signInWithGoogle}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuthGateMode(null)}
|
||||
className="inline-flex min-h-[44px] w-full items-center justify-center text-sm font-medium text-[#6B665F] hover:text-[#101724] active:underline touch-manipulation"
|
||||
>
|
||||
{t.cartDrawer.inquiryCancel}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-center text-[#6B665F] mb-4">
|
||||
{t.cartDrawer.inquiryHint}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInquiryMode('pickup')}
|
||||
className="btn-primary w-full py-4 rounded-full text-base tracking-[0.5px] font-medium mb-2 flex items-center justify-center gap-2"
|
||||
>
|
||||
<MessageCircle className="h-5 w-5" aria-hidden />
|
||||
{t.cartDrawer.pickupInquiry}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startInquiry('pickup')}
|
||||
disabled={isAuthLoading}
|
||||
className="btn-primary mb-2 flex w-full min-h-[52px] items-center justify-center gap-2 rounded-full py-4 text-base font-medium tracking-[0.5px] touch-manipulation active:scale-[0.985] disabled:opacity-60"
|
||||
>
|
||||
<MessageCircle className="h-5 w-5" aria-hidden />
|
||||
{t.cartDrawer.pickupInquiry}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInquiryMode('delivery')}
|
||||
className="btn-outline w-full py-4 rounded-full text-base tracking-[0.5px] font-medium mb-2 flex items-center justify-center gap-2 border-[#25D366] text-[#128C7E] hover:bg-[#25D366]/10"
|
||||
>
|
||||
<MessageCircle className="h-5 w-5" aria-hidden />
|
||||
{t.cartDrawer.deliveryInquiry}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startInquiry('delivery')}
|
||||
disabled={isAuthLoading}
|
||||
className="btn-outline mb-2 flex w-full min-h-[52px] items-center justify-center gap-2 rounded-full border-[#25D366] py-4 text-base font-medium tracking-[0.5px] text-[#128C7E] hover:bg-[#25D366]/10 touch-manipulation active:scale-[0.985] disabled:opacity-60"
|
||||
>
|
||||
<MessageCircle className="h-5 w-5" aria-hidden />
|
||||
{t.cartDrawer.deliveryInquiry}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => window.open(`tel:${RESTAURANT_CONTACT.phonePrimary}`, '_self')}
|
||||
@@ -224,32 +326,69 @@ export default function CartDrawer() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{inquiryMode && (
|
||||
{inquiryMode && isAuthenticated && (
|
||||
<CartInquiryModal
|
||||
mode={inquiryMode}
|
||||
isOpen={!!inquiryMode}
|
||||
language={language}
|
||||
customerEmail={customerEmail}
|
||||
customerName={customerName}
|
||||
onClose={() => setInquiryMode(null)}
|
||||
onSubmitPickup={handlePickupSubmit}
|
||||
onSubmitDelivery={handleDeliverySubmit}
|
||||
labels={{
|
||||
titlePickup: t.cartDrawer.inquiryModalTitlePickup,
|
||||
titleDelivery: t.cartDrawer.inquiryModalTitleDelivery,
|
||||
signedInAs: t.cartDrawer.signedInAs,
|
||||
nameLabel: t.cartDrawer.nameLabel,
|
||||
namePlaceholder: t.cartDrawer.namePlaceholder,
|
||||
phoneLabel: t.cartDrawer.phoneLabel,
|
||||
phonePlaceholder: t.cartDrawer.phonePlaceholder,
|
||||
emailLabel: t.cartDrawer.emailLabel,
|
||||
emailPlaceholder: t.cartDrawer.emailPlaceholder,
|
||||
emailRequired: t.cartDrawer.emailRequired,
|
||||
branchLabel: t.cartDrawer.branchLabel,
|
||||
branchAskim: t.cartDrawer.branchAskim,
|
||||
branchBackaplan: t.cartDrawer.branchBackaplan,
|
||||
askimOnlineUnavailable: t.cartDrawer.askimOnlineUnavailable,
|
||||
addressLabel: t.cartDrawer.addressLabel,
|
||||
addressPlaceholder: t.cartDrawer.addressPlaceholder,
|
||||
addressHint:
|
||||
(t.cartDrawer as { addressHint?: string }).addressHint ??
|
||||
'Only Gothenburg addresses can be selected. Pick one from the suggestions.',
|
||||
addressFallbackPlaceholder:
|
||||
(t.cartDrawer as { addressFallbackPlaceholder?: string }).addressFallbackPlaceholder ??
|
||||
'Street, postcode, city…',
|
||||
addressFallbackHint:
|
||||
(t.cartDrawer as { addressFallbackHint?: string }).addressFallbackHint ??
|
||||
'Address search is temporarily unavailable. Type your full delivery address manually.',
|
||||
addressSearching:
|
||||
(t.cartDrawer as { addressSearching?: string }).addressSearching ??
|
||||
'Searching addresses…',
|
||||
addressNoResults:
|
||||
(t.cartDrawer as { addressNoResults?: string }).addressNoResults ??
|
||||
'No matching addresses. Try street name and number.',
|
||||
addressOutsideGothenburg:
|
||||
(t.cartDrawer as { addressOutsideGothenburg?: string }).addressOutsideGothenburg ??
|
||||
'This address is outside Gothenburg. We only deliver within the city.',
|
||||
addressSelectSuggestion:
|
||||
(t.cartDrawer as { addressSelectSuggestion?: string }).addressSelectSuggestion ??
|
||||
'Please pick an address from the suggestions.',
|
||||
addressInvalid:
|
||||
(t.cartDrawer as { addressInvalid?: string }).addressInvalid ??
|
||||
'Select a valid Gothenburg address from the list.',
|
||||
preferredDateLabel: t.cartDrawer.preferredDateLabel,
|
||||
preferredDatePlaceholder: t.cartDrawer.preferredDatePlaceholder,
|
||||
dateToday: t.cartDrawer.dateToday,
|
||||
dateTomorrow: t.cartDrawer.dateTomorrow,
|
||||
preferredTimeLabel: t.cartDrawer.preferredTimeLabel,
|
||||
preferredTimePlaceholder: t.cartDrawer.preferredTimePlaceholder,
|
||||
scheduleOptionalHint: t.cartDrawer.scheduleOptionalHint,
|
||||
submitPickup: t.cartDrawer.submitPickup,
|
||||
submitDelivery: t.cartDrawer.submitDelivery,
|
||||
cancel: t.cartDrawer.inquiryCancel,
|
||||
close: t.cartDrawer.inquiryClose,
|
||||
nameRequired: t.cartDrawer.nameRequired,
|
||||
phoneRequired: t.cartDrawer.phoneRequired,
|
||||
addressRequired: t.cartDrawer.addressRequired,
|
||||
timeRequired: t.cartDrawer.timeRequired,
|
||||
branchRequired: t.cartDrawer.branchRequired,
|
||||
scheduleIncomplete: t.cartDrawer.scheduleIncomplete,
|
||||
scheduleTooSoon: t.cartDrawer.scheduleTooSoon,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
+280
-105
@@ -2,58 +2,106 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { MessageCircle, X } from 'lucide-react';
|
||||
import type { FulfillmentMode } from '@/domain/cart/inquiry';
|
||||
import type {
|
||||
FulfillmentMode,
|
||||
InquiryBranch,
|
||||
InquiryPreferredDate,
|
||||
} from '@/domain/cart/inquiry';
|
||||
import type { DeliveryInquiryDetails, PickupInquiryDetails } from '@/domain/cart/inquiry';
|
||||
import {
|
||||
DEFAULT_INQUIRY_BRANCH,
|
||||
getMinInquiryTimeForToday,
|
||||
isInquiryBranchOnlineEnabled,
|
||||
validateInquirySchedule,
|
||||
} from '@/domain/cart/inquiry';
|
||||
import type { Language } from '@/domain/language/entities';
|
||||
import { getWhatsAppInquiryLanguage } from '@/application/messaging/inquiry-language';
|
||||
import DeliveryAddressAutocomplete from '@/components/DeliveryAddressAutocomplete';
|
||||
|
||||
export interface CartInquiryModalLabels {
|
||||
titlePickup: string;
|
||||
titleDelivery: string;
|
||||
signedInAs: string;
|
||||
nameLabel: string;
|
||||
namePlaceholder: string;
|
||||
phoneLabel: string;
|
||||
phonePlaceholder: string;
|
||||
emailLabel: string;
|
||||
emailPlaceholder: string;
|
||||
emailRequired: string;
|
||||
branchLabel: string;
|
||||
branchAskim: string;
|
||||
branchBackaplan: string;
|
||||
askimOnlineUnavailable: string;
|
||||
addressLabel: string;
|
||||
addressPlaceholder: string;
|
||||
addressHint: string;
|
||||
addressFallbackPlaceholder: string;
|
||||
addressFallbackHint: string;
|
||||
addressSearching: string;
|
||||
addressNoResults: string;
|
||||
addressOutsideGothenburg: string;
|
||||
addressSelectSuggestion: string;
|
||||
addressInvalid: string;
|
||||
preferredDateLabel: string;
|
||||
preferredDatePlaceholder: string;
|
||||
dateToday: string;
|
||||
dateTomorrow: string;
|
||||
preferredTimeLabel: string;
|
||||
preferredTimePlaceholder: string;
|
||||
scheduleOptionalHint: string;
|
||||
submitPickup: string;
|
||||
submitDelivery: string;
|
||||
cancel: string;
|
||||
close: string;
|
||||
nameRequired: string;
|
||||
phoneRequired: string;
|
||||
addressRequired: string;
|
||||
timeRequired: string;
|
||||
branchRequired: string;
|
||||
scheduleIncomplete: string;
|
||||
scheduleTooSoon: string;
|
||||
}
|
||||
|
||||
interface CartInquiryModalProps {
|
||||
mode: FulfillmentMode;
|
||||
isOpen: boolean;
|
||||
language: Language;
|
||||
customerEmail: string | null;
|
||||
customerName: string | null;
|
||||
onClose: () => void;
|
||||
onSubmitPickup: (details: PickupInquiryDetails) => void;
|
||||
onSubmitDelivery: (details: DeliveryInquiryDetails) => void;
|
||||
labels: CartInquiryModalLabels;
|
||||
}
|
||||
|
||||
const emptyPickup = (): PickupInquiryDetails => ({ name: '', phone: '' });
|
||||
const emptyDelivery = (): DeliveryInquiryDetails => ({
|
||||
name: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
preferredTime: '',
|
||||
});
|
||||
function createPickupDefaults(email = '', name = ''): PickupInquiryDetails {
|
||||
return {
|
||||
name: name.trim(),
|
||||
email,
|
||||
branch: DEFAULT_INQUIRY_BRANCH,
|
||||
preferredDate: '',
|
||||
preferredTime: '',
|
||||
};
|
||||
}
|
||||
|
||||
function createDeliveryDefaults(email = '', name = ''): DeliveryInquiryDetails {
|
||||
return {
|
||||
...createPickupDefaults(email, name),
|
||||
address: '',
|
||||
};
|
||||
}
|
||||
|
||||
export default function CartInquiryModal({
|
||||
mode,
|
||||
isOpen,
|
||||
language,
|
||||
customerEmail,
|
||||
customerName,
|
||||
onClose,
|
||||
onSubmitPickup,
|
||||
onSubmitDelivery,
|
||||
labels,
|
||||
}: CartInquiryModalProps) {
|
||||
const [pickup, setPickup] = useState(emptyPickup);
|
||||
const [delivery, setDelivery] = useState(emptyDelivery);
|
||||
const [pickup, setPickup] = useState(createPickupDefaults);
|
||||
const [delivery, setDelivery] = useState(createDeliveryDefaults);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [addressValid, setAddressValid] = useState(true);
|
||||
const autocompleteLang = getWhatsAppInquiryLanguage(language);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
@@ -67,20 +115,86 @@ export default function CartInquiryModal({
|
||||
setErrors({});
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, handleKeyDown, mode]);
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPickup(emptyPickup());
|
||||
setDelivery(emptyDelivery());
|
||||
setPickup(createPickupDefaults());
|
||||
setDelivery(createDeliveryDefaults());
|
||||
setErrors({});
|
||||
setAddressValid(true);
|
||||
return;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const email = customerEmail ?? '';
|
||||
const name = customerName ?? '';
|
||||
setPickup(createPickupDefaults(email, name));
|
||||
setDelivery(createDeliveryDefaults(email, name));
|
||||
setErrors({});
|
||||
setAddressValid(true);
|
||||
}, [isOpen, customerEmail, customerName]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const title = mode === 'pickup' ? labels.titlePickup : labels.titleDelivery;
|
||||
const submitLabel = mode === 'pickup' ? labels.submitPickup : labels.submitDelivery;
|
||||
const formState = mode === 'pickup' ? pickup : delivery;
|
||||
const branch = formState.branch;
|
||||
const preferredDate = formState.preferredDate;
|
||||
const preferredTime = formState.preferredTime;
|
||||
const isAskimBlocked = branch === 'askim';
|
||||
const isSubmitDisabled = !isInquiryBranchOnlineEnabled(branch);
|
||||
const minTimeToday = getMinInquiryTimeForToday();
|
||||
const emailLocked = Boolean(customerEmail);
|
||||
|
||||
const setName = (value: string) => {
|
||||
if (mode === 'pickup') setPickup((p) => ({ ...p, name: value }));
|
||||
else setDelivery((d) => ({ ...d, name: value }));
|
||||
if (errors.name) setErrors((err) => ({ ...err, name: '' }));
|
||||
};
|
||||
|
||||
const setEmail = (value: string) => {
|
||||
if (emailLocked) return;
|
||||
if (mode === 'pickup') setPickup((p) => ({ ...p, email: value }));
|
||||
else setDelivery((d) => ({ ...d, email: value }));
|
||||
if (errors.email) setErrors((err) => ({ ...err, email: '' }));
|
||||
};
|
||||
|
||||
const setBranch = (value: InquiryBranch) => {
|
||||
if (mode === 'pickup') setPickup((p) => ({ ...p, branch: value }));
|
||||
else setDelivery((d) => ({ ...d, branch: value }));
|
||||
if (errors.branch) setErrors((err) => ({ ...err, branch: '' }));
|
||||
};
|
||||
|
||||
const setPreferredDate = (value: InquiryPreferredDate) => {
|
||||
if (mode === 'pickup') {
|
||||
setPickup((p) => ({ ...p, preferredDate: value }));
|
||||
} else {
|
||||
setDelivery((d) => ({ ...d, preferredDate: value }));
|
||||
}
|
||||
if (errors.schedule) setErrors((err) => ({ ...err, schedule: '' }));
|
||||
};
|
||||
|
||||
const setPreferredTime = (value: string) => {
|
||||
if (mode === 'pickup') {
|
||||
setPickup((p) => ({ ...p, preferredTime: value }));
|
||||
} else {
|
||||
setDelivery((d) => ({ ...d, preferredTime: value }));
|
||||
}
|
||||
if (errors.schedule) setErrors((err) => ({ ...err, schedule: '' }));
|
||||
};
|
||||
|
||||
const validateSchedule = (state: PickupInquiryDetails): boolean => {
|
||||
const result = validateInquirySchedule(state);
|
||||
if (result === 'ok') return true;
|
||||
|
||||
if (result === 'incomplete') {
|
||||
setErrors((err) => ({ ...err, schedule: labels.scheduleIncomplete }));
|
||||
} else if (result === 'too_soon' || result === 'invalid_time') {
|
||||
setErrors((err) => ({ ...err, schedule: labels.scheduleTooSoon }));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -88,28 +202,35 @@ export default function CartInquiryModal({
|
||||
|
||||
if (mode === 'pickup') {
|
||||
if (!pickup.name.trim()) nextErrors.name = labels.nameRequired;
|
||||
if (!pickup.phone.trim()) nextErrors.phone = labels.phoneRequired;
|
||||
if (!pickup.email.trim()) nextErrors.email = labels.emailRequired;
|
||||
if (!pickup.branch) nextErrors.branch = labels.branchRequired;
|
||||
if (Object.keys(nextErrors).length) {
|
||||
setErrors(nextErrors);
|
||||
return;
|
||||
}
|
||||
if (!validateSchedule(pickup)) return;
|
||||
if (!isInquiryBranchOnlineEnabled(pickup.branch)) return;
|
||||
onSubmitPickup(pickup);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!delivery.name.trim()) nextErrors.name = labels.nameRequired;
|
||||
if (!delivery.phone.trim()) nextErrors.phone = labels.phoneRequired;
|
||||
if (!delivery.address.trim()) nextErrors.address = labels.addressRequired;
|
||||
if (!delivery.preferredTime.trim()) nextErrors.preferredTime = labels.timeRequired;
|
||||
if (!delivery.email.trim()) nextErrors.email = labels.emailRequired;
|
||||
if (!delivery.branch) nextErrors.branch = labels.branchRequired;
|
||||
if (delivery.address.trim() && !addressValid) {
|
||||
nextErrors.address = labels.addressInvalid;
|
||||
}
|
||||
if (Object.keys(nextErrors).length) {
|
||||
setErrors(nextErrors);
|
||||
return;
|
||||
}
|
||||
if (!validateSchedule(delivery)) return;
|
||||
if (!isInquiryBranchOnlineEnabled(delivery.branch)) return;
|
||||
onSubmitDelivery(delivery);
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
'mt-1.5 w-full rounded-xl border border-[#EDE6D9] bg-[#FFFCF7] px-3 py-2.5 text-sm text-[#2C2A26] placeholder:text-[#8A8478] focus:border-[#c99a2e] focus:ring-2 focus:ring-[#c99a2e]/20';
|
||||
'mt-1.5 w-full min-h-[48px] rounded-xl border border-[#EDE6D9] bg-[#FFFCF7] px-3 py-3 text-base text-[#2C2A26] placeholder:text-[#8A8478] focus:border-[#c99a2e] focus:ring-2 focus:ring-[#c99a2e]/20 touch-manipulation';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -126,37 +247,47 @@ export default function CartInquiryModal({
|
||||
aria-labelledby="cart-inquiry-title"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto w-full sm:max-w-md max-h-[90dvh] overflow-y-auto rounded-t-3xl sm:rounded-2xl bg-[#FFFCF7] border border-[#EDE6D9] shadow-2xl"
|
||||
className="pointer-events-auto flex max-h-[92dvh] w-full flex-col overflow-hidden rounded-t-3xl border border-[#EDE6D9] bg-[#FFFCF7] shadow-2xl sm:max-w-md sm:rounded-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[#EDE6D9] px-6 py-4">
|
||||
<h2 id="cart-inquiry-title" className="font-serif text-xl tracking-[-0.3px] text-[#101724]">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[#EDE6D9] px-4 py-3.5 sm:px-6 sm:py-4">
|
||||
<h2 id="cart-inquiry-title" className="pe-3 font-serif text-lg tracking-[-0.3px] text-[#101724] sm:text-xl">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={labels.close}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-[#EDE6D9] bg-white text-[#6B665F] hover:text-[#101724]"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-[#EDE6D9] bg-white text-[#6B665F] hover:text-[#101724] touch-manipulation"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-6">
|
||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-y-contain px-4 py-4 sm:px-6 sm:py-5">
|
||||
{customerEmail && (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-[#c99a2e]/25 bg-gradient-to-r from-[#FFF6DC] to-[#FFFCF7] px-3 py-3 sm:px-4">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#101724] text-xs font-bold text-white">
|
||||
{customerEmail.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#8f6b22]">
|
||||
{labels.signedInAs}
|
||||
</p>
|
||||
<p className="truncate text-sm font-medium text-[#101724]">{customerEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.nameLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mode === 'pickup' ? pickup.name : delivery.name}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (mode === 'pickup') setPickup((p) => ({ ...p, name: v }));
|
||||
else setDelivery((d) => ({ ...d, name: v }));
|
||||
if (errors.name) setErrors((err) => ({ ...err, name: '' }));
|
||||
}}
|
||||
value={formState.name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={labels.namePlaceholder}
|
||||
className={inputClass}
|
||||
autoComplete="name"
|
||||
@@ -168,83 +299,127 @@ export default function CartInquiryModal({
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.phoneLabel}
|
||||
{labels.emailLabel}
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={mode === 'pickup' ? pickup.phone : delivery.phone}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (mode === 'pickup') setPickup((p) => ({ ...p, phone: v }));
|
||||
else setDelivery((d) => ({ ...d, phone: v }));
|
||||
if (errors.phone) setErrors((err) => ({ ...err, phone: '' }));
|
||||
}}
|
||||
placeholder={labels.phonePlaceholder}
|
||||
className={inputClass}
|
||||
autoComplete="tel"
|
||||
type="email"
|
||||
value={formState.email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={labels.emailPlaceholder}
|
||||
readOnly={emailLocked}
|
||||
className={`${inputClass} ${emailLocked ? 'bg-[#F8F5F0]/80 text-[#6B665F] cursor-default' : ''}`}
|
||||
autoComplete="email"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="mt-1 text-xs text-[#B45309]" role="alert">{errors.phone}</p>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-xs text-[#B45309]" role="alert">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.branchLabel}
|
||||
</label>
|
||||
<select
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value as InquiryBranch)}
|
||||
className={`${inputClass} ${isAskimBlocked ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : ''}`}
|
||||
>
|
||||
<option value="backaplan">{labels.branchBackaplan}</option>
|
||||
<option value="askim">{labels.branchAskim}</option>
|
||||
</select>
|
||||
{errors.branch && (
|
||||
<p className="mt-1 text-xs text-red-600" role="alert">{errors.branch}</p>
|
||||
)}
|
||||
{isAskimBlocked && (
|
||||
<p className="mt-1 text-xs font-medium text-red-600" role="alert">
|
||||
{labels.askimOnlineUnavailable}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === 'delivery' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.addressLabel}
|
||||
</label>
|
||||
<textarea
|
||||
value={delivery.address}
|
||||
onChange={(e) => {
|
||||
setDelivery((d) => ({ ...d, address: e.target.value }));
|
||||
if (errors.address) setErrors((err) => ({ ...err, address: '' }));
|
||||
}}
|
||||
placeholder={labels.addressPlaceholder}
|
||||
rows={3}
|
||||
className={`${inputClass} resize-none`}
|
||||
/>
|
||||
{errors.address && (
|
||||
<p className="mt-1 text-xs text-[#B45309]" role="alert">{errors.address}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.preferredTimeLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={delivery.preferredTime}
|
||||
onChange={(e) => {
|
||||
setDelivery((d) => ({ ...d, preferredTime: e.target.value }));
|
||||
if (errors.preferredTime) setErrors((err) => ({ ...err, preferredTime: '' }));
|
||||
}}
|
||||
placeholder={labels.preferredTimePlaceholder}
|
||||
className={inputClass}
|
||||
/>
|
||||
{errors.preferredTime && (
|
||||
<p className="mt-1 text-xs text-[#B45309]" role="alert">{errors.preferredTime}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.addressLabel}
|
||||
</label>
|
||||
<DeliveryAddressAutocomplete
|
||||
value={delivery.address}
|
||||
onChange={(address) => setDelivery((d) => ({ ...d, address }))}
|
||||
onValidationChange={setAddressValid}
|
||||
language={autocompleteLang}
|
||||
labels={{
|
||||
placeholder: labels.addressPlaceholder,
|
||||
hint: labels.addressHint,
|
||||
fallbackPlaceholder: labels.addressFallbackPlaceholder,
|
||||
fallbackHint: labels.addressFallbackHint,
|
||||
searching: labels.addressSearching,
|
||||
noResults: labels.addressNoResults,
|
||||
outsideGothenburg: labels.addressOutsideGothenburg,
|
||||
selectSuggestion: labels.addressSelectSuggestion,
|
||||
}}
|
||||
inputClassName={inputClass}
|
||||
/>
|
||||
{errors.address && (
|
||||
<p className="mt-1 text-xs text-red-600" role="alert">{errors.address}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-full border border-[#EDE6D9] py-3 text-sm font-medium text-[#6B665F] hover:bg-[#F8F5F0]"
|
||||
>
|
||||
{labels.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary flex flex-1 items-center justify-center gap-2 rounded-full py-3 text-sm font-medium tracking-wide"
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" aria-hidden />
|
||||
{submitLabel}
|
||||
</button>
|
||||
<div className="rounded-2xl border border-[#EDE6D9] bg-[#FFFCF7]/80 p-4 space-y-4">
|
||||
<p className="text-xs text-[#6B665F]">{labels.scheduleOptionalHint}</p>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.preferredDateLabel}
|
||||
</label>
|
||||
<select
|
||||
value={preferredDate}
|
||||
onChange={(e) => setPreferredDate(e.target.value as InquiryPreferredDate)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">{labels.preferredDatePlaceholder}</option>
|
||||
<option value="today">{labels.dateToday}</option>
|
||||
<option value="tomorrow">{labels.dateTomorrow}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.preferredTimeLabel}
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={preferredTime}
|
||||
onChange={(e) => setPreferredTime(e.target.value)}
|
||||
min={preferredDate === 'today' ? minTimeToday : undefined}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errors.schedule && (
|
||||
<p className="text-xs text-red-600" role="alert">{errors.schedule}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-[#EDE6D9] bg-[#FFFCF7]/95 px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] backdrop-blur-sm sm:px-6">
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex min-h-[52px] flex-1 items-center justify-center rounded-full border border-[#EDE6D9] py-3.5 text-sm font-medium text-[#6B665F] hover:bg-[#F8F5F0] touch-manipulation active:scale-[0.985]"
|
||||
>
|
||||
{labels.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitDisabled}
|
||||
className="btn-primary flex min-h-[52px] flex-1 items-center justify-center gap-2 rounded-full py-3.5 text-sm font-medium tracking-wide touch-manipulation active:scale-[0.985] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<MessageCircle className="h-4 w-4 shrink-0" aria-hidden />
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/lib/catering-data';
|
||||
import { buildCateringPackagePricingMessage } from '@/application/messaging/whatsapp-message-builder';
|
||||
import { container } from '@/infrastructure/di/container';
|
||||
import { getWhatsAppInquiryTranslation } from '@/application/messaging/inquiry-language';
|
||||
import { useLanguage } from '@/lib/language-context';
|
||||
import { getTranslation } from '@/lib/translations';
|
||||
import { MessageCircle, Users, UtensilsCrossed } from 'lucide-react';
|
||||
@@ -29,9 +30,12 @@ export default function CateringPackageCard({ pkg, index = 0 }: CateringPackageC
|
||||
(t.catering.packageDescriptions as Record<string, string>)?.[pkg.id] ?? pkg.description;
|
||||
|
||||
const handlePricingInquiry = () => {
|
||||
const template = (t.catering as { packagePricingInquiry?: string }).packagePricingInquiry;
|
||||
const inquiryT = getWhatsAppInquiryTranslation(language);
|
||||
const template = (inquiryT.catering as { packagePricingInquiry?: string }).packagePricingInquiry;
|
||||
if (!template) return;
|
||||
const message = buildCateringPackagePricingMessage(packageName, template);
|
||||
const inquiryPackageName =
|
||||
(inquiryT.catering.packages as Record<string, string>)?.[pkg.id] ?? pkg.name;
|
||||
const message = buildCateringPackagePricingMessage(inquiryPackageName, template);
|
||||
container.messagingGateway.openWhatsApp(message);
|
||||
};
|
||||
|
||||
@@ -67,7 +71,7 @@ export default function CateringPackageCard({ pkg, index = 0 }: CateringPackageC
|
||||
<div className="flex flex-1 flex-col p-6 sm:p-7">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-[22px] leading-tight tracking-[-0.4px] text-[#101724]">
|
||||
<h3 className="text-lg sm:text-[22px] leading-tight tracking-[-0.4px] text-[#101724] break-words">
|
||||
{packageName}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[#6B665F]">
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { MapPin } from 'lucide-react';
|
||||
import {
|
||||
fetchGeoapifyAddressSuggestions,
|
||||
isDeliverableGeoapifyFeature,
|
||||
shouldUseGeoapifyAutocomplete,
|
||||
} from '@/infrastructure/geocoding/geoapify-autocomplete';
|
||||
import type { GeoapifyFeature } from '@/infrastructure/geocoding/geoapify-types';
|
||||
|
||||
export interface DeliveryAddressAutocompleteLabels {
|
||||
placeholder: string;
|
||||
hint: string;
|
||||
fallbackPlaceholder: string;
|
||||
fallbackHint: string;
|
||||
searching: string;
|
||||
noResults: string;
|
||||
outsideGothenburg: string;
|
||||
selectSuggestion: string;
|
||||
}
|
||||
|
||||
interface DeliveryAddressAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onValidationChange?: (isValid: boolean) => void;
|
||||
language: 'sv' | 'en';
|
||||
labels: DeliveryAddressAutocompleteLabels;
|
||||
inputClassName: string;
|
||||
}
|
||||
|
||||
function formatSuggestion(feature: GeoapifyFeature): string {
|
||||
return (
|
||||
feature.properties.formatted ??
|
||||
[feature.properties.address_line1, feature.properties.address_line2]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeliveryAddressAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
onValidationChange,
|
||||
language,
|
||||
labels,
|
||||
inputClassName,
|
||||
}: DeliveryAddressAutocompleteProps) {
|
||||
const listboxId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const fetchGenerationRef = useRef(0);
|
||||
const [query, setQuery] = useState(value);
|
||||
const [suggestions, setSuggestions] = useState<GeoapifyFeature[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedFromList, setSelectedFromList] = useState(false);
|
||||
const [autocompleteActive, setAutocompleteActive] = useState<boolean | null>(null);
|
||||
|
||||
const deactivateAutocomplete = () => {
|
||||
setAutocompleteActive(false);
|
||||
setSuggestions([]);
|
||||
setIsOpen(false);
|
||||
setIsLoading(false);
|
||||
setSelectedFromList(false);
|
||||
setError('');
|
||||
onValidationChange?.(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const active = shouldUseGeoapifyAutocomplete();
|
||||
setAutocompleteActive(active);
|
||||
if (!active) onValidationChange?.(true);
|
||||
}, [onValidationChange]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(value);
|
||||
if (autocompleteActive) {
|
||||
setSelectedFromList(!!value.trim());
|
||||
}
|
||||
setError('');
|
||||
}, [value, autocompleteActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autocompleteActive !== true) return;
|
||||
|
||||
if (!query.trim()) {
|
||||
setSuggestions([]);
|
||||
setError('');
|
||||
setSelectedFromList(false);
|
||||
onValidationChange?.(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedFromList) {
|
||||
onValidationChange?.(!error);
|
||||
return;
|
||||
}
|
||||
|
||||
onValidationChange?.(false);
|
||||
|
||||
const generation = ++fetchGenerationRef.current;
|
||||
|
||||
const handle = window.setTimeout(async () => {
|
||||
const trimmedQuery = query.trim();
|
||||
if (trimmedQuery.length < 3) {
|
||||
if (generation === fetchGenerationRef.current) {
|
||||
setSuggestions([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await fetchGeoapifyAddressSuggestions(trimmedQuery, language);
|
||||
if (generation !== fetchGenerationRef.current) return;
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
deactivateAutocomplete();
|
||||
return;
|
||||
}
|
||||
setSuggestions(result.features);
|
||||
setIsOpen(result.features.length > 0);
|
||||
} finally {
|
||||
if (generation === fetchGenerationRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, 600);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query, language, selectedFromList, error, onValidationChange, autocompleteActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autocompleteActive !== true) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [autocompleteActive]);
|
||||
|
||||
const applySuggestion = (feature: GeoapifyFeature) => {
|
||||
if (!isDeliverableGeoapifyFeature(feature)) {
|
||||
setError(labels.outsideGothenburg);
|
||||
setSelectedFromList(false);
|
||||
onValidationChange?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const formatted = formatSuggestion(feature);
|
||||
setQuery(formatted);
|
||||
onChange(formatted);
|
||||
setSelectedFromList(true);
|
||||
setSuggestions([]);
|
||||
setIsOpen(false);
|
||||
setError('');
|
||||
onValidationChange?.(true);
|
||||
};
|
||||
|
||||
const handleInputChange = (next: string) => {
|
||||
setQuery(next);
|
||||
onChange(next);
|
||||
if (autocompleteActive) {
|
||||
setSelectedFromList(false);
|
||||
setError('');
|
||||
onValidationChange?.(!next.trim());
|
||||
} else {
|
||||
onValidationChange?.(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (autocompleteActive === false) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<MapPin className="pointer-events-none absolute start-4 top-4 z-10 h-5 w-5 text-[#B38B4D]" />
|
||||
<textarea
|
||||
value={query}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
placeholder={labels.fallbackPlaceholder}
|
||||
autoComplete="street-address"
|
||||
rows={3}
|
||||
className={`${inputClassName} min-h-[5.5rem] resize-y ps-12`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-[#6B665F]">{labels.fallbackHint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<div className="relative">
|
||||
<MapPin className="pointer-events-none absolute start-4 top-4 z-10 h-5 w-5 text-[#B38B4D]" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (suggestions.length > 0) setIsOpen(true);
|
||||
}}
|
||||
placeholder={labels.placeholder}
|
||||
autoComplete="street-address"
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={listboxId}
|
||||
className={`${inputClassName} ps-12`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-1.5 text-xs text-[#6B665F]">{labels.hint}</p>
|
||||
|
||||
{isLoading && (
|
||||
<p className="mt-1 text-xs text-[#8A8478]">{labels.searching}</p>
|
||||
)}
|
||||
|
||||
{isOpen && suggestions.length > 0 && (
|
||||
<ul
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-xl border border-[#EDE6D9] bg-white py-1 shadow-lg"
|
||||
>
|
||||
{suggestions.map((feature, index) => {
|
||||
const label = formatSuggestion(feature);
|
||||
const secondary = [feature.properties.postcode, feature.properties.city]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
return (
|
||||
<li key={`${label}-${index}`} role="option">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onTouchStart={(e) => e.preventDefault()}
|
||||
onClick={() => applySuggestion(feature)}
|
||||
className="flex w-full flex-col px-4 py-3.5 text-left hover:bg-[#F8F5F0] active:bg-[#EDE6D9] min-h-[48px] touch-manipulation"
|
||||
>
|
||||
<span className="truncate text-sm font-medium text-[#101724]" title={label}>{label}</span>
|
||||
{secondary && (
|
||||
<span className="text-xs text-[#6B665F]">{secondary}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!isLoading && query.trim().length >= 3 && suggestions.length === 0 && !selectedFromList && (
|
||||
<p className="mt-1 text-xs text-[#8A8478]">{labels.noResults}</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!selectedFromList && query.trim().length > 0 && !error && (
|
||||
<p className="mt-1 text-xs text-[#8A8478]">{labels.selectSuggestion}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import { getHeaderTotalHeight } from '@/components/LanguageSwitcher';
|
||||
import { useCustomerAuth } from '@/presentation/providers/customer-auth-provider';
|
||||
|
||||
export default function DynamicToaster() {
|
||||
const { isAuthenticated, isMenuManager, isLoading } = useCustomerAuth();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setIsMobile(window.innerWidth < 640);
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
const headerHeight = isLoading
|
||||
? getHeaderTotalHeight(true, isMobile, true)
|
||||
: getHeaderTotalHeight(isAuthenticated, isMobile, isMenuManager);
|
||||
|
||||
return (
|
||||
<Toaster
|
||||
position="top-center"
|
||||
richColors
|
||||
closeButton
|
||||
offset={headerHeight + 8}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ function FooterHeading({ children }: { children: ReactNode }) {
|
||||
export default function Footer() {
|
||||
const { language } = useLanguage();
|
||||
const t = getTranslation(language);
|
||||
const copy = FOOTER_COPY[language] ?? FOOTER_COPY.en;
|
||||
const copy = FOOTER_COPY[language] ?? FOOTER_COPY.sv;
|
||||
|
||||
return (
|
||||
<footer id="contact" className="scroll-mt-header shahi-footer relative mt-auto overflow-hidden">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getHeaderTotalHeight } from '@/components/LanguageSwitcher';
|
||||
import { useCustomerAuth } from '@/presentation/providers/customer-auth-provider';
|
||||
|
||||
/** Keeps `--header-height` in sync with the dynamic language bar + navbar. */
|
||||
export default function HeaderHeightSync() {
|
||||
const { isAuthenticated, isMenuManager, isLoading } = useCustomerAuth();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setIsMobile(window.innerWidth < 640);
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const height = isLoading
|
||||
? getHeaderTotalHeight(true, isMobile, true)
|
||||
: getHeaderTotalHeight(isAuthenticated, isMobile, isMenuManager);
|
||||
|
||||
document.documentElement.style.setProperty('--header-height', `${height}px`);
|
||||
document.documentElement.dataset.auth = isAuthenticated ? 'true' : 'false';
|
||||
}, [isAuthenticated, isMenuManager, isLoading, isMobile]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,12 +1,30 @@
|
||||
import { HEADER_TOTAL_HEIGHT } from '@/components/LanguageSwitcher';
|
||||
'use client';
|
||||
|
||||
/** Reserves vertical space for the fixed language bar + navbar (116px). */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getHeaderTotalHeight } from '@/components/LanguageSwitcher';
|
||||
import { useCustomerAuth } from '@/presentation/providers/customer-auth-provider';
|
||||
|
||||
/** Reserves vertical space for the fixed language bar + navbar. */
|
||||
export default function HeaderSpacer() {
|
||||
const { isAuthenticated, isMenuManager, isLoading } = useCustomerAuth();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setIsMobile(window.innerWidth < 640);
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
const height = isLoading
|
||||
? getHeaderTotalHeight(true, isMobile, true)
|
||||
: getHeaderTotalHeight(isAuthenticated, isMobile, isMenuManager);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="shrink-0"
|
||||
style={{ height: HEADER_TOTAL_HEIGHT }}
|
||||
className="shrink-0 transition-[height] duration-200"
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+171
-11
@@ -1,37 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useLanguage } from '@/lib/language-context';
|
||||
import { languages, Language } from '@/lib/translations';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { languages, Language, getTranslation } from '@/lib/translations';
|
||||
import { useCustomerAuth } from '@/presentation/providers/customer-auth-provider';
|
||||
import { Globe, LogIn, LogOut, Sparkles, UtensilsCrossed } from 'lucide-react';
|
||||
|
||||
export const LANGUAGE_BANNER_HEIGHT = 48;
|
||||
export const LANGUAGE_BANNER_HEIGHT_LOGGED_IN_DESKTOP = 56;
|
||||
export const LANGUAGE_BANNER_HEIGHT_LOGGED_IN_MOBILE = 160;
|
||||
export const LANGUAGE_BANNER_HEIGHT_MENU_MANAGER_MOBILE = 160;
|
||||
export const NAVBAR_HEIGHT = 68;
|
||||
|
||||
export function getLanguageBannerHeight(
|
||||
isAuthenticated: boolean,
|
||||
isMobile = false,
|
||||
isMenuManager = false,
|
||||
): number {
|
||||
if (!isAuthenticated) return LANGUAGE_BANNER_HEIGHT;
|
||||
if (isMobile) {
|
||||
return isMenuManager
|
||||
? LANGUAGE_BANNER_HEIGHT_MENU_MANAGER_MOBILE
|
||||
: LANGUAGE_BANNER_HEIGHT_LOGGED_IN_MOBILE;
|
||||
}
|
||||
return LANGUAGE_BANNER_HEIGHT_LOGGED_IN_DESKTOP;
|
||||
}
|
||||
|
||||
export function getHeaderTotalHeight(
|
||||
isAuthenticated: boolean,
|
||||
isMobile = false,
|
||||
isMenuManager = false,
|
||||
): number {
|
||||
return getLanguageBannerHeight(isAuthenticated, isMobile, isMenuManager) + NAVBAR_HEIGHT;
|
||||
}
|
||||
|
||||
/** @deprecated Use getHeaderTotalHeight() for dynamic height. */
|
||||
export const HEADER_TOTAL_HEIGHT = LANGUAGE_BANNER_HEIGHT + NAVBAR_HEIGHT;
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
const { language, setLanguage } = useLanguage();
|
||||
const t = getTranslation(language);
|
||||
const pathname = usePathname();
|
||||
const { email, isAuthenticated, isMenuManager, logout } = useCustomerAuth();
|
||||
const isLoginActive = !isAuthenticated && pathname === '/login';
|
||||
const isAdminActive = pathname === '/admin';
|
||||
|
||||
const emailInitial = email?.charAt(0).toUpperCase() ?? '?';
|
||||
|
||||
const handleSelect = (lang: Language) => {
|
||||
setLanguage(lang);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
const loginButton = (
|
||||
<Link
|
||||
href="/login"
|
||||
aria-current={isLoginActive ? 'page' : undefined}
|
||||
className={`group relative inline-flex shrink-0 items-center gap-1.5 overflow-hidden rounded-full px-3.5 py-2 text-[11px] font-bold uppercase tracking-[0.1em] transition-all duration-300 active:scale-[0.96] touch-manipulation min-h-[36px] sm:min-h-[38px] sm:px-4 ${
|
||||
isLoginActive
|
||||
? 'bg-white text-[#0f5a4a] shadow-lg shadow-black/25 ring-2 ring-[#f4d47f]/60'
|
||||
: 'border border-[#f4d47f]/45 bg-gradient-to-br from-[#c99a2e] via-[#e8c56a] to-[#b8892f] text-[#1a1206] shadow-md shadow-black/30 hover:scale-[1.03] hover:shadow-lg hover:shadow-[#c99a2e]/40'
|
||||
}`}
|
||||
>
|
||||
<span className="relative flex h-5 w-5 items-center justify-center rounded-full bg-[#1a1206]/10">
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
<Sparkles className="absolute -right-0.5 -top-0.5 h-2 w-2 text-[#fff8e7] opacity-80" />
|
||||
</span>
|
||||
<span className="whitespace-nowrap max-sm:hidden">{t.nav.login}</span>
|
||||
<span className="whitespace-nowrap sm:hidden">Login</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const menuManagementLink = (variant: 'desktop' | 'mobile' | 'mobile-full' = 'desktop') => (
|
||||
<Link
|
||||
href="/admin"
|
||||
aria-current={isAdminActive ? 'page' : undefined}
|
||||
className={`group relative inline-flex shrink-0 items-center justify-center gap-1.5 overflow-hidden rounded-full font-bold transition-all duration-300 active:scale-[0.96] touch-manipulation ${
|
||||
variant === 'mobile-full'
|
||||
? 'flex-1 min-h-[44px] border border-[#f4d47f]/60 bg-gradient-to-br from-[#c99a2e] via-[#e8c56a] to-[#b8892f] px-3 py-2.5 text-[11px] text-[#1a1206] shadow-md'
|
||||
: variant === 'mobile'
|
||||
? 'min-h-[40px] border border-[#f4d47f]/60 bg-gradient-to-br from-[#c99a2e] to-[#d4a73d] px-3 py-2 text-[10px] text-[#1a1206]'
|
||||
: 'min-h-[38px] border border-[#f4d47f]/50 bg-gradient-to-br from-[#c99a2e] via-[#e8c56a] to-[#b8892f] px-3 py-1.5 text-[10px] text-[#1a1206] shadow-md hover:scale-[1.03] sm:px-3.5 sm:text-[11px]'
|
||||
} ${isAdminActive ? 'ring-2 ring-white/70' : ''}`}
|
||||
>
|
||||
<UtensilsCrossed className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="whitespace-nowrap uppercase tracking-[0.06em]">
|
||||
{variant === 'mobile' ? 'Menu' : t.auth.customer.menuManagement}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const logoutButton = (variant: 'desktop' | 'mobile' | 'mobile-full' = 'desktop') => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLogout()}
|
||||
aria-label={t.auth.customer.logout}
|
||||
className={`group relative inline-flex shrink-0 items-center justify-center gap-1.5 overflow-hidden rounded-full border border-white/30 bg-white/10 font-bold text-white shadow-md transition-all duration-300 hover:bg-white/20 active:scale-[0.96] touch-manipulation ${
|
||||
variant === 'mobile-full'
|
||||
? 'flex-1 min-h-[44px] px-3 py-2.5 text-[11px]'
|
||||
: variant === 'mobile'
|
||||
? 'min-h-[40px] min-w-[40px] px-3 py-2'
|
||||
: 'min-h-[38px] px-3 py-1.5 text-[10px] sm:px-3.5 sm:text-[11px]'
|
||||
}`}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5 shrink-0" />
|
||||
{variant !== 'mobile' && (
|
||||
<span className="whitespace-nowrap uppercase tracking-[0.06em]">
|
||||
{t.auth.customer.logout}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lang-banner w-full border-b border-[#c99a2e]/25 bg-gradient-to-r from-[#0a4a3d] via-[#0f5a4a] to-[#0a4a3d]"
|
||||
style={{ height: LANGUAGE_BANNER_HEIGHT }}
|
||||
className={`lang-banner w-full overflow-visible border-b border-[#c99a2e]/25 bg-gradient-to-r from-[#0a4a3d] via-[#0f5a4a] to-[#0a4a3d] ${
|
||||
!isAuthenticated
|
||||
? 'h-12'
|
||||
: 'sm:h-14'
|
||||
}`}
|
||||
role="navigation"
|
||||
aria-label="Language selection"
|
||||
>
|
||||
<div className="mx-auto flex h-full max-w-7xl items-center gap-2 px-3 sm:px-6">
|
||||
<div className="hidden shrink-0 items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-[#d4a73d]/90 sm:flex">
|
||||
{/* ── Row 1: languages (+ desktop auth inline) ── */}
|
||||
<div
|
||||
className={`mx-auto flex max-w-7xl items-center gap-2 px-3 sm:px-6 ${
|
||||
isAuthenticated ? 'h-12 sm:h-14' : 'h-12'
|
||||
}`}
|
||||
>
|
||||
<div className="hidden shrink-0 items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-[#d4a73d]/90 lg:flex">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
<span>Language</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center gap-1 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:gap-1.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:gap-1.5">
|
||||
{languages.map((lang) => {
|
||||
const isActive = language === lang.code;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={lang.code}
|
||||
@@ -39,21 +147,73 @@ export default function LanguageSwitcher() {
|
||||
onClick={() => handleSelect(lang.code)}
|
||||
aria-pressed={isActive}
|
||||
aria-label={`${lang.name} (${lang.native})`}
|
||||
className={`flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-semibold transition-all sm:gap-1.5 sm:px-3 sm:py-1.5 sm:text-xs ${
|
||||
className={`flex shrink-0 items-center gap-1 rounded-full px-2.5 py-2 text-[11px] font-semibold transition-all min-h-[34px] touch-manipulation sm:min-h-[36px] sm:gap-1.5 sm:px-3 sm:py-1.5 sm:text-xs ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-[#c99a2e] to-[#e8c56a] text-[#1a1206] shadow-lg shadow-[#c99a2e]/35 ring-1 ring-[#f4d47f]/50'
|
||||
: 'text-white/80 hover:bg-white/12 hover:text-white'
|
||||
: 'text-white/80 hover:bg-white/12 hover:text-white active:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm sm:text-base" aria-hidden="true">
|
||||
{lang.flag}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{lang.native}</span>
|
||||
<span className="whitespace-nowrap max-[400px]:hidden min-[401px]:inline">
|
||||
{lang.native}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop: guest login OR logged-in account strip (single row, no clipping) */}
|
||||
<div className="hidden shrink-0 sm:flex sm:items-center">
|
||||
{!isAuthenticated ? (
|
||||
loginButton
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5 border-s border-white/15 ps-3">
|
||||
<div className="hidden min-w-0 max-w-[150px] md:block">
|
||||
<p className="text-[8px] font-medium uppercase tracking-[0.14em] text-[#d4a73d]/90 leading-none">
|
||||
{t.auth.customer.loggedInAs}
|
||||
</p>
|
||||
<p className="truncate text-[11px] font-semibold text-white leading-tight mt-0.5" title={email ?? ''}>
|
||||
{email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isMenuManager && menuManagementLink('desktop')}
|
||||
{logoutButton('desktop')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile guest: login icon only */}
|
||||
{!isAuthenticated && <div className="shrink-0 sm:hidden">{loginButton}</div>}
|
||||
</div>
|
||||
|
||||
{/* ── Mobile logged-in: creative account card ── */}
|
||||
{isAuthenticated && (
|
||||
<div className="px-3 pb-2.5 sm:hidden">
|
||||
<div className="rounded-2xl border border-[#f4d47f]/20 bg-gradient-to-br from-white/[0.08] to-white/[0.03] p-2.5 shadow-inner backdrop-blur-sm">
|
||||
<div className="mb-2.5 flex items-center gap-2.5">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-[#c99a2e] to-[#e8c56a] text-sm font-bold text-[#1a1206] shadow-md ring-2 ring-white/20">
|
||||
{emailInitial}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[9px] font-semibold uppercase tracking-[0.14em] text-[#d4a73d]/90">
|
||||
{t.auth.customer.loggedInAs}
|
||||
</p>
|
||||
<p className="truncate text-sm font-semibold text-white" title={email ?? ''}>
|
||||
{email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isMenuManager && menuManagementLink('mobile-full')}
|
||||
{logoutButton('mobile-full')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useCart } from "./CartContext";
|
||||
import { useWishlist } from "./WishlistContext";
|
||||
import { ShoppingBag, Heart, ArrowRight, Home, UtensilsCrossed, MapPin, Star, Phone, X, LogIn, ChefHat } from "lucide-react";
|
||||
import { ShoppingBag, Heart, ArrowRight, Home, UtensilsCrossed, MapPin, Star, Phone, X, ChefHat } from "lucide-react";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import HeaderSpacer from "./HeaderSpacer";
|
||||
import { logoUrl } from "@/lib/assets";
|
||||
@@ -44,7 +44,6 @@ export default function Navbar({ variant = "default" }: NavbarProps) {
|
||||
{ href: "/menu", label: t.nav.menu },
|
||||
{ href: "/catering", label: t.nav.catering },
|
||||
{ href: "/locations", label: t.nav.locations },
|
||||
{ href: "/login", label: t.nav.login },
|
||||
{ href: "/#experience", label: t.nav.experience },
|
||||
{ href: "/#contact", label: t.nav.contact },
|
||||
];
|
||||
@@ -232,7 +231,7 @@ export default function Navbar({ variant = "default" }: NavbarProps) {
|
||||
|
||||
{/* Sliding Panel - modern, full-bleed on small phones, elegant on larger */}
|
||||
<motion.div
|
||||
className={`absolute top-0 bottom-0 w-[82%] max-w-[340px] bg-[#fbf7ef] shadow-2xl flex flex-col overflow-y-auto z-[10000] ${
|
||||
className={`absolute top-0 bottom-0 w-[82%] max-w-[340px] bg-[#fbf7ef] shadow-2xl flex flex-col overflow-y-auto z-[10000] pt-[env(safe-area-inset-top)] ${
|
||||
isRtl
|
||||
? 'left-0 border-r border-[#c99a2e]/10'
|
||||
: 'right-0 border-l border-[#c99a2e]/10'
|
||||
@@ -257,7 +256,7 @@ export default function Navbar({ variant = "default" }: NavbarProps) {
|
||||
</div>
|
||||
<button
|
||||
onClick={closeMenu}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/70 active:bg-[#EDE6D9] text-[#101724] transition-colors"
|
||||
className="min-h-11 min-w-11 flex items-center justify-center rounded-full bg-white/70 active:bg-[#EDE6D9] text-[#101724] transition-colors touch-manipulation"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
@@ -273,7 +272,6 @@ export default function Navbar({ variant = "default" }: NavbarProps) {
|
||||
link.href === '/menu' ? UtensilsCrossed :
|
||||
link.href === '/catering' ? ChefHat :
|
||||
link.href === '/locations' ? MapPin :
|
||||
link.href === '/login' ? LogIn :
|
||||
link.href.includes('experience') ? Star : Phone;
|
||||
|
||||
return (
|
||||
@@ -281,14 +279,14 @@ export default function Navbar({ variant = "default" }: NavbarProps) {
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={closeMenu}
|
||||
className={`flex items-center gap-4 px-4 py-3.5 mx-1 my-0.5 rounded-2xl text-[17px] font-medium transition-all active:scale-[0.985] ${
|
||||
className={`flex items-center gap-4 px-4 py-3.5 mx-1 my-0.5 rounded-2xl text-[17px] font-medium transition-all active:scale-[0.985] min-h-[48px] touch-manipulation ${
|
||||
active
|
||||
? 'bg-[#101724] text-white shadow-sm'
|
||||
: 'text-[#101724] hover:bg-white active:bg-[#EDE6D9]'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 flex-shrink-0 ${active ? 'text-[#c99a2e]' : 'text-[#B38B4D]'}`} />
|
||||
<span className="whitespace-nowrap">{link.label}</span>
|
||||
<span className="min-w-0 truncate">{link.label}</span>
|
||||
{active && (
|
||||
<span className="ml-auto text-xs tracking-widest opacity-70">CURRENT</span>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useCart } from '@/presentation/providers/cart-provider';
|
||||
import { useLanguage } from '@/presentation/providers/language-provider';
|
||||
import { getTranslation } from '@/presentation/i18n/translations';
|
||||
import { getMenuPosterSrc, applyNextImageFallback, getMenuPosterCandidates } from '@/lib/assets';
|
||||
import { getMenuItemById } from '@/lib/menu-data';
|
||||
import { useMenu } from '@/presentation/providers/menu-provider';
|
||||
import { getMenuItemName } from '@/application/i18n/menu-localization';
|
||||
import { buildCartLineFromMenuItem } from '@/application/cart/cart-line-builder';
|
||||
|
||||
@@ -21,13 +21,14 @@ export default function WishlistDrawer() {
|
||||
} = useWishlist();
|
||||
|
||||
const { addToCart } = useCart();
|
||||
const { getItemById } = useMenu();
|
||||
const { language, isRtl } = useLanguage();
|
||||
const t = getTranslation(language);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAddToCart = (item: (typeof items)[number]) => {
|
||||
const menuItem = getMenuItemById(item.id);
|
||||
const menuItem = getItemById(item.id);
|
||||
addToCart(menuItem ? buildCartLineFromMenuItem(menuItem) : {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
@@ -94,7 +95,7 @@ export default function WishlistDrawer() {
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{items.map((item) => {
|
||||
const menuItem = getMenuItemById(item.id);
|
||||
const menuItem = getItemById(item.id);
|
||||
const displayName = getMenuItemName(language, {
|
||||
id: item.id,
|
||||
name: menuItem?.name ?? item.name,
|
||||
@@ -128,7 +129,7 @@ export default function WishlistDrawer() {
|
||||
</h4>
|
||||
<span className="shrink-0 text-right font-medium text-[#B38B4D] tabular-nums text-sm leading-tight">
|
||||
{(() => {
|
||||
const menuItem = getMenuItemById(item.id);
|
||||
const menuItem = getItemById(item.id);
|
||||
if (menuItem?.pricing === 'weight') {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
BookmarkPlus,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
History,
|
||||
Lock,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import type { MenuCategory } from '@/domain/menu/entities';
|
||||
import type { MenuVersionListItem } from '@/domain/menu/versioning';
|
||||
|
||||
interface MenuVersionTimelineProps {
|
||||
onMenuRestored: (categories: MenuCategory[]) => void;
|
||||
onNotifyMenuUpdated: () => void;
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
function formatVersionDate(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return date.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function triggerBadge(trigger: MenuVersionListItem['trigger']): string {
|
||||
if (trigger === 'manual') return 'Checkpoint';
|
||||
if (trigger === 'restore') return 'Pre-rollback';
|
||||
return 'Auto-save';
|
||||
}
|
||||
|
||||
export default function MenuVersionTimeline({
|
||||
onMenuRestored,
|
||||
onNotifyMenuUpdated,
|
||||
refreshTrigger = 0,
|
||||
}: MenuVersionTimelineProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [versions, setVersions] = useState<MenuVersionListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [checkpointLabel, setCheckpointLabel] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
const response = await fetch('/api/admin/menu/versions', { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Could not load version history');
|
||||
const data = (await response.json()) as { versions: MenuVersionListItem[] };
|
||||
setVersions(data.versions);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadVersions()
|
||||
.catch(() => setStatus('Version history unavailable.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [loadVersions, refreshTrigger]);
|
||||
|
||||
const handleRestore = async (version: MenuVersionListItem) => {
|
||||
if (version.matchesLive) {
|
||||
setStatus('This version is already live.');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Restore "${version.label}"?\n\nThe live menu will switch to this snapshot. Later versions stay in history — nothing is deleted.`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setBusyId(version.id);
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/menu/versions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'restore', versionId: version.id }),
|
||||
});
|
||||
const data = (await response.json()) as {
|
||||
categories?: MenuCategory[];
|
||||
versions?: MenuVersionListItem[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !data.categories) {
|
||||
throw new Error(data.error ?? 'Restore failed');
|
||||
}
|
||||
|
||||
setVersions(data.versions ?? []);
|
||||
onMenuRestored(data.categories);
|
||||
onNotifyMenuUpdated();
|
||||
setStatus(`Live menu restored to "${version.label}".`);
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'Restore failed');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (version: MenuVersionListItem) => {
|
||||
if (version.isBaseline) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Delete snapshot "${version.label}" from history?\n\nThis only removes the saved copy — it does not change the live menu.`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setBusyId(version.id);
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/menu/versions/${version.id}`, { method: 'DELETE' });
|
||||
const data = (await response.json()) as { versions?: MenuVersionListItem[]; error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error ?? 'Delete failed');
|
||||
}
|
||||
|
||||
setVersions(data.versions ?? []);
|
||||
setStatus('Snapshot removed from history.');
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'Delete failed');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckpoint = async () => {
|
||||
setBusyId('checkpoint');
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/menu/versions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label: checkpointLabel.trim() || 'Manual checkpoint' }),
|
||||
});
|
||||
const data = (await response.json()) as {
|
||||
versions?: MenuVersionListItem[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error ?? 'Could not save checkpoint');
|
||||
}
|
||||
|
||||
setVersions(data.versions ?? []);
|
||||
setCheckpointLabel('');
|
||||
setStatus('Checkpoint saved.');
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'Could not save checkpoint');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const liveVersion = versions.find((version) => version.matchesLive);
|
||||
const historyCount = versions.filter((version) => !version.isBaseline).length;
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-[#EDE6D9] bg-white overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className="flex w-full min-h-[56px] items-center justify-between gap-3 px-4 py-4 text-left transition hover:bg-[#FFFCF7] active:bg-[#FFFCF7] touch-manipulation sm:gap-4 sm:px-5"
|
||||
>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-[#FFF6DC] to-[#F8F5F0] border border-[#EDE6D9]">
|
||||
<History className="h-5 w-5 text-[#8f6b22]" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="font-serif text-base text-[#101724] sm:text-lg">Menu time machine</h2>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[#101724] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span className="sm:hidden">History</span>
|
||||
<span className="hidden sm:inline">Version history</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-relaxed text-[#6B665F] sm:text-sm">
|
||||
Every change is saved. Roll back anytime — later snapshots are kept unless you remove them.
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] font-medium text-[#8A8478] sm:hidden">
|
||||
{historyCount} snapshot{historyCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
{liveVersion && (
|
||||
<p className="mt-2 text-xs font-medium text-[#8f6b22] line-clamp-2 sm:truncate">
|
||||
Live now: {liveVersion.label}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-[#6B665F]">
|
||||
<span className="hidden text-xs font-medium sm:inline">
|
||||
{historyCount} snapshot{historyCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
{expanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-[#EDE6D9] bg-[#FFFCF7]/60 px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={checkpointLabel}
|
||||
onChange={(e) => setCheckpointLabel(e.target.value)}
|
||||
placeholder="Name this checkpoint (optional)…"
|
||||
className="min-h-[48px] flex-1 rounded-xl border border-[#EDE6D9] bg-white px-3 py-2.5 text-base text-[#2C2A26] placeholder:text-[#8A8478] focus:border-[#c99a2e] focus:ring-2 focus:ring-[#c99a2e]/20 sm:text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void handleCheckpoint();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCheckpoint()}
|
||||
disabled={busyId !== null}
|
||||
className="inline-flex min-h-[48px] w-full items-center justify-center gap-2 rounded-xl bg-[#101724] px-4 py-2.5 text-sm font-semibold text-white touch-manipulation active:scale-[0.985] disabled:opacity-60 sm:w-auto"
|
||||
>
|
||||
<BookmarkPlus className="h-4 w-4" />
|
||||
Save checkpoint
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status && <p className="mb-3 text-sm text-[#6B665F]">{status}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-[#8A8478] py-6 text-center">Loading version history…</p>
|
||||
) : versions.length === 0 ? (
|
||||
<p className="text-sm text-[#8A8478] py-6 text-center">No versions yet.</p>
|
||||
) : (
|
||||
<div className="relative max-h-[min(58dvh,420px)] overflow-y-auto overscroll-y-contain pr-1 sm:max-h-[min(52dvh,420px)]">
|
||||
<div className="absolute start-[1.125rem] top-3 bottom-3 w-px bg-gradient-to-b from-[#c99a2e]/50 via-[#EDE6D9] to-[#c99a2e]/30" />
|
||||
|
||||
<ul className="space-y-3">
|
||||
{versions.map((version) => {
|
||||
const isBusy = busyId === version.id;
|
||||
const isLive = version.matchesLive;
|
||||
|
||||
return (
|
||||
<li key={version.id} className="relative ps-10">
|
||||
<span
|
||||
className={`absolute start-2.5 top-4 h-4 w-4 rounded-full border-2 ${
|
||||
version.isBaseline
|
||||
? 'border-[#c99a2e] bg-[#FFF6DC]'
|
||||
: isLive
|
||||
? 'border-[#101724] bg-[#101724]'
|
||||
: 'border-[#B38B4D] bg-white'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-3 sm:p-4 transition ${
|
||||
version.isBaseline
|
||||
? 'border-[#c99a2e]/40 bg-gradient-to-r from-[#FFF6DC]/80 to-white'
|
||||
: isLive
|
||||
? 'border-[#101724]/20 bg-white shadow-sm ring-1 ring-[#101724]/10'
|
||||
: 'border-[#EDE6D9] bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-medium text-sm text-[#101724] break-words sm:truncate">
|
||||
{version.label}
|
||||
</h3>
|
||||
{version.isBaseline && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-[#c99a2e]/40 bg-[#FFF6DC] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-[#8f6b22]">
|
||||
<Lock className="h-3 w-3" />
|
||||
Baseline
|
||||
</span>
|
||||
)}
|
||||
{isLive && (
|
||||
<span className="inline-flex items-center rounded-full bg-[#101724] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white">
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-full bg-[#F8F5F0] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[#6B665F]">
|
||||
{triggerBadge(version.trigger)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[#8A8478]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatVersionDate(version.createdAt)}
|
||||
</span>
|
||||
<span>
|
||||
{version.categoryCount} categor{version.categoryCount === 1 ? 'y' : 'ies'}
|
||||
{' · '}
|
||||
{version.dishCount} dish{version.dishCount === 1 ? '' : 'es'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full shrink-0 flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
{!isLive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRestore(version)}
|
||||
disabled={busyId !== null}
|
||||
className="inline-flex min-h-[48px] flex-1 items-center justify-center gap-1.5 rounded-xl border border-[#B38B4D]/40 bg-[#FFF6DC] px-3 py-2.5 text-xs font-semibold text-[#8f6b22] hover:bg-[#FFF6DC]/80 touch-manipulation active:scale-[0.985] disabled:opacity-50 sm:min-h-[40px] sm:flex-none sm:rounded-lg"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 shrink-0" />
|
||||
{isBusy ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
)}
|
||||
{!version.isBaseline && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDelete(version)}
|
||||
disabled={busyId !== null}
|
||||
className="inline-flex min-h-[48px] flex-1 items-center justify-center gap-1.5 rounded-xl border border-red-200 bg-red-50 px-3 py-2.5 text-xs font-semibold text-red-700 hover:bg-red-100 touch-manipulation active:scale-[0.985] disabled:opacity-50 sm:min-h-[40px] sm:flex-none sm:rounded-lg"
|
||||
title="Remove from history"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 shrink-0" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{version.isBaseline && (
|
||||
<p className="mt-2 text-xs text-[#8f6b22]/90">
|
||||
The original menu snapshot — permanently protected. You can always return here.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Award,
|
||||
Baby,
|
||||
Briefcase,
|
||||
Cake,
|
||||
Gem,
|
||||
Gift,
|
||||
GraduationCap,
|
||||
Heart,
|
||||
MoreHorizontal,
|
||||
PartyPopper,
|
||||
Plane,
|
||||
Sparkles,
|
||||
TreePine,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
BOOKING_EVENT_TYPES,
|
||||
type BookingEventType,
|
||||
type EventTypeId,
|
||||
} from '@/domain/booking/event-types';
|
||||
import { getEventTypeLabel } from '@/presentation/i18n/booking-events';
|
||||
import type { Language } from '@/domain/language/entities';
|
||||
|
||||
const ICON_MAP: Record<BookingEventType['icon'], LucideIcon> = {
|
||||
cake: Cake,
|
||||
heart: Heart,
|
||||
gem: Gem,
|
||||
sparkles: Sparkles,
|
||||
baby: Baby,
|
||||
'graduation-cap': GraduationCap,
|
||||
briefcase: Briefcase,
|
||||
users: Users,
|
||||
'party-popper': PartyPopper,
|
||||
plane: Plane,
|
||||
award: Award,
|
||||
gift: Gift,
|
||||
'tree-pine': TreePine,
|
||||
'more-horizontal': MoreHorizontal,
|
||||
};
|
||||
|
||||
interface EventTypePickerProps {
|
||||
language: Language;
|
||||
selected: EventTypeId | '';
|
||||
onSelect: (id: EventTypeId) => void;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export default function EventTypePicker({
|
||||
language,
|
||||
selected,
|
||||
onSelect,
|
||||
title,
|
||||
subtitle,
|
||||
}: EventTypePickerProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h3 className="font-serif text-xl tracking-tight text-[#101724] sm:text-2xl">{title}</h3>
|
||||
<p className="mt-1 mb-5 text-sm text-[#6B665F]">{subtitle}</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 sm:gap-3 lg:grid-cols-5">
|
||||
{BOOKING_EVENT_TYPES.map((event) => {
|
||||
const Icon = ICON_MAP[event.icon];
|
||||
const isActive = selected === event.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(event.id)}
|
||||
className={`group relative min-h-[5.25rem] touch-manipulation overflow-hidden rounded-2xl border p-3 text-left transition-all active:scale-[0.98] sm:min-h-[5.5rem] sm:p-4 ${
|
||||
isActive
|
||||
? 'border-[#c99a2e] bg-gradient-to-br from-[#fff6dc] to-[#fffcf7] shadow-lg shadow-[#c99a2e]/15 ring-2 ring-[#c99a2e]/30'
|
||||
: 'border-[#EDE6D9] bg-[#FFFCF7] hover:border-[#c99a2e]/50 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mb-3 flex h-10 w-10 items-center justify-center rounded-xl transition-colors ${
|
||||
isActive
|
||||
? 'bg-gradient-to-br from-[#c99a2e] to-[#d4a73d] text-[#241806]'
|
||||
: 'bg-[#F8F5F0] text-[#B38B4D] group-hover:bg-[#fff6dc]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<span className="block text-xs sm:text-sm font-semibold leading-snug text-[#101724] line-clamp-2">
|
||||
{getEventTypeLabel(language, event.id)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user