mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 16:43:36 +00:00
2 nextjs scaffold with store mode shop and admin session experiment wiring event emission v1 (#17)
* chore: cleaning gitignore * formating and env documentation * feat: context switching of hotel/airline depndent on env var via middleware * fixed alignment and building * wrong file * prods * fixed applying style * better session cookie management * tentative session storage with maybe using airtable * migrated api of ingestion * events and products apge * fixing build * 13 create outline for research paper draft (#18) * updated outline for paper from issue * extra paper sections and some formalization of series data * algorithms and acknowledgements * updated outline for paper from issue * upadted text formating * event unification * refactor tracking to ues callbacks instead of refs * implement a pricing display api with session passing * moved middleware to proxy according to new changes in Nextjs * refactoed kafka ingestion to go via backend not web-db * Refactor docker-compose services to use individual Dockerfiles (#20) * Initial plan * Refactor services into individual Dockerfiles Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com> * Add EXPOSE directives to all Dockerfiles with port documentation Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com> * fixing small bugs and adding exepriments to tracking * added some doc
This commit is contained in:
committed by
GitHub
parent
7ece6e82cb
commit
37b2099ee0
199
web/src/app/admin/experiments/page.tsx
Executable file
199
web/src/app/admin/experiments/page.tsx
Executable file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSession } from '@/hooks/useSession';
|
||||
|
||||
type Experiment = {
|
||||
id: string;
|
||||
status: 'active' | 'stopped';
|
||||
sessionIds: string[];
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export default function ExperimentsAdmin() {
|
||||
const { sessionId, isLoading: sessionLoading } = useSession();
|
||||
const [exps, setExps] = useState<Experiment[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchExps = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/experiments');
|
||||
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setExps(data.experiments || []);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchExps();
|
||||
}, []);
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!sessionId) {
|
||||
setError('no session available');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/experiments/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'start failed');
|
||||
}
|
||||
|
||||
await fetchExps(); // refresh list
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async (expId: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/experiments/stop', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ experimentId: expId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'stop failed');
|
||||
}
|
||||
|
||||
await fetchExps(); // refresh list
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
||||
<p className="text-zinc-600 dark:text-zinc-400">loading session...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 px-6 py-12 dark:bg-black">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
|
||||
Experiments
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
current session: {sessionId || 'none'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={loading || !sessionId}
|
||||
className="rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
|
||||
>
|
||||
{loading ? 'starting...' : 'start experiment'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
experiment id
|
||||
</th>
|
||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
status
|
||||
</th>
|
||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
session count
|
||||
</th>
|
||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
created
|
||||
</th>
|
||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
action
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
{exps.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-6 py-8 text-center text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
no experiments yet
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
exps.map((exp) => (
|
||||
<tr
|
||||
key={exp.id}
|
||||
className="hover:bg-zinc-50 dark:hover:bg-zinc-900"
|
||||
>
|
||||
<td className="px-6 py-4 font-mono text-xs text-zinc-700 dark:text-zinc-300">
|
||||
{exp.id.slice(0, 8)}...
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-1 text-xs font-medium ${
|
||||
exp.status === 'active'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200'
|
||||
: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{exp.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-700 dark:text-zinc-300">
|
||||
{exp.sessionIds.length}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-700 dark:text-zinc-300">
|
||||
{new Date(exp.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{exp.status === 'active' && (
|
||||
<button
|
||||
onClick={() => handleStop(exp.id)}
|
||||
disabled={loading}
|
||||
className="text-sm font-medium text-red-600 hover:text-red-700 disabled:opacity-50 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
stop
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
web/src/app/airline/layout.tsx
Normal file
6
web/src/app/airline/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
import '@/styles/airline.css';
|
||||
|
||||
export default function AirlineLayout({ children }: { children: ReactNode }) {
|
||||
return <div data-mode="airline">{children}</div>;
|
||||
}
|
||||
9
web/src/app/airline/page.tsx
Normal file
9
web/src/app/airline/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import AirlineHero from '@/components/feats/airline/AirlineHero';
|
||||
|
||||
export default function AirlineHome() {
|
||||
return (
|
||||
<main>
|
||||
<AirlineHero />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
74
web/src/app/airline/products/page.tsx
Normal file
74
web/src/app/airline/products/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { Navigation } from '@/components/ui';
|
||||
import AirlineCard from '@/components/feats/airline/AirlineCard';
|
||||
|
||||
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
|
||||
type FareRule = 'flexible' | 'standard' | 'basic';
|
||||
|
||||
interface Flight {
|
||||
id: string;
|
||||
departure: { time: string; airport: string };
|
||||
arrival: { time: string; airport: string };
|
||||
duration: string;
|
||||
stops: number;
|
||||
cabinClass: CabinClass;
|
||||
fareRule: FareRule;
|
||||
refundable: boolean;
|
||||
basePrice: number;
|
||||
}
|
||||
|
||||
const genRandomFlights = (): Flight[] => {
|
||||
const airports = ['JFK', 'LAX', 'ORD', 'ATL', 'DFW', 'SFO', 'SEA', 'MIA'];
|
||||
const cabins: CabinClass[] = ['economy', 'premium', 'business', 'first'];
|
||||
const fareRules: FareRule[] = ['flexible', 'standard', 'basic'];
|
||||
|
||||
return Array.from({ length: 12 }, (_, i) => {
|
||||
const depHour = Math.floor(Math.random() * 24);
|
||||
const arrHour = (depHour + Math.floor(Math.random() * 6) + 2) % 24;
|
||||
const stops = Math.random() > 0.6 ? 0 : Math.floor(Math.random() * 2) + 1;
|
||||
const cabin = cabins[Math.floor(Math.random() * cabins.length)];
|
||||
const fareRule = fareRules[Math.floor(Math.random() * fareRules.length)];
|
||||
|
||||
const basePrice = Math.floor(
|
||||
(cabin === 'economy' ? 200 : cabin === 'premium' ? 400 : cabin === 'business' ? 800 : 1500) +
|
||||
Math.random() * 300
|
||||
);
|
||||
|
||||
return {
|
||||
id: `flt-${i}`,
|
||||
departure: {
|
||||
time: `${depHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`,
|
||||
airport: airports[Math.floor(Math.random() * airports.length)],
|
||||
},
|
||||
arrival: {
|
||||
time: `${arrHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`,
|
||||
airport: airports[Math.floor(Math.random() * airports.length)],
|
||||
},
|
||||
duration: `${Math.floor(Math.random() * 5) + 2}h ${Math.floor(Math.random() * 60)}m`,
|
||||
stops,
|
||||
cabinClass: cabin,
|
||||
fareRule,
|
||||
refundable: Math.random() > 0.7,
|
||||
basePrice,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default function AirlineProducts() {
|
||||
const flights = genRandomFlights();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Available Flights</h1>
|
||||
<div className="space-y-4">
|
||||
{flights.map((f) => (
|
||||
<AirlineCard key={f.id} flight={f} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
web/src/app/api/admin/experiments/route.ts
Normal file
15
web/src/app/api/admin/experiments/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAllExperiments } from '@/lib/sessionStore';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const exps = getAllExperiments();
|
||||
return NextResponse.json({ experiments: exps });
|
||||
} catch (err: any) {
|
||||
console.error('experiments list error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
43
web/src/app/api/admin/experiments/start/route.ts
Normal file
43
web/src/app/api/admin/experiments/start/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createExperiment, getSession } from '@/lib/sessionStore';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { sessionId } = body;
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sessionId required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// verify session exists
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'session not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// generate and create experiment
|
||||
const experimentId = randomUUID();
|
||||
const exp = createExperiment(sessionId, experimentId);
|
||||
|
||||
return NextResponse.json({
|
||||
experimentId: exp.id,
|
||||
sessionId,
|
||||
status: exp.status,
|
||||
createdAt: exp.createdAt,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('experiment start error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
39
web/src/app/api/admin/experiments/stop/route.ts
Normal file
39
web/src/app/api/admin/experiments/stop/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { stopExperimentById, getExperiment } from '@/lib/sessionStore';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { experimentId } = body;
|
||||
|
||||
if (!experimentId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'experimentId required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// verify experiment exists
|
||||
const existing = getExperiment(experimentId);
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'experiment not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// stop the experiment
|
||||
const exp = stopExperimentById(experimentId);
|
||||
|
||||
return NextResponse.json({
|
||||
experimentId: exp!.id,
|
||||
status: exp!.status,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('experiment stop error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
web/src/app/api/ingest/route.ts
Normal file
42
web/src/app/api/ingest/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { EventBase } from '@/lib/events';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:5000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
const storeMode = process.env.STORE_MODE || 'hotel';
|
||||
const userAgent = req.headers.get('user-agent') || undefined;
|
||||
|
||||
const event: EventBase = {
|
||||
...body,
|
||||
storeMode,
|
||||
userAgent,
|
||||
ts: body.ts || new Date().toISOString(),
|
||||
};
|
||||
|
||||
const res = await fetch(`${BACKEND_URL}/api/kafka/ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Backend returned ${res.status}`);
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_APP_ENV === 'dev') {
|
||||
console.log('[ingest]', event);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error('[ingest error]', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
45
web/src/app/api/pricing/route.ts
Normal file
45
web/src/app/api/pricing/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
interface PricingResponse {
|
||||
price: number;
|
||||
currency: string;
|
||||
cachedAt: string;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const productId = searchParams.get('productId');
|
||||
const sessionId = searchParams.get('sessionId');
|
||||
const experimentId = searchParams.get('experimentId');
|
||||
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || 'shop';
|
||||
|
||||
// log in dev
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[pricing-api]', {
|
||||
productId,
|
||||
sessionId,
|
||||
experimentId,
|
||||
storeMode,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!productId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'productId is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// stub: call external pricing provider (random for now)
|
||||
const basePrice = 100 + Math.random() * 900; // 100-1000 range
|
||||
const price = Math.round(basePrice * 100) / 100;
|
||||
|
||||
const response: PricingResponse = {
|
||||
price,
|
||||
currency: 'EUR',
|
||||
cachedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
46
web/src/app/api/session/route.ts
Normal file
46
web/src/app/api/session/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getSession, createSession } from '@/lib/sessionStore';
|
||||
|
||||
const COOKIE_NAME = 'phantom_session_id';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// check for existing session cookie
|
||||
const existingSession = req.cookies.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (existingSession) {
|
||||
const sessionData = getSession(existingSession);
|
||||
return NextResponse.json({
|
||||
sessionId: existingSession,
|
||||
experimentId: sessionData?.experimentId,
|
||||
});
|
||||
}
|
||||
|
||||
// mint new session id
|
||||
const sessionId = randomUUID();
|
||||
createSession(sessionId);
|
||||
|
||||
const res = NextResponse.json({ sessionId, experimentId: undefined });
|
||||
|
||||
// set httpOnly cookie with security flags
|
||||
res.cookies.set({
|
||||
name: COOKIE_NAME,
|
||||
value: sessionId,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd,
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (err: any) {
|
||||
console.error('session error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { sendInteractionEvent } from '@/lib/kafka';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { sessionId, eventType, targetEl, targetUrl, metadata } = body;
|
||||
|
||||
if (!sessionId || !eventType) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sessionId and eventType required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await sendInteractionEvent({
|
||||
sessionId,
|
||||
eventType,
|
||||
targetEl,
|
||||
targetUrl,
|
||||
metadata,
|
||||
ts: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error('track error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
@@ -13,6 +14,7 @@
|
||||
--border-radius: 8px;
|
||||
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
@@ -21,6 +23,7 @@
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
@@ -66,7 +69,9 @@ input, select, textarea {
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
@@ -86,13 +91,19 @@ input, select, textarea {
|
||||
font-size: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
transition: all 0.2s ease;
|
||||
background-color: #007aff;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
background-color: #0051d5;
|
||||
}
|
||||
|
||||
.section-spacing {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
|
||||
6
web/src/app/hotel/layout.tsx
Normal file
6
web/src/app/hotel/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
import '@/styles/hotel.css';
|
||||
|
||||
export default function HotelLayout({ children }: { children: ReactNode }) {
|
||||
return <div data-mode="hotel">{children}</div>;
|
||||
}
|
||||
9
web/src/app/hotel/page.tsx
Normal file
9
web/src/app/hotel/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import HotelHero from '@/components/feats/hotel/HotelHero';
|
||||
|
||||
export default function HotelHome() {
|
||||
return (
|
||||
<main>
|
||||
<HotelHero />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
75
web/src/app/hotel/products/page.tsx
Normal file
75
web/src/app/hotel/products/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { Navigation } from '@/components/ui';
|
||||
import HotelCard from '@/components/feats/hotel/HotelCard';
|
||||
|
||||
interface Hotel {
|
||||
id: string;
|
||||
name: string;
|
||||
roomType: string;
|
||||
checkIn: string;
|
||||
checkOut: string;
|
||||
amenities: string[];
|
||||
refundable: boolean;
|
||||
pricePerNight: number;
|
||||
nights: number;
|
||||
}
|
||||
|
||||
const genRandomHotels = (): Hotel[] => {
|
||||
const names = [
|
||||
'Grand Plaza Hotel',
|
||||
'Seaside Resort',
|
||||
'Downtown Suites',
|
||||
'Mountain View Lodge',
|
||||
'City Center Inn',
|
||||
'Luxury Beach Resort',
|
||||
'Urban Boutique Hotel',
|
||||
'Garden View Hotel',
|
||||
];
|
||||
const roomTypes = ['Standard Room', 'Deluxe Room', 'Suite', 'Executive Suite', 'Premium Room'];
|
||||
const amenities = ['wifi', 'pool', 'gym', 'parking', 'breakfast', 'spa'];
|
||||
|
||||
return Array.from({ length: 10 }, (_, i) => {
|
||||
const nights = Math.floor(Math.random() * 5) + 1;
|
||||
const basePrice = Math.floor(80 + Math.random() * 220);
|
||||
const selectedAmenities = amenities
|
||||
.sort(() => Math.random() - 0.5)
|
||||
.slice(0, Math.floor(Math.random() * 3) + 2);
|
||||
|
||||
const today = new Date();
|
||||
const checkInDate = new Date(today);
|
||||
checkInDate.setDate(today.getDate() + Math.floor(Math.random() * 10));
|
||||
const checkOutDate = new Date(checkInDate);
|
||||
checkOutDate.setDate(checkInDate.getDate() + nights);
|
||||
|
||||
return {
|
||||
id: `htl-${i}`,
|
||||
name: names[i % names.length],
|
||||
roomType: roomTypes[Math.floor(Math.random() * roomTypes.length)],
|
||||
checkIn: checkInDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
checkOut: checkOutDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
amenities: selectedAmenities,
|
||||
refundable: Math.random() > 0.5,
|
||||
pricePerNight: basePrice,
|
||||
nights,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default function HotelProducts() {
|
||||
const hotels = genRandomHotels();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Available Hotels</h1>
|
||||
<div className="space-y-4">
|
||||
{hotels.map((h) => (
|
||||
<HotelCard key={h.id} hotel={h} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user