diff --git a/web/src/app/airline/layout.tsx b/web/src/app/airline/layout.tsx index 755ecbf..a298d7d 100644 --- a/web/src/app/airline/layout.tsx +++ b/web/src/app/airline/layout.tsx @@ -1,4 +1,6 @@ import { ReactNode } from 'react'; +import '@/styles/airline.css'; + export default function AirlineLayout({ children }: { children: ReactNode }) { - return <>{children}; + return
{children}
; } diff --git a/web/src/app/airline/page.tsx b/web/src/app/airline/page.tsx index 184d63c..7499f46 100644 --- a/web/src/app/airline/page.tsx +++ b/web/src/app/airline/page.tsx @@ -1,7 +1,9 @@ +import AirlineHero from '@/components/feats/airline/AirlineHero'; + export default function AirlineHome() { return ( -
-

Airline Mode

-
+
+ +
); } diff --git a/web/src/app/globals.css b/web/src/app/globals.css index eba0bfc..4a5b0c9 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -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); } +} diff --git a/web/src/app/hotel/layout.tsx b/web/src/app/hotel/layout.tsx index 87d0a21..ead9aa1 100644 --- a/web/src/app/hotel/layout.tsx +++ b/web/src/app/hotel/layout.tsx @@ -1,4 +1,6 @@ import { ReactNode } from 'react'; +import '@/styles/hotel.css'; + export default function HotelLayout({ children }: { children: ReactNode }) { - return <>{children}; + return
{children}
; } diff --git a/web/src/app/hotel/page.tsx b/web/src/app/hotel/page.tsx index b9d495c..c614ff5 100644 --- a/web/src/app/hotel/page.tsx +++ b/web/src/app/hotel/page.tsx @@ -1,7 +1,9 @@ +import HotelHero from '@/components/feats/hotel/HotelHero'; + export default function HotelHome() { return ( -
-

Hotel Mode

-
+
+ +
); } diff --git a/web/src/components/feats/airline/AirlineHero.tsx b/web/src/components/feats/airline/AirlineHero.tsx new file mode 100644 index 0000000..cca2e45 --- /dev/null +++ b/web/src/components/feats/airline/AirlineHero.tsx @@ -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 = () => ( + + + +); + +const LocationIcon = () => ( + + + + +); + +export default function AirlineHero() { + const [tripType, setTripType] = useState('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 ( +
+
+
+

+ Book flights at the best prices +

+

+ Compare hundreds of airlines and find the perfect flight for your journey +

+
+ +
+
+
+ +
+ +
+
+ + setOrigin(e.target.value)} + placeholder="Airport or city" + icon={} + required + /> +
+ +
+ + setDestination(e.target.value)} + placeholder="Airport or city" + icon={} + required + /> +
+ +
+ + setDepartDate(e.target.value)} + required + /> +
+ +
+ + {tripType === 'roundtrip' ? ( + setReturnDate(e.target.value)} + required + /> + ) : ( + + )} +
+
+ +
+
+ + + setPassengers({ ...passengers, adults: v })} + /> + setPassengers({ ...passengers, children: v })} + /> + setPassengers({ ...passengers, infants: v })} + /> + +
+
+ +
+ +
+
+
+ +
+

Direct flights available · Flexible booking · Compare 500+ airlines worldwide

+
+
+
+ ); +} diff --git a/web/src/components/feats/hotel/HotelHero.tsx b/web/src/components/feats/hotel/HotelHero.tsx new file mode 100644 index 0000000..70bd595 --- /dev/null +++ b/web/src/components/feats/hotel/HotelHero.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useState, FormEvent } from 'react'; +import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui'; + +const LocationIcon = () => ( + + + + +); + +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 ( +
+
+
+

+ Find your perfect stay +

+

+ Search hotels, compare prices, and book with confidence +

+
+ +
+
+
+ + setDestination(e.target.value)} + placeholder="City, hotel, or landmark" + icon={} + required + /> +
+ +
+ + setCheckIn(e.target.value)} + required + /> +
+ +
+ + setCheckOut(e.target.value)} + required + /> +
+ +
+ + + setGuests({ ...guests, adults: v })} + /> + setGuests({ ...guests, rooms: v })} + /> + +
+ +
+ +
+
+
+ +
+

Over 2 million hotels worldwide · Best price guarantee · Free cancellation on most bookings

