mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Fix chat bot alignment
This commit is contained in:
@@ -188,7 +188,7 @@ export default function ConfirmationPage() {
|
||||
if (!bookingId || !pnr) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Success Header */}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ProgressIndicator } from '@/components/ProgressIndicator';
|
||||
import { PageTransition } from '@/components/PageTransition';
|
||||
|
||||
export default function BookingLayout({
|
||||
children,
|
||||
@@ -31,7 +32,7 @@ export default function BookingLayout({
|
||||
<ProgressIndicator currentStep={currentStep} />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
<PageTransition>{children}</PageTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1024,7 +1024,7 @@ export default function PassengersPage() {
|
||||
|
||||
if (!formInitialized || faydaCompleting) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-lg mx-auto text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-600" />
|
||||
@@ -1043,7 +1043,7 @@ export default function PassengersPage() {
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Passenger details</h1>
|
||||
<h1 className="section-title">Passenger details</h1>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-6">
|
||||
{fields.map((field, index) => {
|
||||
|
||||
@@ -188,10 +188,10 @@ export default function PaymentPage() {
|
||||
|
||||
if (!bookingId && !pnr) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12 flex items-center justify-center">
|
||||
<div className="booking-page flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">Loading payment details...</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading payment details...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -372,10 +372,10 @@ export default function PaymentPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
|
||||
<div className="booking-page pb-28 lg:pb-10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Complete payment</h1>
|
||||
<h1 className="section-title">Complete payment</h1>
|
||||
|
||||
{/* Reservation confirmation banner */}
|
||||
<div className="card bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 mb-4 flex items-start gap-3">
|
||||
|
||||
@@ -524,7 +524,7 @@ export default function ResultsPage() {
|
||||
e.stopPropagation();
|
||||
handleSelect(classModal, isOutbound);
|
||||
}}
|
||||
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]"
|
||||
className="btn-primary w-full mt-3 text-sm active:scale-[0.98]"
|
||||
>
|
||||
<span>
|
||||
{isRoundTrip && isOutbound
|
||||
@@ -739,7 +739,7 @@ export default function ResultsPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Progress Header */}
|
||||
@@ -942,7 +942,7 @@ export default function ResultsPage() {
|
||||
(results?.alternativeInbound || []).length === 0;
|
||||
if (isRoundTripNoResults) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
@@ -964,7 +964,7 @@ export default function ResultsPage() {
|
||||
const hasAlternatives = alternativeOutbound.length > 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
{renderClassModal()}
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
@@ -1003,7 +1003,7 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
@@ -1022,7 +1022,7 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{renderClassModal()}
|
||||
|
||||
@@ -613,10 +613,10 @@ export default function ReviewPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
|
||||
<div className="booking-page pb-28 lg:pb-10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">
|
||||
<h1 className="section-title">
|
||||
{packageName ? `Review your ${packageName} booking` : 'Review your booking'}
|
||||
</h1>
|
||||
|
||||
|
||||
@@ -1079,7 +1079,7 @@ export default function SearchPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg disabled:opacity-50"
|
||||
className="btn-primary w-full text-sm"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
@@ -1219,7 +1219,7 @@ export default function SearchPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all transform hover:-translate-y-0.5 shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
@@ -1482,7 +1482,7 @@ export default function SearchPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||
className="flex items-center justify-center gap-2 px-6 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all transform hover:-translate-y-0.5 shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Armchair, Bed, ChevronLeft, ChevronDown, Train, TrainFront, X } from "l
|
||||
import Image from "next/image";
|
||||
|
||||
import CustomModal from "@/components/CustomModal";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { isChild } from "@/utils/fare-utils";
|
||||
|
||||
const BED_POSITION_SUFFIX: Record<string, string> = { lower: 'L', middle: 'M', upper: 'U' };
|
||||
@@ -2187,11 +2188,16 @@ export default function SeatsPage() {
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading seat map...
|
||||
</p>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-8 w-24 rounded-lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-square rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||
|
||||
@@ -26,19 +26,17 @@
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors;
|
||||
@apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors hover:bg-[rgba(20,113,76,0.1)] dark:hover:bg-[rgba(20,113,76,0.2)];
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background-color: rgba(20, 113, 76, 0.1);
|
||||
|
||||
.booking-page {
|
||||
@apply min-h-screen bg-gray-50 dark:bg-gray-900 py-6 md:py-8;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.btn-ghost:hover {
|
||||
background-color: rgba(20, 113, 76, 0.2);
|
||||
}
|
||||
|
||||
.booking-container {
|
||||
@apply container mx-auto px-4 max-w-6xl;
|
||||
}
|
||||
|
||||
|
||||
.input-field {
|
||||
@apply w-full px-4 py-3 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-[rgb(20_113_76)] focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-all duration-200 text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
@@ -162,7 +160,18 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@@ -188,6 +197,10 @@
|
||||
animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.35s ease-out;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Providers } from './providers';
|
||||
import AppHeader from '@/components/AppHeader';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { LoadingIndicator } from '@/components/LoadingIndicator';
|
||||
import SupportWidget from '@/features/support/SupportWidget';
|
||||
import SupportWidget from '@/features/support/SupportWidgetLazy';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'EDR Passenger Portal - Book your train journey',
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import { Suspense } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
|
||||
import SearchPage from '@/app/booking/search/page';
|
||||
import PackagesSection from '@/components/PackagesSection';
|
||||
|
||||
export default async function Home() {
|
||||
// Below-the-fold on the landing page — code-split out of the initial bundle
|
||||
// needed to render/hydrate the (above-the-fold) search form.
|
||||
const PackagesSection = dynamic(() => import('@/components/PackagesSection'));
|
||||
|
||||
// Skeleton for the search form while stations are prefetched server-side —
|
||||
// keeps first paint on a slow connection from being a blank page, and
|
||||
// approximates the real form's height to avoid layout shift once it streams in.
|
||||
function SearchFormSkeleton() {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto animate-pulse">
|
||||
<div className="rounded-2xl border border-gray-100 dark:border-gray-800 p-6 space-y-4">
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/3" />
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded-xl" />
|
||||
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded-xl" />
|
||||
</div>
|
||||
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded-xl" />
|
||||
<div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-xl w-40" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Isolates the server-side stations prefetch behind its own Suspense boundary
|
||||
// (a real async Server Component, unlike a plain `await` in the page body) so
|
||||
// Next.js can stream: the shell flushes immediately with SearchFormSkeleton,
|
||||
// then the real search form + hydrated stations data stream in once ready —
|
||||
// instead of blocking the whole page's TTFB on that one fetch.
|
||||
async function StationsPrefetch({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = new QueryClient();
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
@@ -18,10 +46,18 @@ export default async function Home() {
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<Suspense>
|
||||
<SearchPage />
|
||||
<PackagesSection />
|
||||
</Suspense>
|
||||
{children}
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense fallback={<SearchFormSkeleton />}>
|
||||
<StationsPrefetch>
|
||||
<SearchPage />
|
||||
<PackagesSection />
|
||||
</StationsPrefetch>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
|
||||
export function LoadingIndicator() {
|
||||
// Minimum time the bar stays visible once shown, so very fast local
|
||||
// transitions don't flash imperceptibly (standard practice for top-loading
|
||||
// progress bars).
|
||||
const MIN_VISIBLE_MS = 300;
|
||||
|
||||
function LoadingIndicatorInner() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const shownAtRef = useRef<number | null>(null);
|
||||
|
||||
// Detects the START of a navigation: a same-origin link click gives
|
||||
// instant feedback on tap, before the RSC fetch even begins. Browser
|
||||
// back/forward (popstate) is covered the same way.
|
||||
useEffect(() => {
|
||||
const handleStart = () => setIsVisible(true);
|
||||
const handleEnd = () => setIsVisible(false);
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (e.defaultPrevented || e.button !== 0) return;
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
|
||||
window.addEventListener('beforeunload', handleStart);
|
||||
window.addEventListener('load', handleEnd);
|
||||
const anchor = (e.target as HTMLElement | null)?.closest('a');
|
||||
if (!anchor) return;
|
||||
if (anchor.target && anchor.target !== '_self') return;
|
||||
if (anchor.hasAttribute('download')) return;
|
||||
|
||||
const href = anchor.getAttribute('href');
|
||||
if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(href, window.location.href);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (url.origin !== window.location.origin) return;
|
||||
if (url.pathname + url.search === window.location.pathname + window.location.search) return;
|
||||
|
||||
shownAtRef.current = Date.now();
|
||||
setIsVisible(true);
|
||||
};
|
||||
|
||||
const handlePopState = () => {
|
||||
shownAtRef.current = Date.now();
|
||||
setIsVisible(true);
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClick, true);
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleStart);
|
||||
window.removeEventListener('load', handleEnd);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Detects the END of a navigation: pathname/search settling to a new value
|
||||
// means the route transition has completed.
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const elapsed = shownAtRef.current ? Date.now() - shownAtRef.current : MIN_VISIBLE_MS;
|
||||
const remaining = Math.max(0, MIN_VISIBLE_MS - elapsed);
|
||||
const timer = setTimeout(() => setIsVisible(false), remaining);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
@@ -28,3 +77,11 @@ export function LoadingIndicator() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingIndicator() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoadingIndicatorInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,6 +259,21 @@ export default function ModernDatePicker({
|
||||
{calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()}
|
||||
{calendarFooter}
|
||||
</div>
|
||||
|
||||
{/* Mobile-only: a large, unmistakable Cancel action in addition to the header
|
||||
X — the mobile view is a full-screen takeover with no backdrop to tap, so
|
||||
this is the fallback way out without having to pick a date. */}
|
||||
{isMobileView && (
|
||||
<div className="flex-shrink-0 border-t border-gray-200 dark:border-gray-700 p-3 bg-white dark:bg-gray-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="w-full py-3 rounded-xl border-2 border-gray-200 dark:border-gray-700 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
// Keying by pathname forces React to remount this wrapper on every route
|
||||
// change, which restarts the CSS animation — a subtle fade/slide-up on each
|
||||
// step of the booking flow, no animation library required.
|
||||
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div key={pathname} className="animate-fade-in-up">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
apps/edr-passenger-web/portal/src/components/Skeleton.tsx
Normal file
10
apps/edr-passenger-web/portal/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Shared pulse-block primitive for loading placeholders — replaces the
|
||||
// hand-rolled `animate-pulse` divs duplicated across PackagesSection,
|
||||
// results/page.tsx, and the home page's search skeleton.
|
||||
export function Skeleton({ className = '' }: SkeletonProps) {
|
||||
return <div className={`animate-pulse rounded-lg bg-gray-200 dark:bg-gray-700 ${className}`} />;
|
||||
}
|
||||
@@ -23,7 +23,11 @@ export function SupportWidget() {
|
||||
useSupportSocket(true);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-[100] flex flex-col items-end gap-3">
|
||||
// Kept below every modal/drawer tier (z-90 through z-[120]) and the mobile sticky
|
||||
// bottom action bars (z-40) used on booking flow pages, so the floating launcher never
|
||||
// paints over dialog content or a page's "Continue" bar — it only sits above ordinary
|
||||
// in-page content.
|
||||
<div className="fixed bottom-6 right-6 z-30 flex flex-col items-end gap-3">
|
||||
{open && <SupportPanel onClose={() => setOpen(false)} />}
|
||||
{!open && (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// Code-split the support widget out of the initial page bundle — it pulls in
|
||||
// socket.io-client and opens a live WebSocket connection on mount (see
|
||||
// useSupportSocket), which otherwise happens on every single page load before
|
||||
// the user ever opens the chat. next/dynamic + ssr:false must be called from a
|
||||
// Client Component (disallowed directly in a Server Component like layout.tsx),
|
||||
// hence this thin wrapper.
|
||||
const SupportWidget = dynamic(() => import('./SupportWidget'), { ssr: false });
|
||||
|
||||
export default SupportWidget;
|
||||
Reference in New Issue
Block a user