mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 16:43:36 +00:00
fixed alignment and building
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import '@/styles/airline.css';
|
||||||
|
|
||||||
export default function AirlineLayout({ children }: { children: ReactNode }) {
|
export default function AirlineLayout({ children }: { children: ReactNode }) {
|
||||||
return <>{children}</>;
|
return <div data-mode="airline">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import AirlineHero from '@/components/feats/airline/AirlineHero';
|
||||||
|
|
||||||
export default function AirlineHome() {
|
export default function AirlineHome() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<main>
|
||||||
<h1 className="text-3xl font-bold">Airline Mode</h1>
|
<AirlineHero />
|
||||||
</div>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
--border-radius: 8px;
|
--border-radius: 8px;
|
||||||
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
|
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--background: #0a0a0a;
|
||||||
@@ -66,7 +69,9 @@ input, select, textarea {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -86,13 +91,19 @@ input, select, textarea {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
background-color: #007aff;
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
background-color: #0051d5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-spacing {
|
.section-spacing {
|
||||||
margin-bottom: var(--spacing-lg);
|
margin-bottom: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import '@/styles/hotel.css';
|
||||||
|
|
||||||
export default function HotelLayout({ children }: { children: ReactNode }) {
|
export default function HotelLayout({ children }: { children: ReactNode }) {
|
||||||
return <>{children}</>;
|
return <div data-mode="hotel">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import HotelHero from '@/components/feats/hotel/HotelHero';
|
||||||
|
|
||||||
export default function HotelHome() {
|
export default function HotelHome() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<main>
|
||||||
<h1 className="text-3xl font-bold">Hotel Mode</h1>
|
<HotelHero />
|
||||||
</div>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
156
web/src/components/feats/airline/AirlineHero.tsx
Normal file
156
web/src/components/feats/airline/AirlineHero.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
||||||
|
|
||||||
|
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
||||||
|
|
||||||
|
const PlaneIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LocationIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function AirlineHero() {
|
||||||
|
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
||||||
|
const [origin, setOrigin] = useState('');
|
||||||
|
const [destination, setDestination] = useState('');
|
||||||
|
const [departDate, setDepartDate] = useState('');
|
||||||
|
const [returnDate, setReturnDate] = useState('');
|
||||||
|
const [passengers, setPassengers] = useState({ adults: 1, children: 0, infants: 0 });
|
||||||
|
|
||||||
|
const handleSearch = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log({ tripType, origin, destination, departDate, returnDate, passengers });
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPax = passengers.adults + passengers.children + passengers.infants;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hero-section min-h-[70vh] flex items-center justify-center">
|
||||||
|
<div className="w-full max-w-5xl px-4">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||||
|
Book flights at the best prices
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg">
|
||||||
|
Compare hundreds of airlines and find the perfect flight for your journey
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="search-form">
|
||||||
|
<form onSubmit={handleSearch}>
|
||||||
|
<div className="mb-6">
|
||||||
|
<RadioGroup
|
||||||
|
name="tripType"
|
||||||
|
value={tripType}
|
||||||
|
onChange={setTripType}
|
||||||
|
options={[
|
||||||
|
{ value: 'roundtrip', label: 'Round-trip' },
|
||||||
|
{ value: 'oneway', label: 'One-way' },
|
||||||
|
{ value: 'multicity', label: 'Multi-city' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="origin">From</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="origin"
|
||||||
|
value={origin}
|
||||||
|
onChange={(e) => setOrigin(e.target.value)}
|
||||||
|
placeholder="Airport or city"
|
||||||
|
icon={<PlaneIcon />}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="destination">To</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="destination"
|
||||||
|
value={destination}
|
||||||
|
onChange={(e) => setDestination(e.target.value)}
|
||||||
|
placeholder="Airport or city"
|
||||||
|
icon={<LocationIcon />}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="departDate">Departure</Label>
|
||||||
|
<DateInput
|
||||||
|
id="departDate"
|
||||||
|
value={departDate}
|
||||||
|
onChange={(e) => setDepartDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="returnDate">Return</Label>
|
||||||
|
{tripType === 'roundtrip' ? (
|
||||||
|
<DateInput
|
||||||
|
id="returnDate"
|
||||||
|
value={returnDate}
|
||||||
|
onChange={(e) => setReturnDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DateInput id="returnDate" disabled />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
|
||||||
|
<div className="sm:col-span-1 lg:col-span-1">
|
||||||
|
<Label htmlFor="passengers">Passengers</Label>
|
||||||
|
<Dropdown label={`${totalPax} ${totalPax === 1 ? 'passenger' : 'passengers'}`}>
|
||||||
|
<DropdownCounter
|
||||||
|
label="Adults"
|
||||||
|
sublabel="12+ years"
|
||||||
|
value={passengers.adults}
|
||||||
|
min={1}
|
||||||
|
onChange={(v) => setPassengers({ ...passengers, adults: v })}
|
||||||
|
/>
|
||||||
|
<DropdownCounter
|
||||||
|
label="Children"
|
||||||
|
sublabel="2-11 years"
|
||||||
|
value={passengers.children}
|
||||||
|
onChange={(v) => setPassengers({ ...passengers, children: v })}
|
||||||
|
/>
|
||||||
|
<DropdownCounter
|
||||||
|
label="Infants"
|
||||||
|
sublabel="Under 2"
|
||||||
|
value={passengers.infants}
|
||||||
|
onChange={(v) => setPassengers({ ...passengers, infants: v })}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button type="submit" fullWidth>
|
||||||
|
Search Flights
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-sm">
|
||||||
|
<p>Direct flights available · Flexible booking · Compare 500+ airlines worldwide</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
web/src/components/feats/hotel/HotelHero.tsx
Normal file
103
web/src/components/feats/hotel/HotelHero.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
|
||||||
|
|
||||||
|
const LocationIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function HotelHero() {
|
||||||
|
const [destination, setDestination] = useState('');
|
||||||
|
const [checkIn, setCheckIn] = useState('');
|
||||||
|
const [checkOut, setCheckOut] = useState('');
|
||||||
|
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
|
||||||
|
|
||||||
|
const handleSearch = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log({ destination, checkIn, checkOut, guests });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hero-section min-h-[70vh] flex items-center justify-center">
|
||||||
|
<div className="w-full max-w-4xl px-4">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||||
|
Find your perfect stay
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg">
|
||||||
|
Search hotels, compare prices, and book with confidence
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearch} className="search-form">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label htmlFor="destination">Where to?</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="destination"
|
||||||
|
value={destination}
|
||||||
|
onChange={(e) => setDestination(e.target.value)}
|
||||||
|
placeholder="City, hotel, or landmark"
|
||||||
|
icon={<LocationIcon />}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="checkIn">Check-in</Label>
|
||||||
|
<DateInput
|
||||||
|
id="checkIn"
|
||||||
|
value={checkIn}
|
||||||
|
onChange={(e) => setCheckIn(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="checkOut">Check-out</Label>
|
||||||
|
<DateInput
|
||||||
|
id="checkOut"
|
||||||
|
value={checkOut}
|
||||||
|
onChange={(e) => setCheckOut(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2 lg:col-span-4">
|
||||||
|
<Label htmlFor="guests">Guests & Rooms</Label>
|
||||||
|
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}, ${guests.rooms} ${guests.rooms === 1 ? 'room' : 'rooms'}`}>
|
||||||
|
<DropdownCounter
|
||||||
|
label="Adults"
|
||||||
|
value={guests.adults}
|
||||||
|
min={1}
|
||||||
|
onChange={(v) => setGuests({ ...guests, adults: v })}
|
||||||
|
/>
|
||||||
|
<DropdownCounter
|
||||||
|
label="Rooms"
|
||||||
|
value={guests.rooms}
|
||||||
|
min={1}
|
||||||
|
onChange={(v) => setGuests({ ...guests, rooms: v })}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2 lg:col-span-4">
|
||||||
|
<Button type="submit" fullWidth>
|
||||||
|
Search Hotels
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-sm">
|
||||||
|
<p>Over 2 million hotels worldwide · Best price guarantee · Free cancellation on most bookings</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
web/src/components/ui/Button.tsx
Normal file
20
web/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ReactNode, ButtonHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
type BtnVariant = 'primary' | 'secondary';
|
||||||
|
|
||||||
|
interface BtnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: BtnVariant;
|
||||||
|
children: ReactNode;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({ variant = 'primary', children, fullWidth, className = '', ...props }: BtnProps) {
|
||||||
|
const baseClass = variant === 'primary' ? 'btn-primary' : 'btn-secondary';
|
||||||
|
const widthClass = fullWidth ? 'w-full' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={`${baseClass} ${widthClass} ${className}`.trim()} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
web/src/components/ui/DateInput.tsx
Normal file
7
web/src/components/ui/DateInput.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { InputHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
interface DateInpProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {}
|
||||||
|
|
||||||
|
export default function DateInput({ className = '', ...props }: DateInpProps) {
|
||||||
|
return <input type="date" className={`input-field ${className}`.trim()} {...props} />;
|
||||||
|
}
|
||||||
83
web/src/components/ui/Dropdown.tsx
Normal file
83
web/src/components/ui/Dropdown.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode, useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface DropdownProps {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dropdown({ label, children }: DropdownProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="input-field flex justify-between items-center w-full"
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-10 mt-2 w-full bg-white border border-gray-200 rounded-lg shadow-lg p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CounterProps {
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
value: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
onChange: (val: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownCounter({ label, sublabel, value, min = 0, max = 99, onChange }: CounterProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center py-3 border-b border-gray-100 last:border-b-0">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-gray-900">{label}</span>
|
||||||
|
{sublabel && <span className="text-xs text-gray-500 mt-0.5">{sublabel}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(Math.max(min, value - 1))}
|
||||||
|
disabled={value <= min}
|
||||||
|
className="w-9 h-9 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-colors text-lg font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="w-10 text-center font-semibold text-gray-900">{value}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(Math.min(max, value + 1))}
|
||||||
|
disabled={value >= max}
|
||||||
|
className="w-9 h-9 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-colors text-lg font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
web/src/components/ui/Input.tsx
Normal file
29
web/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { InputHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface InpProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Input({ icon, className = '', style, ...props }: InpProps) {
|
||||||
|
const padClass = icon ? 'pl-10' : '';
|
||||||
|
// Fallback if a custom CSS rule still overrides Tailwind
|
||||||
|
const mergedStyle = icon ? { paddingInlineStart: '2.5rem', ...style } : style;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{icon && (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400 z-10"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
className={`input-field ${className} ${padClass}`}
|
||||||
|
style={mergedStyle}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
web/src/components/ui/Label.tsx
Normal file
13
web/src/components/ui/Label.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ReactNode, LabelHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
interface LblProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Label({ children, className = '', ...props }: LblProps) {
|
||||||
|
return (
|
||||||
|
<label className={`block text-sm font-medium mb-2 ${className}`.trim()} {...props}>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
web/src/components/ui/RadioGroup.tsx
Normal file
33
web/src/components/ui/RadioGroup.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface RadioOpt<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RadioGrpProps<T extends string> {
|
||||||
|
name: string;
|
||||||
|
options: RadioOpt<T>[];
|
||||||
|
value: T;
|
||||||
|
onChange: (val: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RadioGroup<T extends string>({ name, options, value, onChange }: RadioGrpProps<T>) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<label key={opt.value} className="flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={name}
|
||||||
|
value={opt.value}
|
||||||
|
checked={value === opt.value}
|
||||||
|
onChange={(e) => onChange(e.target.value as T)}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
web/src/components/ui/index.ts
Normal file
6
web/src/components/ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as Button } from './Button';
|
||||||
|
export { default as Label } from './Label';
|
||||||
|
export { default as Input } from './Input';
|
||||||
|
export { default as DateInput } from './DateInput';
|
||||||
|
export { default as RadioGroup } from './RadioGroup';
|
||||||
|
export { default as Dropdown, DropdownCounter } from './Dropdown';
|
||||||
@@ -3,13 +3,13 @@ import { z } from 'zod';
|
|||||||
type Env = z.infer<typeof envSchema>;
|
type Env = z.infer<typeof envSchema>;
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
STORE_MODE: z.enum(['hotel', 'airline'], {
|
STORE_MODE: z.enum(['hotel', 'airline'], {
|
||||||
errorMap: () => ({ message: 'STORE_MODE must be either "hotel" or "airline"' })
|
message: 'STORE_MODE must be either "hotel" or "airline"'
|
||||||
}),
|
}),
|
||||||
NEXT_PUBLIC_API_BASE: z.string().url({
|
NEXT_PUBLIC_API_BASE: z.string().url({
|
||||||
message: 'NEXT_PUBLIC_API_BASE must be a valid URL (e.g., http://localhost:3000)'
|
message: 'NEXT_PUBLIC_API_BASE must be a valid URL (e.g., http://localhost:3000)'
|
||||||
}),
|
}),
|
||||||
NEXT_PUBLIC_APP_ENV: z.enum(['dev', 'prod'], {
|
NEXT_PUBLIC_APP_ENV: z.enum(['dev', 'prod'], {
|
||||||
errorMap: () => ({ message: 'NEXT_PUBLIC_APP_ENV must be either "dev" or "prod"' })
|
message: 'NEXT_PUBLIC_APP_ENV must be either "dev" or "prod"'
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ const parseEnv = (): Env => {
|
|||||||
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
|
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
|
||||||
});
|
});
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const errors = result.error.errors.map((err) => `${err.path.join('.')}: ${err.message}`).join('\n');
|
const errors = result.error.issues.map((err) => `${err.path.join('.')}: ${err.message}`).join('\n');
|
||||||
throw new Error(`Environment validation failed:\n${errors}`);
|
throw new Error(`Environment validation failed:\n${errors}`);
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* Airline Platform - Sky Blue Theme */
|
/* Airline Platform - Sky Blue Theme */
|
||||||
|
|
||||||
|
@layer base {
|
||||||
:root[data-mode="airline"] {
|
:root[data-mode="airline"] {
|
||||||
--accent-primary: #007aff;
|
--accent-primary: #007aff;
|
||||||
--accent-secondary: #4caf50;
|
--accent-secondary: #4caf50;
|
||||||
@@ -7,19 +8,31 @@
|
|||||||
--accent-primary-hover: #0051d5;
|
--accent-primary-hover: #0051d5;
|
||||||
--accent-primary-light: #e6f2ff;
|
--accent-primary-light: #e6f2ff;
|
||||||
--text-accent: #007aff;
|
--text-accent: #007aff;
|
||||||
|
--hero-bg: linear-gradient(to bottom, white, #e6f2ff);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
[data-mode="airline"] {
|
[data-mode="airline"] {
|
||||||
--primary-color: var(--accent-primary);
|
--primary-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="airline"] .btn-primary {
|
[data-mode="airline"] .btn-primary {
|
||||||
background-color: var(--accent-primary);
|
background-color: var(--accent-primary) !important;
|
||||||
color: #ffffff;
|
color: #ffffff !important;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="airline"] .btn-primary:hover {
|
[data-mode="airline"] .btn-primary:hover {
|
||||||
background-color: var(--accent-primary-hover);
|
background-color: var(--accent-primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="airline"] .btn-secondary {
|
[data-mode="airline"] .btn-secondary {
|
||||||
@@ -264,6 +277,7 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
transition: border-color 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="airline"] .input-field:focus {
|
[data-mode="airline"] .input-field:focus {
|
||||||
@@ -300,3 +314,8 @@
|
|||||||
[data-mode="airline"] .checkbox-label:hover {
|
[data-mode="airline"] .checkbox-label:hover {
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-mode="airline"] .hero-section {
|
||||||
|
background: var(--hero-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* Hotel Platform - Action Blue Theme */
|
/* Hotel Platform - Action Blue Theme */
|
||||||
|
|
||||||
|
@layer base {
|
||||||
:root[data-mode="hotel"] {
|
:root[data-mode="hotel"] {
|
||||||
--accent-primary: #007aff;
|
--accent-primary: #007aff;
|
||||||
--accent-secondary: #4caf50;
|
--accent-secondary: #4caf50;
|
||||||
@@ -8,8 +9,11 @@
|
|||||||
--accent-primary-light: #e6f2ff;
|
--accent-primary-light: #e6f2ff;
|
||||||
--text-accent: #007aff;
|
--text-accent: #007aff;
|
||||||
--bg-tertiary: #f5f5f7;
|
--bg-tertiary: #f5f5f7;
|
||||||
|
--hero-bg: linear-gradient(to bottom, white, #f5f5f5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
[data-mode="hotel"] {
|
[data-mode="hotel"] {
|
||||||
--primary-color: var(--accent-primary);
|
--primary-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
@@ -17,10 +21,19 @@
|
|||||||
[data-mode="hotel"] .btn-primary {
|
[data-mode="hotel"] .btn-primary {
|
||||||
background-color: var(--accent-primary);
|
background-color: var(--accent-primary);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="hotel"] .btn-primary:hover {
|
[data-mode="hotel"] .btn-primary:hover {
|
||||||
background-color: var(--accent-primary-hover);
|
background-color: var(--accent-primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="hotel"] .btn-secondary {
|
[data-mode="hotel"] .btn-secondary {
|
||||||
@@ -398,3 +411,8 @@
|
|||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
border-bottom-color: var(--accent-primary);
|
border-bottom-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-mode="hotel"] .hero-section {
|
||||||
|
background: var(--hero-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user