+
+
+
+ ); +} diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..a2302a7 --- /dev/null +++ b/web/src/components/ui/Button.tsx @@ -0,0 +1,20 @@ +import { ReactNode, ButtonHTMLAttributes } from 'react'; + +type BtnVariant = 'primary' | 'secondary'; + +interface BtnProps extends ButtonHTMLAttributes { + 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 ( + + ); +} diff --git a/web/src/components/ui/DateInput.tsx b/web/src/components/ui/DateInput.tsx new file mode 100644 index 0000000..f6edd94 --- /dev/null +++ b/web/src/components/ui/DateInput.tsx @@ -0,0 +1,7 @@ +import { InputHTMLAttributes } from 'react'; + +interface DateInpProps extends Omit, 'type'> {} + +export default function DateInput({ className = '', ...props }: DateInpProps) { + return ; +} diff --git a/web/src/components/ui/Dropdown.tsx b/web/src/components/ui/Dropdown.tsx new file mode 100644 index 0000000..5a70dd4 --- /dev/null +++ b/web/src/components/ui/Dropdown.tsx @@ -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(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 ( +
+ + {open && ( +
+ {children} +
+ )} +
+ ); +} + +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 ( +
+
+ {label} + {sublabel && {sublabel}} +
+
+ + {value} + +
+
+ ); +} diff --git a/web/src/components/ui/Input.tsx b/web/src/components/ui/Input.tsx new file mode 100644 index 0000000..f58c86d --- /dev/null +++ b/web/src/components/ui/Input.tsx @@ -0,0 +1,29 @@ +import { InputHTMLAttributes, ReactNode } from 'react'; + +interface InpProps extends InputHTMLAttributes { + 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 ( +
+ {icon && ( +
+ {icon} +
+ )} + +
+ ); +} diff --git a/web/src/components/ui/Label.tsx b/web/src/components/ui/Label.tsx new file mode 100644 index 0000000..a404e92 --- /dev/null +++ b/web/src/components/ui/Label.tsx @@ -0,0 +1,13 @@ +import { ReactNode, LabelHTMLAttributes } from 'react'; + +interface LblProps extends LabelHTMLAttributes { + children: ReactNode; +} + +export default function Label({ children, className = '', ...props }: LblProps) { + return ( + + ); +} diff --git a/web/src/components/ui/RadioGroup.tsx b/web/src/components/ui/RadioGroup.tsx new file mode 100644 index 0000000..e315565 --- /dev/null +++ b/web/src/components/ui/RadioGroup.tsx @@ -0,0 +1,33 @@ +'use client'; + +interface RadioOpt { + value: T; + label: string; +} + +interface RadioGrpProps { + name: string; + options: RadioOpt[]; + value: T; + onChange: (val: T) => void; +} + +export default function RadioGroup({ name, options, value, onChange }: RadioGrpProps) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} diff --git a/web/src/components/ui/index.ts b/web/src/components/ui/index.ts new file mode 100644 index 0000000..3180ea1 --- /dev/null +++ b/web/src/components/ui/index.ts @@ -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'; diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 8be3088..ca9664e 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -3,13 +3,13 @@ import { z } from 'zod'; type Env = z.infer; const envSchema = z.object({ 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({ message: 'NEXT_PUBLIC_API_BASE must be a valid URL (e.g., http://localhost:3000)' }), 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, }); 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}`); } return result.data; diff --git a/web/src/styles/airline.css b/web/src/styles/airline.css index 33f5cb9..880b9d5 100644 --- a/web/src/styles/airline.css +++ b/web/src/styles/airline.css @@ -1,5 +1,6 @@ /* Airline Platform - Sky Blue Theme */ +@layer base { :root[data-mode="airline"] { --accent-primary: #007aff; --accent-secondary: #4caf50; @@ -7,19 +8,31 @@ --accent-primary-hover: #0051d5; --accent-primary-light: #e6f2ff; --text-accent: #007aff; + --hero-bg: linear-gradient(to bottom, white, #e6f2ff); +} } +@layer components { [data-mode="airline"] { --primary-color: var(--accent-primary); } [data-mode="airline"] .btn-primary { - background-color: var(--accent-primary); - color: #ffffff; + background-color: var(--accent-primary) !important; + 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 { 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 { @@ -264,6 +277,7 @@ border-radius: 6px; padding: 12px; transition: border-color 0.2s ease; + width: 100%; } [data-mode="airline"] .input-field:focus { @@ -300,3 +314,8 @@ [data-mode="airline"] .checkbox-label:hover { color: var(--accent-primary); } + +[data-mode="airline"] .hero-section { + background: var(--hero-bg); +} +} diff --git a/web/src/styles/hotel.css b/web/src/styles/hotel.css index 8c67285..1d7c181 100644 --- a/web/src/styles/hotel.css +++ b/web/src/styles/hotel.css @@ -1,5 +1,6 @@ /* Hotel Platform - Action Blue Theme */ +@layer base { :root[data-mode="hotel"] { --accent-primary: #007aff; --accent-secondary: #4caf50; @@ -8,8 +9,11 @@ --accent-primary-light: #e6f2ff; --text-accent: #007aff; --bg-tertiary: #f5f5f7; + --hero-bg: linear-gradient(to bottom, white, #f5f5f5); +} } +@layer components { [data-mode="hotel"] { --primary-color: var(--accent-primary); } @@ -17,10 +21,19 @@ [data-mode="hotel"] .btn-primary { background-color: var(--accent-primary); 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 { 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 { @@ -398,3 +411,8 @@ color: var(--accent-primary); border-bottom-color: var(--accent-primary); } + +[data-mode="hotel"] .hero-section { + background: var(--hero-bg); +} +}