Initial build: Great Bear microbrewery site
- TanStack Start (React 19, Tailwind 4) frontend recreated from Stitch design - PocketBase backend with schema migration + idempotent content seed - Single Docker image running both services (non-root, healthchecked) - deploy.sh to build & push to registry.tanshu.com - Ansible playbook (roles: app, caddy) deploying to www.greatbear.in with Caddy TLS entries for the site and the PocketBase admin
No files matched your search
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "greatbear-app",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"start": "node .output/server/index.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.5",
|
||||
"@fontsource-variable/playfair-display": "^5.2.5",
|
||||
"@tanstack/react-router": "^1.170.32",
|
||||
"@tanstack/react-start": "^1.168.49",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"nitro": "^3.0.260311-beta",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^8.0.14"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,107 @@
|
||||
import * as React from 'react'
|
||||
import type { MenuCategory, MenuItem } from '~/lib/types'
|
||||
|
||||
export function MenuBrowser({
|
||||
categories,
|
||||
items,
|
||||
}: {
|
||||
categories: MenuCategory[]
|
||||
items: MenuItem[]
|
||||
}) {
|
||||
const [active, setActive] = React.useState<string>('all')
|
||||
|
||||
const visible = items.filter((item) => active === 'all' || item.category === active)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sticky Menu Navigation Filter */}
|
||||
<section className="sticky top-20 z-40 bg-surface/90 py-space-md shadow-md backdrop-blur-md">
|
||||
<div className="no-scrollbar mx-auto flex max-w-7xl items-center justify-start gap-space-sm overflow-x-auto px-grid-margin md:justify-center">
|
||||
<FilterTab
|
||||
key="all"
|
||||
label="All Offerings"
|
||||
active={active === 'all'}
|
||||
onClick={() => setActive('all')}
|
||||
/>
|
||||
{categories.map((category) => (
|
||||
<FilterTab
|
||||
key={category.slug}
|
||||
label={category.name}
|
||||
active={active === category.slug}
|
||||
onClick={() => setActive(category.slug)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main Menu Grid */}
|
||||
<section className="mx-auto w-full max-w-7xl px-grid-margin py-space-xl">
|
||||
<div className="grid grid-cols-1 gap-grid-gutter md:grid-cols-2">
|
||||
{visible.map((item) => (
|
||||
<div
|
||||
key={item.uid}
|
||||
className="flex flex-col overflow-hidden rounded-xl bg-surface-container shadow-md transition-all duration-300 hover:shadow-xl"
|
||||
>
|
||||
<div
|
||||
className="h-64 w-full bg-cover bg-center"
|
||||
style={{ backgroundImage: `url('${item.image}')` }}
|
||||
/>
|
||||
<div className="flex flex-grow flex-col justify-between">
|
||||
<div>
|
||||
<div className="mb-space-xs flex items-center justify-between">
|
||||
<h3 className="font-headline-md text-on-surface">{item.name}</h3>
|
||||
<span className="font-headline-md text-primary">₹{item.price}</span>
|
||||
</div>
|
||||
<p className="mb-space-md text-body-md text-on-surface-variant">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="-mx-space-lg -mb-space-lg flex items-center justify-between bg-surface-container-low p-space-md pt-space-md">
|
||||
<div className="flex items-center gap-space-sm">
|
||||
<span
|
||||
className="material-symbols-outlined text-[20px] text-primary"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
sports_bar
|
||||
</span>
|
||||
<span className="text-label-md text-on-surface">
|
||||
Perfect Pairing:{' '}
|
||||
<strong className="text-primary">{item.pairing}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<span className="rounded bg-surface-container-highest px-space-sm py-space-xs text-label-md uppercase text-on-surface-variant">
|
||||
{item.tag}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterTab({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={
|
||||
active
|
||||
? 'whitespace-nowrap rounded-lg bg-primary-container px-space-md py-space-sm text-label-lg font-bold text-on-primary-container transition-all'
|
||||
: 'whitespace-nowrap rounded-lg bg-surface-container px-space-md py-space-sm text-label-lg text-on-surface-variant transition-all hover:bg-surface-container-high'
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react'
|
||||
import { createReservation } from '~/lib/data'
|
||||
|
||||
export function QuickReserveForm() {
|
||||
const [name, setName] = React.useState('')
|
||||
const [phone, setPhone] = React.useState('')
|
||||
const [status, setStatus] = React.useState<'idle' | 'sending' | 'done' | 'error'>('idle')
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setStatus('sending')
|
||||
try {
|
||||
await createReservation({ data: { name, phone, source: 'quick' } })
|
||||
setStatus('done')
|
||||
setName('')
|
||||
setPhone('')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'done') {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-space-sm rounded-xl bg-surface-container p-space-lg text-body-md text-primary">
|
||||
<span className="material-symbols-outlined">check_circle</span>
|
||||
Reservation request received! We will contact you shortly.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-space-md rounded-xl bg-surface-container p-space-lg md:flex-row"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<input
|
||||
className="flex-1 rounded bg-surface-container-highest px-space-md py-space-sm text-on-surface placeholder:text-on-surface-variant/50 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder="Your Name"
|
||||
required
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="flex-1 rounded bg-surface-container-highest px-space-md py-space-sm text-on-surface placeholder:text-on-surface-variant/50 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder="Phone Number"
|
||||
required
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="rounded bg-primary px-space-lg py-space-sm font-label-lg text-on-primary transition-all hover:bg-primary-container"
|
||||
type="submit"
|
||||
disabled={status === 'sending'}
|
||||
>
|
||||
{status === 'sending' ? 'Booking…' : 'Book Table'}
|
||||
</button>
|
||||
{status === 'error' ? (
|
||||
<p className="text-body-sm text-error md:hidden">
|
||||
Something went wrong — please call us instead.
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import * as React from 'react'
|
||||
import { createReservation } from '~/lib/data'
|
||||
|
||||
const TIME_SLOTS = [
|
||||
{ value: '12:00', label: '12:00 PM - Lunch' },
|
||||
{ value: '15:00', label: '3:00 PM - Afternoon' },
|
||||
{ value: '19:00', label: '7:00 PM - Evening' },
|
||||
{ value: '21:30', label: '9:30 PM - Late Night' },
|
||||
]
|
||||
|
||||
const PARTY_SIZES = [
|
||||
{ value: '1', label: '1 Person' },
|
||||
{ value: '2', label: '2 Guests' },
|
||||
{ value: '4', label: '4 Guests' },
|
||||
{ value: '6', label: '6 Guests' },
|
||||
{ value: '8+', label: '8+ (Large Group)' },
|
||||
]
|
||||
|
||||
const inputClasses =
|
||||
'rounded-lg bg-surface-container-low px-space-md py-space-sm text-on-surface placeholder:text-outline/50 focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
|
||||
export function ReservationForm() {
|
||||
const [status, setStatus] = React.useState<'idle' | 'sending' | 'done' | 'error'>('idle')
|
||||
const [errorMessage, setErrorMessage] = React.useState('')
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const form = event.currentTarget
|
||||
const data = new FormData(form)
|
||||
setStatus('sending')
|
||||
try {
|
||||
await createReservation({
|
||||
data: {
|
||||
name: String(data.get('name') ?? ''),
|
||||
phone: String(data.get('phone') ?? ''),
|
||||
source: 'table',
|
||||
date: String(data.get('date') ?? '') || undefined,
|
||||
time: String(data.get('time') ?? '') || undefined,
|
||||
guests: String(data.get('guests') ?? '') || undefined,
|
||||
requests: String(data.get('requests') ?? '') || undefined,
|
||||
},
|
||||
})
|
||||
setStatus('done')
|
||||
form.reset()
|
||||
} catch (error) {
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : 'Something went wrong — please try again.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'done') {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-space-md rounded-xl bg-surface-container-low p-space-xl text-center">
|
||||
<span className="material-symbols-outlined text-[48px] text-primary">
|
||||
event_available
|
||||
</span>
|
||||
<h3 className="font-headline-md text-on-surface">Reservation request received!</h3>
|
||||
<p className="text-body-sm text-on-surface-variant">
|
||||
Our host will contact you shortly to confirm your table.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-space-md" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-space-md md:grid-cols-2">
|
||||
<div className="flex flex-col gap-space-xs">
|
||||
<label className="text-label-md text-on-surface" htmlFor="name">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
className={inputClasses}
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Aarav Sharma"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-space-xs">
|
||||
<label className="text-label-md text-on-surface" htmlFor="phone">
|
||||
Phone Number
|
||||
</label>
|
||||
<input
|
||||
className={inputClasses}
|
||||
id="phone"
|
||||
name="phone"
|
||||
placeholder="+91 98765 43210"
|
||||
required
|
||||
type="tel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-space-md md:grid-cols-3">
|
||||
<div className="flex flex-col gap-space-xs">
|
||||
<label className="text-label-md text-on-surface" htmlFor="date">
|
||||
Date
|
||||
</label>
|
||||
<input className={inputClasses} id="date" name="date" required type="date" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-space-xs">
|
||||
<label className="text-label-md text-on-surface" htmlFor="time">
|
||||
Time Slot
|
||||
</label>
|
||||
<select className={inputClasses} id="time" name="time" required defaultValue="">
|
||||
<option disabled value="">
|
||||
Select time
|
||||
</option>
|
||||
{TIME_SLOTS.map((slot) => (
|
||||
<option key={slot.value} value={slot.value}>
|
||||
{slot.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-space-xs">
|
||||
<label className="text-label-md text-on-surface" htmlFor="guests">
|
||||
Guests
|
||||
</label>
|
||||
<select className={inputClasses} id="guests" name="guests" required defaultValue="">
|
||||
<option disabled value="">
|
||||
Party size
|
||||
</option>
|
||||
{PARTY_SIZES.map((size) => (
|
||||
<option key={size.value} value={size.value}>
|
||||
{size.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-space-xs">
|
||||
<label className="text-label-md text-on-surface" htmlFor="requests">
|
||||
Special Requests / Seating Preference
|
||||
</label>
|
||||
<textarea
|
||||
className={inputClasses}
|
||||
id="requests"
|
||||
name="requests"
|
||||
placeholder="Indoor seating near copper tanks, anniversary celebration, etc."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
{status === 'error' ? (
|
||||
<p className="text-body-sm text-error">{errorMessage}</p>
|
||||
) : null}
|
||||
<button
|
||||
className="mt-space-sm flex cursor-pointer items-center justify-center gap-space-sm rounded-lg bg-primary px-space-lg py-space-md font-bold text-on-primary shadow-md transition-all duration-300 hover:bg-primary-container"
|
||||
type="submit"
|
||||
disabled={status === 'sending'}
|
||||
>
|
||||
<span className="material-symbols-outlined">event_available</span>
|
||||
{status === 'sending' ? 'Sending…' : 'Confirm Reservation'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react'
|
||||
import type { Settings } from '~/lib/types'
|
||||
|
||||
export function SiteFooter({ settings }: { settings: Settings }) {
|
||||
return (
|
||||
<footer className="w-full bg-surface-container-low py-space-xl">
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-grid-gutter px-grid-margin text-on-surface-variant md:grid-cols-3">
|
||||
<div className="flex flex-col gap-space-sm">
|
||||
<span className="font-headline-md text-on-surface">{settings.brand}</span>
|
||||
<p className="text-body-sm">{settings.footer_about}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-space-sm">
|
||||
<span className="font-headline-md text-on-surface">Location</span>
|
||||
<p className="text-body-sm">
|
||||
{settings.address_line}
|
||||
<br />
|
||||
{settings.city_line}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-space-sm">
|
||||
<span className="font-headline-md text-on-surface">Hours</span>
|
||||
<p className="text-body-sm">{settings.hours_multiline}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto mt-space-lg max-w-7xl border-t border-outline-variant/20 px-grid-margin pt-space-md text-center text-body-sm text-on-surface-variant">
|
||||
© {new Date().getFullYear()} {settings.brand}. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import * as React from 'react'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: 'Home', to: '/', exact: true },
|
||||
{ label: 'Our Brews', to: '/our-brews', exact: false },
|
||||
{ label: 'Menu', to: '/menu', exact: false },
|
||||
{ label: 'Visit / Contact', to: '/visit', exact: false },
|
||||
] as const
|
||||
|
||||
const ACTIVE_CLASSES =
|
||||
'bg-primary-container text-on-primary-container font-bold rounded-lg px-space-md py-space-sm'
|
||||
const INACTIVE_CLASSES =
|
||||
'text-label-lg text-on-surface-variant hover:text-on-surface transition-colors'
|
||||
|
||||
export function SiteHeader() {
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 z-50 w-full bg-surface/80 shadow-[0_1px_8px_rgba(0,0,0,0.04)] backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-20 max-w-7xl items-center justify-between px-grid-margin">
|
||||
<Link to="/" className="flex items-center gap-space-md" onClick={() => setMobileOpen(false)}>
|
||||
<img
|
||||
alt="The Great Bear Logo"
|
||||
className="h-8 w-auto object-contain"
|
||||
src="/images/logo.jpg"
|
||||
/>
|
||||
<span className="font-headline-md text-on-surface">The Great Bear</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-space-lg md:flex">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
activeOptions={{ exact: item.exact }}
|
||||
className="transition-colors"
|
||||
activeProps={{ className: ACTIVE_CLASSES }}
|
||||
inactiveProps={{ className: INACTIVE_CLASSES }}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-space-md">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary">
|
||||
<span className="material-symbols-outlined text-on-primary text-[18px]">person</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle navigation"
|
||||
aria-expanded={mobileOpen}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg text-on-surface hover:bg-surface-container md:hidden"
|
||||
onClick={() => setMobileOpen((open) => !open)}
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{mobileOpen ? 'close' : 'menu'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobileOpen ? (
|
||||
<nav className="flex flex-col gap-space-xs border-t border-outline-variant/20 bg-surface px-grid-margin py-space-md md:hidden">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
activeOptions={{ exact: item.exact }}
|
||||
className="rounded-lg px-space-md py-space-sm transition-colors"
|
||||
activeProps={{ className: ACTIVE_CLASSES }}
|
||||
inactiveProps={{ className: 'text-label-lg text-on-surface-variant hover:text-on-surface' }}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as React from 'react'
|
||||
import { createReservation } from '~/lib/data'
|
||||
import type { Brew } from '~/lib/types'
|
||||
|
||||
const FLIGHT_PRICE = 699
|
||||
|
||||
export function TastingFlight({ brews }: { brews: Brew[] }) {
|
||||
const [selected, setSelected] = React.useState<Set<string>>(
|
||||
() => new Set(brews.slice(0, 4).map((brew) => brew.uid)),
|
||||
)
|
||||
const [status, setStatus] = React.useState<'idle' | 'sending' | 'done' | 'error'>('idle')
|
||||
|
||||
function toggle(uid: string) {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(uid)) {
|
||||
next.delete(uid)
|
||||
} else {
|
||||
next.add(uid)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const chosen = brews.filter((brew) => selected.has(brew.uid))
|
||||
if (chosen.length === 0) return
|
||||
setStatus('sending')
|
||||
try {
|
||||
await createReservation({
|
||||
data: {
|
||||
name: 'Tasting Flight Request',
|
||||
phone: '-',
|
||||
source: 'flight',
|
||||
requests: `Custom flight (${chosen.length} x 150ml): ${chosen
|
||||
.map((brew) => brew.name)
|
||||
.join(', ')}`,
|
||||
},
|
||||
})
|
||||
setStatus('done')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'done') {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-space-md rounded-xl bg-surface p-space-xl text-center shadow-2xl">
|
||||
<span className="material-symbols-outlined text-[48px] text-primary">sports_bar</span>
|
||||
<h2 className="font-headline-lg text-on-surface">Your custom flight is on its way</h2>
|
||||
<p className="text-body-md text-on-surface-variant">
|
||||
We've sent your tasting flight to the bar. See you soon!
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-space-lg rounded-xl bg-surface p-space-xl shadow-2xl">
|
||||
<div className="flex flex-col gap-space-xs text-center">
|
||||
<span className="text-label-lg uppercase tracking-widest text-primary">
|
||||
Taproom Experience
|
||||
</span>
|
||||
<h2 className="font-headline-lg text-on-surface">Build Your Custom Tasting Flight</h2>
|
||||
<p className="text-body-md text-on-surface-variant">
|
||||
Select any 4 brews for our signature wooden sampling paddle (150ml each).
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-space-md sm:grid-cols-2 md:grid-cols-3">
|
||||
{brews.map((brew) => (
|
||||
<label
|
||||
key={brew.uid}
|
||||
className="flex cursor-pointer items-center justify-between rounded-lg bg-surface-container p-space-md transition-colors hover:bg-surface-container-high"
|
||||
>
|
||||
<div className="flex items-center gap-space-md">
|
||||
<input
|
||||
checked={selected.has(brew.uid)}
|
||||
className="h-5 w-5 cursor-pointer rounded accent-primary"
|
||||
type="checkbox"
|
||||
onChange={() => toggle(brew.uid)}
|
||||
/>
|
||||
<span className="text-body-md font-medium text-on-surface">{brew.short_name}</span>
|
||||
</div>
|
||||
<span className="text-label-md text-primary">{brew.abv}%</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-between gap-space-md border-t border-outline-variant/20 pt-space-md sm:flex-row">
|
||||
<div className="text-body-sm text-on-surface-variant">
|
||||
Flight Price:{' '}
|
||||
<span className="font-bold text-headline-md text-on-surface">₹{FLIGHT_PRICE}</span>{' '}
|
||||
<span className="text-xs">(4 x 150ml pours)</span>
|
||||
</div>
|
||||
<button
|
||||
className="w-full rounded-lg bg-primary px-space-xl py-space-md text-label-lg font-medium text-on-primary shadow-lg transition-all hover:bg-primary/90 disabled:opacity-60 sm:w-auto"
|
||||
type="button"
|
||||
disabled={status === 'sending' || selected.size !== 4}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{status === 'sending'
|
||||
? 'Reserving…'
|
||||
: selected.size === 4
|
||||
? 'Reserve Tasting Flight'
|
||||
: `Select ${4 - selected.size} more brew${selected.size === 3 ? '' : 's'}`}
|
||||
</button>
|
||||
</div>
|
||||
{status === 'error' ? (
|
||||
<p className="text-body-sm text-error">
|
||||
Could not submit your flight right now — please try again or ask your server.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"settings": {
|
||||
"uid": "settings",
|
||||
"brand": "The Great Bear",
|
||||
"footer_about": "Craft microbrewery and upscale dining blending industrial warmth with artisanal heritage.",
|
||||
"address_line": "SCO 32, Sector 26, Madhya Marg",
|
||||
"city_line": "Chandigarh, 160026, India",
|
||||
"hours_line": "Monday – Sunday: 12:00 PM – 1:00 AM",
|
||||
"hours_multiline": "Monday – Sunday\n12:00 PM – 1:00 AM",
|
||||
"phone_primary": "+91 172 500 1234",
|
||||
"phone_secondary": "+91 98881 23456",
|
||||
"instagram_url": "https://www.instagram.com/thegreatbear.chandigarh/",
|
||||
"instagram_handle": "@thegreatbear.chandigarh",
|
||||
"maps_query": "The Great Bear Microbrewery, SCO 32, Sector 26, Madhya Marg, Chandigarh"
|
||||
},
|
||||
"stats": [
|
||||
{ "uid": "stat-taps", "value": "12+", "label": "On-Tap Varieties", "sort": 1 },
|
||||
{ "uid": "stat-malts", "value": "100%", "label": "Artisanal Malts", "sort": 2 },
|
||||
{ "uid": "stat-rooftop", "value": "3,000", "label": "Sq. Ft. Rooftop", "sort": 3 },
|
||||
{ "uid": "stat-music", "value": "7", "label": "Days Live Music", "sort": 4 }
|
||||
],
|
||||
"brews": [
|
||||
{
|
||||
"uid": "brew-grizzly-ipa",
|
||||
"name": "The Grizzly IPA",
|
||||
"style": "India Pale Ale",
|
||||
"short_name": "Grizzly IPA",
|
||||
"abv": 6.8,
|
||||
"ibu": 65,
|
||||
"description": "Bold tropical fruit aromas with a resinous pine backbone and a crisp, assertive bitter finish.",
|
||||
"tasting_notes": "Tropical fruit, Pine, Crisp bitterness",
|
||||
"pairing": "Spicy Tandoori Platters, Burgers",
|
||||
"image": "/images/beer-grizzly-ipa.jpg",
|
||||
"featured": true,
|
||||
"flagship": false,
|
||||
"on_tap": false,
|
||||
"seasonal": false,
|
||||
"seasonal_icon": "",
|
||||
"flight": false,
|
||||
"sort": 1
|
||||
},
|
||||
{
|
||||
"uid": "brew-moonlit-wit",
|
||||
"name": "Moonlit Wit",
|
||||
"style": "Belgian Wit",
|
||||
"short_name": "Moonlit Wit",
|
||||
"abv": 5.2,
|
||||
"ibu": 15,
|
||||
"description": "Unfiltered wheat ale spiced with organic Curaçao orange peel and cracked coriander seeds.",
|
||||
"tasting_notes": "Orange peel, Coriander, Wheat",
|
||||
"pairing": "Seafood, Salads",
|
||||
"image": "/images/beer-moonlit-wit.jpg",
|
||||
"featured": true,
|
||||
"flagship": false,
|
||||
"on_tap": false,
|
||||
"seasonal": false,
|
||||
"seasonal_icon": "",
|
||||
"flight": false,
|
||||
"sort": 2
|
||||
},
|
||||
{
|
||||
"uid": "brew-black-bear-stout",
|
||||
"name": "Black Bear Stout",
|
||||
"style": "Imperial Stout",
|
||||
"short_name": "Black Bear Stout",
|
||||
"abv": 7.4,
|
||||
"ibu": 45,
|
||||
"description": "Decadent notes of dark chocolate, espresso, and roasted barley with a velvety, full-bodied mouthfeel.",
|
||||
"tasting_notes": "Dark chocolate, Espresso, Roasted barley",
|
||||
"pairing": "Desserts, Smoked Meats",
|
||||
"image": "/images/beer-black-bear-stout.jpg",
|
||||
"featured": true,
|
||||
"flagship": false,
|
||||
"on_tap": false,
|
||||
"seasonal": false,
|
||||
"seasonal_icon": "",
|
||||
"flight": false,
|
||||
"sort": 3
|
||||
},
|
||||
{
|
||||
"uid": "brew-chandigarh-lager",
|
||||
"name": "Chandigarh Lager",
|
||||
"style": "Vienna Lager",
|
||||
"short_name": "Chandigarh Lager",
|
||||
"abv": 4.8,
|
||||
"ibu": 22,
|
||||
"description": "Smooth, balanced amber lager featuring toasted bready malt flavors and a crisp, refreshing finish.",
|
||||
"tasting_notes": "Toasted malt, Caramel, Crisp finish",
|
||||
"pairing": "Wood-fired Pizzas, Pretzels",
|
||||
"image": "/images/beer-chandigarh-lager.jpg",
|
||||
"featured": true,
|
||||
"flagship": false,
|
||||
"on_tap": false,
|
||||
"seasonal": false,
|
||||
"seasonal_icon": "",
|
||||
"flight": false,
|
||||
"sort": 4
|
||||
},
|
||||
{
|
||||
"uid": "brew-chandigarh-ipa",
|
||||
"name": "Chandigarh IPA",
|
||||
"style": "IPA",
|
||||
"short_name": "Chandigarh IPA",
|
||||
"abv": 6.8,
|
||||
"ibu": 65,
|
||||
"description": "Our signature West Coast style IPA loaded with American hops delivering an assertive punch of vibrant citrus zest, crushed pine needles, and a lingering resinous finish.",
|
||||
"tasting_notes": "Grapefruit, Pine, Resinous Hop",
|
||||
"pairing": "Spicy Tandoori Platters, Burgers",
|
||||
"image": "/images/brew-flagship-ipa.jpg",
|
||||
"featured": false,
|
||||
"flagship": true,
|
||||
"on_tap": false,
|
||||
"seasonal": false,
|
||||
"seasonal_icon": "",
|
||||
"flight": true,
|
||||
"sort": 5
|
||||
},
|
||||
{
|
||||
"uid": "brew-belgian-witbier",
|
||||
"name": "Belgian Witbier",
|
||||
"style": "Wheat Ale",
|
||||
"short_name": "Belgian Witbier",
|
||||
"abv": 4.8,
|
||||
"ibu": 15,
|
||||
"description": "A refreshing, hazy wheat beer spiced delicately with Curaçao orange peel and crushed coriander seeds for a zesty, citrus-forward finish.",
|
||||
"tasting_notes": "Orange peel, Corianders, Biscuit",
|
||||
"pairing": "Seafood, Herb-roasted Chicken",
|
||||
"image": "/images/brew-belgian-witbier.jpg",
|
||||
"featured": false,
|
||||
"flagship": false,
|
||||
"on_tap": true,
|
||||
"seasonal": false,
|
||||
"seasonal_icon": "",
|
||||
"flight": true,
|
||||
"sort": 6
|
||||
},
|
||||
{
|
||||
"uid": "brew-coffee-oatmeal-stout",
|
||||
"name": "Coffee Oatmeal Stout",
|
||||
"style": "Dark & Roasty",
|
||||
"short_name": "Coffee Stout",
|
||||
"abv": 6.2,
|
||||
"ibu": 35,
|
||||
"description": "Silky smooth body infused with locally roasted artisanal cold brew coffee and flaked oats. Rich aromas of dark chocolate and espresso.",
|
||||
"tasting_notes": "Dark Chocolate, Espresso, Oats",
|
||||
"pairing": "Tiramisu, Smoked Meats",
|
||||
"image": "/images/brew-coffee-stout.jpg",
|
||||
"featured": false,
|
||||
"flagship": false,
|
||||
"on_tap": true,
|
||||
"seasonal": false,
|
||||
"seasonal_icon": "",
|
||||
"flight": true,
|
||||
"sort": 7
|
||||
},
|
||||
{
|
||||
"uid": "brew-munich-lager",
|
||||
"name": "Munich Lager",
|
||||
"style": "Crisp & Clean",
|
||||
"short_name": "Munich Lager",
|
||||
"abv": 5.0,
|
||||
"ibu": 22,
|
||||
"description": "Lagered for weeks to achieve absolute clarity and smoothness. Features subtle bready malt sweetness balanced with noble German hop bitterness.",
|
||||
"tasting_notes": "Bready malt, Floral hops, Crisp",
|
||||
"pairing": "Wood-fired Pizzas, Pretzels",
|
||||
"image": "/images/brew-munich-lager.jpg",
|
||||
"featured": false,
|
||||
"flagship": false,
|
||||
"on_tap": true,
|
||||
"seasonal": false,
|
||||
"seasonal_icon": "",
|
||||
"flight": true,
|
||||
"sort": 8
|
||||
},
|
||||
{
|
||||
"uid": "brew-mango-sour",
|
||||
"name": "Alphonso Mango Sour Ale",
|
||||
"style": "Kettle Sour",
|
||||
"short_name": "Mango Sour",
|
||||
"abv": 5.5,
|
||||
"ibu": 0,
|
||||
"description": "Tart kettle sour infused with pure Ratnagiri Alphonso mango puree. Perfectly balanced sweet tropical fruit notes with a clean, lip-smacking acidity.",
|
||||
"tasting_notes": "Alphonso mango, Tart, Tropical",
|
||||
"pairing": "Street Chaat, Fresh Salads",
|
||||
"image": "/images/seasonal-flight.jpg",
|
||||
"featured": false,
|
||||
"flagship": false,
|
||||
"on_tap": false,
|
||||
"seasonal": true,
|
||||
"seasonal_icon": "local_florist",
|
||||
"flight": true,
|
||||
"sort": 9
|
||||
},
|
||||
{
|
||||
"uid": "brew-hibiscus-berry-blonde",
|
||||
"name": "Hibiscus Berry Blonde",
|
||||
"style": "Blonde Ale",
|
||||
"short_name": "Hibiscus Berry",
|
||||
"abv": 4.5,
|
||||
"ibu": 0,
|
||||
"description": "Light blonde ale steeped with organic hibiscus flowers and wild forest berries, imparting a gorgeous ruby hue and tart floral finish.",
|
||||
"tasting_notes": "Hibiscus, Forest berries, Floral",
|
||||
"pairing": "Cheese Boards, Grilled Fish",
|
||||
"image": "/images/seasonal-flight.jpg",
|
||||
"featured": false,
|
||||
"flagship": false,
|
||||
"on_tap": false,
|
||||
"seasonal": true,
|
||||
"seasonal_icon": "eco",
|
||||
"flight": true,
|
||||
"sort": 10
|
||||
}
|
||||
],
|
||||
"experiences": [
|
||||
{
|
||||
"uid": "exp-decor",
|
||||
"title": "Industrial Chic Decor",
|
||||
"description": "Raw steel, reclaimed timber, and ambient copper accents create a sophisticated yet grounded atmosphere for any occasion.",
|
||||
"icon": "architecture",
|
||||
"image": "/images/experience-decor.jpg",
|
||||
"sort": 1
|
||||
},
|
||||
{
|
||||
"uid": "exp-rooftop",
|
||||
"title": "Starlit Rooftop Seating",
|
||||
"description": "Breathe in Chandigarh's evening breeze on our expansive terrace lounge, complete with cozy seating and panoramic views.",
|
||||
"icon": "deck",
|
||||
"image": "/images/experience-rooftop.jpg",
|
||||
"sort": 2
|
||||
},
|
||||
{
|
||||
"uid": "exp-dj",
|
||||
"title": "Live DJ & Acoustic Nights",
|
||||
"description": "From soulful weekend acoustic sessions to high-energy late-night DJ sets, our soundscape elevates every pour.",
|
||||
"icon": "headphones",
|
||||
"image": "/images/experience-dj.jpg",
|
||||
"sort": 3
|
||||
}
|
||||
],
|
||||
"testimonials": [
|
||||
{
|
||||
"uid": "testimonial-aarav",
|
||||
"name": "Aarav Randhawa",
|
||||
"role": "Local Food & Beer Critic",
|
||||
"quote": "The Grizzly IPA is hands down the best craft beer in Punjab. The ambiance on the rooftop during winter evenings is unbeatable.",
|
||||
"rating": 5,
|
||||
"sort": 1
|
||||
},
|
||||
{
|
||||
"uid": "testimonial-neha",
|
||||
"name": "Neha Sharma",
|
||||
"role": "Architect & Regular Patron",
|
||||
"quote": "Sector 26 has many spots, but The Great Bear stands out for its incredible wood-fired pizzas and impeccable beer menu.",
|
||||
"rating": 5,
|
||||
"sort": 2
|
||||
},
|
||||
{
|
||||
"uid": "testimonial-vikramjit",
|
||||
"name": "Vikramjit Kahlon",
|
||||
"role": "Entrepreneur",
|
||||
"quote": "World-class brewing standards right here in Chandigarh. The staff is extremely knowledgeable about pairings and brewing notes.",
|
||||
"rating": 5,
|
||||
"sort": 3
|
||||
}
|
||||
],
|
||||
"menu_categories": [
|
||||
{ "uid": "cat-pizza", "slug": "pizza", "name": "Wood-Fired Pizzas", "sort": 1 },
|
||||
{ "uid": "cat-tapas", "slug": "tapas", "name": "Tandoori & Tapas", "sort": 2 },
|
||||
{ "uid": "cat-burgers", "slug": "burgers", "name": "Burgers & Sliders", "sort": 3 },
|
||||
{ "uid": "cat-desserts", "slug": "desserts", "name": "Artisanal Desserts", "sort": 4 }
|
||||
],
|
||||
"menu_items": [
|
||||
{
|
||||
"uid": "item-truffle-burrata",
|
||||
"category": "pizza",
|
||||
"name": "The Truffle Burrata Wood-Fired Pizza",
|
||||
"description": "Hand-stretched sourdough crust, black truffle cream, fresh Fior di Latte, creamy burrata center, wild arugula, and aged parmesan shavings.",
|
||||
"price": 595,
|
||||
"pairing": "Bear's Witbier",
|
||||
"tag": "Wood-Fired",
|
||||
"image": "/images/menu-pizza-burrata.jpg",
|
||||
"sort": 1
|
||||
},
|
||||
{
|
||||
"uid": "item-calabrian-salami",
|
||||
"category": "pizza",
|
||||
"name": "Fiery Calabrian Salami Pizza",
|
||||
"description": "San Marzano tomato reduction, spicy artisan Calabrian salami, hot honey infusion, fresh basil, and molten mozzarella.",
|
||||
"price": 545,
|
||||
"pairing": "Imperial IPA",
|
||||
"tag": "Wood-Fired",
|
||||
"image": "/images/menu-pizza-salami.jpg",
|
||||
"sort": 2
|
||||
},
|
||||
{
|
||||
"uid": "item-tandoori-lamb",
|
||||
"category": "tapas",
|
||||
"name": "Smoked Tandoori Lamb Chops",
|
||||
"description": "New Zealand lamb chops marinated in Kashmiri chili, hung curd, and roasted garlic, charred over hot coals in our clay tandoor oven.",
|
||||
"price": 725,
|
||||
"pairing": "Robust Oatmeal Stout",
|
||||
"tag": "Tandoori",
|
||||
"image": "/images/menu-tandoori-lamb.jpg",
|
||||
"sort": 3
|
||||
},
|
||||
{
|
||||
"uid": "item-paneer-skewers",
|
||||
"category": "tapas",
|
||||
"name": "Crispy Tandoori Paneer Skewers",
|
||||
"description": "Farmhouse cottage cheese chunks stuffed with mint chutney, skewered with bell peppers and onions, roasted over live embers.",
|
||||
"price": 475,
|
||||
"pairing": "Classic Czech Pilsner",
|
||||
"tag": "Tapas",
|
||||
"image": "/images/menu-paneer-skewers.jpg",
|
||||
"sort": 4
|
||||
},
|
||||
{
|
||||
"uid": "item-bear-burger",
|
||||
"category": "burgers",
|
||||
"name": "The Great Bear Truffle Burger",
|
||||
"description": "Double dry-aged Angus beef patties, molten cheddar cheese, thick-cut bacon, caramelized bourbon onions, and black truffle aioli on a toasted brioche bun.",
|
||||
"price": 525,
|
||||
"pairing": "American Pale Ale",
|
||||
"tag": "Burger",
|
||||
"image": "/images/menu-bear-burger.jpg",
|
||||
"sort": 5
|
||||
},
|
||||
{
|
||||
"uid": "item-chicken-sliders",
|
||||
"category": "burgers",
|
||||
"name": "Hot Honey Fried Chicken Sliders (3x)",
|
||||
"description": "Crispy buttermilk marinated chicken thighs glazed with spicy hot honey, topped with crunchy pickled jalapeño slaw and garlic mayo.",
|
||||
"price": 495,
|
||||
"pairing": "Session IPA",
|
||||
"tag": "Sliders",
|
||||
"image": "/images/menu-chicken-sliders.jpg",
|
||||
"sort": 6
|
||||
},
|
||||
{
|
||||
"uid": "item-stout-brownie",
|
||||
"category": "desserts",
|
||||
"name": "Stout-Infused Chocolate Brownie",
|
||||
"description": "Warm Valrhona dark chocolate brownie baked with our signature oatmeal stout, served with Madagascar vanilla bean ice cream and salted bourbon caramel.",
|
||||
"price": 345,
|
||||
"pairing": "Imperial Stout",
|
||||
"tag": "Dessert",
|
||||
"image": "/images/menu-stout-brownie.jpg",
|
||||
"sort": 7
|
||||
},
|
||||
{
|
||||
"uid": "item-tarte-tatin",
|
||||
"category": "desserts",
|
||||
"name": "Bourbon Apple Tarte Tatin",
|
||||
"description": "Caramelized honey-crisp apples baked in flaky puff pastry, flambéed tableside with Kentucky bourbon, accompanied by clotted cream.",
|
||||
"price": 395,
|
||||
"pairing": "Belgian Tripel",
|
||||
"tag": "Dessert",
|
||||
"image": "/images/menu-tarte-tatin.jpg",
|
||||
"sort": 8
|
||||
}
|
||||
],
|
||||
"pairings": [
|
||||
{
|
||||
"uid": "pairing-hop-heat",
|
||||
"title": "Hop & Heat",
|
||||
"description": "High-IBU IPAs act as a palate cleanser, cutting effortlessly through the rich fats of wood-fired cheeses and charred tandoori spices.",
|
||||
"sort": 1
|
||||
},
|
||||
{
|
||||
"uid": "pairing-malt-roast",
|
||||
"title": "Malt & Roast",
|
||||
"description": "Dark stouts and porters echo the caramelization of seared meats and deepen the chocolate notes in our artisanal house desserts.",
|
||||
"sort": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
createReservationRecord,
|
||||
fetchBrews,
|
||||
fetchExperiences,
|
||||
fetchMenuCategories,
|
||||
fetchMenuItems,
|
||||
fetchPairings,
|
||||
fetchSettings,
|
||||
fetchStats,
|
||||
fetchTestimonials,
|
||||
} from './pb'
|
||||
|
||||
export const getSettings = createServerFn({ method: 'GET' }).handler(() => fetchSettings())
|
||||
|
||||
export const getHomeData = createServerFn({ method: 'GET' }).handler(async () => {
|
||||
const [stats, brews, experiences, testimonials] = await Promise.all([
|
||||
fetchStats(),
|
||||
fetchBrews(),
|
||||
fetchExperiences(),
|
||||
fetchTestimonials(),
|
||||
])
|
||||
return {
|
||||
stats,
|
||||
featuredBrews: brews.filter((b) => b.featured).slice(0, 4),
|
||||
experiences,
|
||||
testimonials,
|
||||
}
|
||||
})
|
||||
|
||||
export const getBrewsData = createServerFn({ method: 'GET' }).handler(async () => {
|
||||
const brews = await fetchBrews()
|
||||
return {
|
||||
flagship: brews.find((b) => b.flagship) ?? null,
|
||||
coreBrews: brews.filter((b) => b.on_tap),
|
||||
seasonalBrews: brews.filter((b) => b.seasonal),
|
||||
flightBrews: brews.filter((b) => b.flight),
|
||||
}
|
||||
})
|
||||
|
||||
export const getMenuData = createServerFn({ method: 'GET' }).handler(async () => {
|
||||
const [categories, items, pairings] = await Promise.all([
|
||||
fetchMenuCategories(),
|
||||
fetchMenuItems(),
|
||||
fetchPairings(),
|
||||
])
|
||||
return { categories, items, pairings }
|
||||
})
|
||||
|
||||
export const getVisitData = createServerFn({ method: 'GET' }).handler(() => fetchSettings())
|
||||
|
||||
const reservationSchema = z.object({
|
||||
name: z.string().trim().min(2, 'Please tell us your name'),
|
||||
phone: z.string().trim().min(7, 'Please provide a valid phone number'),
|
||||
source: z.enum(['table', 'quick', 'flight']),
|
||||
date: z.string().optional(),
|
||||
time: z.string().optional(),
|
||||
guests: z.string().optional(),
|
||||
requests: z.string().optional(),
|
||||
})
|
||||
|
||||
export const createReservation = createServerFn({ method: 'POST' })
|
||||
.validator(reservationSchema)
|
||||
.handler(async ({ data }) => {
|
||||
await createReservationRecord(data)
|
||||
return { ok: true as const }
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Server-side PocketBase access.
|
||||
*
|
||||
* All content lives in PocketBase; this module fetches it over the local
|
||||
* HTTP API. If PocketBase is unreachable (e.g. during an image build check
|
||||
* or a maintenance restart) every fetch falls back to the bundled seed
|
||||
* content so the site still renders.
|
||||
*/
|
||||
import seed from '~/data/content.json'
|
||||
import type {
|
||||
Brew,
|
||||
Experience,
|
||||
MenuCategory,
|
||||
MenuItem,
|
||||
Pairing,
|
||||
Settings,
|
||||
Stat,
|
||||
Testimonial,
|
||||
} from './types'
|
||||
|
||||
const PB_URL = process.env.PB_URL ?? 'http://127.0.0.1:8090'
|
||||
|
||||
const TIMEOUT_MS = 3000
|
||||
|
||||
interface PbRecords<T> {
|
||||
items: T[]
|
||||
}
|
||||
|
||||
async function pbList<T>(collection: string): Promise<T[] | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${PB_URL}/api/collections/${collection}/records?sort=sort&perPage=200`,
|
||||
{ signal: AbortSignal.timeout(TIMEOUT_MS) },
|
||||
)
|
||||
if (!res.ok) return null
|
||||
const json = (await res.json()) as PbRecords<T>
|
||||
return json.items
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function orFallback<T>(items: T[] | null, fallback: T[]): T[] {
|
||||
return items && items.length > 0 ? items : fallback
|
||||
}
|
||||
|
||||
export async function fetchSettings(): Promise<Settings> {
|
||||
const items = await pbList<Settings>('settings')
|
||||
return items?.[0] ?? seed.settings
|
||||
}
|
||||
|
||||
export async function fetchStats(): Promise<Stat[]> {
|
||||
return orFallback(await pbList<Stat>('stats'), seed.stats)
|
||||
}
|
||||
|
||||
export async function fetchBrews(): Promise<Brew[]> {
|
||||
return orFallback(await pbList<Brew>('brews'), seed.brews)
|
||||
}
|
||||
|
||||
export async function fetchExperiences(): Promise<Experience[]> {
|
||||
return orFallback(await pbList<Experience>('experiences'), seed.experiences)
|
||||
}
|
||||
|
||||
export async function fetchTestimonials(): Promise<Testimonial[]> {
|
||||
return orFallback(await pbList<Testimonial>('testimonials'), seed.testimonials)
|
||||
}
|
||||
|
||||
export async function fetchMenuCategories(): Promise<MenuCategory[]> {
|
||||
return orFallback(await pbList<MenuCategory>('menu_categories'), seed.menu_categories)
|
||||
}
|
||||
|
||||
export async function fetchMenuItems(): Promise<MenuItem[]> {
|
||||
return orFallback(await pbList<MenuItem>('menu_items'), seed.menu_items)
|
||||
}
|
||||
|
||||
export async function fetchPairings(): Promise<Pairing[]> {
|
||||
return orFallback(await pbList<Pairing>('pairings'), seed.pairings)
|
||||
}
|
||||
|
||||
export interface CreateReservationPayload {
|
||||
name: string
|
||||
phone: string
|
||||
source: string
|
||||
date?: string
|
||||
time?: string
|
||||
guests?: string
|
||||
requests?: string
|
||||
}
|
||||
|
||||
export async function createReservationRecord(payload: CreateReservationPayload): Promise<void> {
|
||||
const res = await fetch(`${PB_URL}/api/collections/reservations/records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`PocketBase rejected the reservation (HTTP ${res.status})`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export interface Settings {
|
||||
uid: string
|
||||
brand: string
|
||||
footer_about: string
|
||||
address_line: string
|
||||
city_line: string
|
||||
hours_line: string
|
||||
hours_multiline: string
|
||||
phone_primary: string
|
||||
phone_secondary: string
|
||||
instagram_url: string
|
||||
instagram_handle: string
|
||||
maps_query: string
|
||||
}
|
||||
|
||||
export interface Stat {
|
||||
uid: string
|
||||
value: string
|
||||
label: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
export interface Brew {
|
||||
uid: string
|
||||
name: string
|
||||
style: string
|
||||
short_name: string
|
||||
abv: number
|
||||
ibu: number
|
||||
description: string
|
||||
tasting_notes: string
|
||||
pairing: string
|
||||
image: string
|
||||
featured: boolean
|
||||
flagship: boolean
|
||||
on_tap: boolean
|
||||
seasonal: boolean
|
||||
seasonal_icon: string
|
||||
flight: boolean
|
||||
sort: number
|
||||
}
|
||||
|
||||
export interface Experience {
|
||||
uid: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
image: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
export interface Testimonial {
|
||||
uid: string
|
||||
name: string
|
||||
role: string
|
||||
quote: string
|
||||
rating: number
|
||||
sort: number
|
||||
}
|
||||
|
||||
export interface MenuCategory {
|
||||
uid: string
|
||||
slug: string
|
||||
name: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
uid: string
|
||||
category: string
|
||||
name: string
|
||||
description: string
|
||||
price: number
|
||||
pairing: string
|
||||
tag: string
|
||||
image: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
export interface Pairing {
|
||||
uid: string
|
||||
title: string
|
||||
description: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
export type ReservationSource = 'table' | 'quick' | 'flight'
|
||||
|
||||
export interface ReservationInput {
|
||||
name: string
|
||||
phone: string
|
||||
source: ReservationSource
|
||||
date?: string
|
||||
time?: string
|
||||
guests?: string
|
||||
requests?: string
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as MenuRouteImport } from './routes/menu'
|
||||
import { Route as OurBrewsRouteImport } from './routes/our-brews'
|
||||
import { Route as VisitRouteImport } from './routes/visit'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const MenuRoute = MenuRouteImport.update({
|
||||
id: '/menu',
|
||||
path: '/menu',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const OurBrewsRoute = OurBrewsRouteImport.update({
|
||||
id: '/our-brews',
|
||||
path: '/our-brews',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const VisitRoute = VisitRouteImport.update({
|
||||
id: '/visit',
|
||||
path: '/visit',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/menu': typeof MenuRoute
|
||||
'/our-brews': typeof OurBrewsRoute
|
||||
'/visit': typeof VisitRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/menu': typeof MenuRoute
|
||||
'/our-brews': typeof OurBrewsRoute
|
||||
'/visit': typeof VisitRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/menu': typeof MenuRoute
|
||||
'/our-brews': typeof OurBrewsRoute
|
||||
'/visit': typeof VisitRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/menu' | '/our-brews' | '/visit'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/menu' | '/our-brews' | '/visit'
|
||||
id: '__root__' | '/' | '/menu' | '/our-brews' | '/visit'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
MenuRoute: typeof MenuRoute
|
||||
OurBrewsRoute: typeof OurBrewsRoute
|
||||
VisitRoute: typeof VisitRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/menu': {
|
||||
id: '/menu'
|
||||
path: '/menu'
|
||||
fullPath: '/menu'
|
||||
preLoaderRoute: typeof MenuRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/our-brews': {
|
||||
id: '/our-brews'
|
||||
path: '/our-brews'
|
||||
fullPath: '/our-brews'
|
||||
preLoaderRoute: typeof OurBrewsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/visit': {
|
||||
id: '/visit'
|
||||
path: '/visit'
|
||||
fullPath: '/visit'
|
||||
preLoaderRoute: typeof VisitRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
MenuRoute: MenuRoute,
|
||||
OurBrewsRoute: OurBrewsRoute,
|
||||
VisitRoute: VisitRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export function getRouter() {
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent',
|
||||
scrollRestoration: true,
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { HeadContent, Outlet, Scripts, createRootRoute } from '@tanstack/react-router'
|
||||
import * as React from 'react'
|
||||
import appCss from '~/styles/app.css?url'
|
||||
import { SiteHeader } from '~/components/site-header'
|
||||
import { SiteFooter } from '~/components/site-footer'
|
||||
import { getSettings } from '~/lib/data'
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ name: 'theme-color', content: '#1a120b' },
|
||||
],
|
||||
links: [{ rel: 'stylesheet', href: appCss }],
|
||||
scripts: [],
|
||||
}),
|
||||
loader: () => getSettings(),
|
||||
shellComponent: RootDocument,
|
||||
component: SiteLayout,
|
||||
})
|
||||
|
||||
function SiteLayout() {
|
||||
const settings = Route.useLoaderData()
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="w-full bg-surface pt-20">
|
||||
<Outlet />
|
||||
</main>
|
||||
<SiteFooter settings={settings} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { getHomeData } from '~/lib/data'
|
||||
import { QuickReserveForm } from '~/components/quick-reserve-form'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: 'The Great Bear Microbrewery | Chandigarh’s Finest Craft Brewery',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
content:
|
||||
'Handcrafted small-batch brews, wood-fired gastronomy, and the city’s most vibrant rooftop lounge — SCO 32, Sector 26, Chandigarh.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
loader: () => getHomeData(),
|
||||
component: HomePage,
|
||||
})
|
||||
|
||||
function HomePage() {
|
||||
const { stats, featuredBrews, experiences, testimonials } = Route.useLoaderData()
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{/* Cinematic Hero Section */}
|
||||
<section className="relative -mt-20 flex min-h-[870px] w-full items-center justify-center overflow-hidden pt-20">
|
||||
<div
|
||||
className="absolute inset-0 z-0 scale-105 bg-cover bg-center transition-transform duration-1000"
|
||||
style={{ backgroundImage: "url('/images/hero-home.jpg')" }}
|
||||
/>
|
||||
<div className="absolute inset-0 z-10 bg-gradient-to-t from-surface via-surface/60 to-transparent" />
|
||||
<div className="relative z-20 mx-auto flex max-w-7xl flex-col items-center gap-space-lg px-grid-margin py-space-2xl text-center">
|
||||
<div className="inline-flex items-center gap-space-sm rounded-full border border-outline-variant/30 bg-surface-container/80 px-space-md py-space-xs text-label-lg uppercase tracking-widest text-primary backdrop-blur-md">
|
||||
<span className="material-symbols-outlined text-[16px]">local_bar</span>
|
||||
SCO 32, Sector 26, Chandigarh
|
||||
</div>
|
||||
<h1 className="max-w-4xl font-headline-xl text-headline-xl tracking-tight text-on-surface">
|
||||
Chandigarh's Finest <span className="text-primary">Craft Brewery</span>
|
||||
</h1>
|
||||
<p className="max-w-2xl font-body-lg text-body-lg text-on-surface-variant">
|
||||
Where industrial warmth meets artisanal mastery. Experience handcrafted small-batch
|
||||
brews, wood-fired gastronomy, and the city's most vibrant rooftop lounge.
|
||||
</p>
|
||||
<div className="mt-space-sm flex flex-wrap items-center justify-center gap-space-md">
|
||||
<a
|
||||
className="flex items-center gap-space-sm rounded-lg bg-primary px-space-lg py-space-md font-label-lg text-on-primary transition-all hover:bg-primary-container hover:shadow-[0_0_20px_rgba(217,119,6,0.4)]"
|
||||
href="#brews"
|
||||
>
|
||||
<span>Explore Brews</span>
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_forward</span>
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-space-sm rounded-lg bg-surface-container-high/80 font-label-lg text-on-surface backdrop-blur-md transition-all hover:bg-surface-bright"
|
||||
href="#reserve"
|
||||
>
|
||||
<span>Reserve a Table</span>
|
||||
<span className="material-symbols-outlined text-[18px]">event_seat</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Stats / Highlights Bar */}
|
||||
<section className="w-full bg-surface-container-low py-space-xl">
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-2 gap-grid-gutter px-grid-margin text-center md:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.uid}
|
||||
className="flex flex-col gap-space-xs rounded-xl bg-surface-container/50 p-space-md"
|
||||
>
|
||||
<span className="font-headline-xl text-primary">{stat.value}</span>
|
||||
<span className="font-label-lg uppercase text-on-surface-variant">{stat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Artisan Beers */}
|
||||
<section className="w-full py-space-2xl" id="brews">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-space-xl px-grid-margin">
|
||||
<div className="flex flex-col justify-between gap-space-md md:flex-row md:items-end">
|
||||
<div className="flex flex-col gap-space-xs">
|
||||
<span className="text-label-lg uppercase tracking-widest text-primary">
|
||||
Fresh From The Cellar
|
||||
</span>
|
||||
<h2 className="font-headline-lg text-on-surface">Featured Artisan Beers</h2>
|
||||
</div>
|
||||
<p className="max-w-md text-body-md text-on-surface-variant">
|
||||
Brewed on-site using imported German hops, crystal-clear Himalayan water, and
|
||||
traditional time-honored techniques.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-grid-gutter md:grid-cols-2 lg:grid-cols-4">
|
||||
{featuredBrews.map((brew) => (
|
||||
<div
|
||||
key={brew.uid}
|
||||
className="group flex flex-col overflow-hidden rounded-xl bg-surface-container shadow-md transition-all duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div
|
||||
className="relative h-64 w-full bg-cover bg-center"
|
||||
style={{ backgroundImage: `url('${brew.image}')` }}
|
||||
>
|
||||
<div className="absolute right-space-sm top-space-sm rounded bg-surface/80 px-space-sm py-space-xs text-label-md uppercase text-primary backdrop-blur-sm">
|
||||
ABV {brew.abv}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-between gap-space-md p-space-lg">
|
||||
<div className="flex flex-col gap-space-xs">
|
||||
<div className="text-label-md uppercase tracking-wider text-primary">
|
||||
{brew.style}
|
||||
</div>
|
||||
<h3 className="font-headline-md text-on-surface">{brew.name}</h3>
|
||||
<p className="text-body-sm text-on-surface-variant">{brew.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-outline-variant/10 pt-space-md">
|
||||
<span className="text-label-md text-on-surface-variant">IBU: {brew.ibu}</span>
|
||||
<a
|
||||
href="/our-brews"
|
||||
className="flex items-center gap-space-xs text-label-lg text-primary group-hover:underline"
|
||||
>
|
||||
Tasting Notes{' '}
|
||||
<span className="material-symbols-outlined text-[16px]">chevron_right</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ambiance Highlights (Bento Grid layout) */}
|
||||
<section className="w-full bg-surface-container-low py-space-2xl">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-space-xl px-grid-margin">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-space-xs text-center">
|
||||
<span className="text-label-lg uppercase tracking-widest text-primary">
|
||||
The Experience
|
||||
</span>
|
||||
<h2 className="font-headline-lg text-on-surface">Designed for Unforgettable Nights</h2>
|
||||
<p className="text-body-md text-on-surface-variant">
|
||||
Sector 26's premier destination for craft connoisseurs and social gatherings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-grid-gutter md:grid-cols-3">
|
||||
{experiences.map((exp) => (
|
||||
<div key={exp.uid} className="group flex flex-col overflow-hidden rounded-xl bg-surface-container">
|
||||
<div
|
||||
className="h-72 w-full bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
|
||||
style={{ backgroundImage: `url('${exp.image}')` }}
|
||||
/>
|
||||
<div className="flex flex-col gap-space-sm p-space-lg">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/20 text-primary">
|
||||
<span className="material-symbols-outlined">{exp.icon}</span>
|
||||
</div>
|
||||
<h3 className="font-headline-md text-on-surface">{exp.title}</h3>
|
||||
<p className="text-body-sm text-on-surface-variant">{exp.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Customer Testimonials */}
|
||||
<section className="w-full py-space-2xl">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-space-xl px-grid-margin">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-space-xs text-center">
|
||||
<span className="text-label-lg uppercase tracking-widest text-primary">
|
||||
Testimonials
|
||||
</span>
|
||||
<h2 className="font-headline-lg text-on-surface">Loved by Chandigarh</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-grid-gutter md:grid-cols-3">
|
||||
{testimonials.map((t) => (
|
||||
<div
|
||||
key={t.uid}
|
||||
className="flex flex-col justify-between gap-space-lg rounded-xl bg-surface-container p-space-lg"
|
||||
>
|
||||
<div className="flex flex-col gap-space-md">
|
||||
<div className="flex items-center gap-space-xs text-primary">
|
||||
{Array.from({ length: t.rating }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="material-symbols-outlined text-[18px]"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
star
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-body-md italic text-on-surface">"{t.quote}"</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-space-md border-t border-outline-variant/10 pt-space-md">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-bright font-headline-md text-primary">
|
||||
{t.name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-label-lg text-on-surface">{t.name}</span>
|
||||
<span className="text-label-md text-on-surface-variant">{t.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Reserve / CTA Section */}
|
||||
<section
|
||||
className="relative w-full overflow-hidden bg-surface-container-low py-space-2xl"
|
||||
id="reserve"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center opacity-10"
|
||||
style={{ backgroundImage: "url('/images/reserve-bg.jpg')" }}
|
||||
/>
|
||||
<div className="relative z-10 mx-auto flex max-w-4xl flex-col items-center gap-space-lg px-grid-margin text-center">
|
||||
<div className="inline-flex items-center gap-space-sm rounded-full bg-primary/20 px-space-md py-space-xs text-label-lg uppercase tracking-widest text-primary">
|
||||
<span className="material-symbols-outlined text-[16px]">schedule</span>
|
||||
Book Your Experience
|
||||
</div>
|
||||
<h2 className="font-headline-xl text-on-surface">Join Us For a Pint Tonight</h2>
|
||||
<p className="max-w-xl text-body-lg text-on-surface-variant">
|
||||
Whether it's an intimate dinner or a large celebration, secure your table at
|
||||
Chandigarh's finest microbrewery in advance.
|
||||
</p>
|
||||
<div className="mt-space-md w-full max-w-xl">
|
||||
<QuickReserveForm />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { getMenuData } from '~/lib/data'
|
||||
import { MenuBrowser } from '~/components/menu-browser'
|
||||
|
||||
export const Route = createFileRoute('/menu')({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: 'Menu | The Great Bear Microbrewery' },
|
||||
{
|
||||
name: 'description',
|
||||
content:
|
||||
'Wood-fired pizzas, tandoori & tapas, burgers and artisanal desserts — every dish designed to complement our house beers.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
loader: () => getMenuData(),
|
||||
component: MenuPage,
|
||||
})
|
||||
|
||||
function MenuPage() {
|
||||
const { categories, items, pairings } = Route.useLoaderData()
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{/* Hero / Category Header */}
|
||||
<section className="relative w-full overflow-hidden bg-surface-container-lowest py-space-xl">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center opacity-20"
|
||||
style={{ backgroundImage: "url('/images/menu-hero.jpg')" }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-surface via-surface/80 to-transparent" />
|
||||
<div className="relative mx-auto flex max-w-7xl flex-col items-center px-grid-margin text-center">
|
||||
<span className="mb-space-sm text-label-lg uppercase tracking-widest text-primary">
|
||||
Artisanal Gastropub Menu
|
||||
</span>
|
||||
<h1 className="mb-space-md max-w-3xl font-headline-xl text-on-surface">
|
||||
Flavors Crafted for the Glass & Plate
|
||||
</h1>
|
||||
<p className="max-w-xl font-body-lg text-body-lg text-on-surface-variant">
|
||||
Every dish on our menu is thoughtfully designed to complement the complex malt profiles
|
||||
and hop notes of our freshly brewed house beers.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Interactive Menu + Sticky Filter */}
|
||||
<MenuBrowser categories={categories} items={items} />
|
||||
|
||||
{/* Pairing Recommendations Banner */}
|
||||
<section className="mt-space-2xl w-full bg-surface-container-low py-space-xl">
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-1 items-center gap-grid-gutter px-grid-margin md:grid-cols-3">
|
||||
<div className="flex flex-col gap-space-sm">
|
||||
<span className="text-label-lg uppercase text-primary">Cicerone Approved</span>
|
||||
<h2 className="font-headline-lg text-on-surface">
|
||||
The Science of Pairing Craft & Kitchen
|
||||
</h2>
|
||||
<p className="text-body-md text-on-surface-variant">
|
||||
Our master brewer collaborates daily with our executive chef to harmonize hop
|
||||
bitterness, malt sweetness, and umami richness for an unforgettable tasting journey.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-grid-gutter sm:grid-cols-2 md:col-span-2">
|
||||
{pairings.map((pairing) => (
|
||||
<div key={pairing.uid} className="rounded-xl bg-surface p-space-lg shadow">
|
||||
<span className="mb-space-xs block font-headline-md text-primary">
|
||||
{pairing.title}
|
||||
</span>
|
||||
<p className="text-body-sm text-on-surface-variant">{pairing.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { getBrewsData } from '~/lib/data'
|
||||
import { TastingFlight } from '~/components/tasting-flight'
|
||||
|
||||
export const Route = createFileRoute('/our-brews')({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: 'Our Brews | The Great Bear Microbrewery' },
|
||||
{
|
||||
name: 'description',
|
||||
content:
|
||||
'Explore our core lineup and seasonal experiments — handcrafted in small batches within our state-of-the-art copper kettles.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
loader: () => getBrewsData(),
|
||||
component: OurBrewsPage,
|
||||
})
|
||||
|
||||
function OurBrewsPage() {
|
||||
const { flagship, coreBrews, seasonalBrews, flightBrews } = Route.useLoaderData()
|
||||
const tapsFlowing = flightBrews.length
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{/* Hero / Intro Header */}
|
||||
<section className="relative w-full overflow-hidden bg-surface-container-low px-grid-margin py-space-xl">
|
||||
<div className="pointer-events-none absolute -right-20 -top-20 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
|
||||
<div className="relative z-10 mx-auto flex max-w-7xl flex-col justify-between gap-space-lg md:flex-row md:items-center">
|
||||
<div className="flex max-w-2xl flex-col gap-space-md">
|
||||
<div className="flex items-center gap-space-sm">
|
||||
<span className="h-[1px] w-12 bg-primary" />
|
||||
<span className="text-label-lg uppercase tracking-widest text-primary">
|
||||
On Tap Today
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-headline-xl text-headline-xl text-on-surface">
|
||||
Freshly Brewed Masterpieces
|
||||
</h1>
|
||||
<p className="text-body-lg text-on-surface-variant">
|
||||
Handcrafted in small batches within our state-of-the-art copper kettles. Explore our
|
||||
core lineup and seasonal experiments designed to challenge and delight your palate.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-space-md rounded-xl bg-surface-container p-space-md shadow-lg sm:flex-row">
|
||||
<div className="flex flex-col text-center sm:text-left">
|
||||
<span className="text-label-md uppercase text-on-surface-variant">
|
||||
Brewery Status
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-2 text-body-md font-semibold text-on-surface">
|
||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-green-500" />{' '}
|
||||
{tapsFlowing} Taps Flowing Fresh
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
className="rounded-lg bg-primary px-space-md py-space-sm text-center text-label-lg font-medium text-on-primary transition-all hover:bg-primary/90"
|
||||
href="#tasting-flight"
|
||||
>
|
||||
Build a Flight
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Brew Banner */}
|
||||
{flagship ? (
|
||||
<section className="w-full px-grid-margin py-space-xl">
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-1 items-center gap-grid-gutter overflow-hidden rounded-xl bg-surface-container shadow-xl lg:grid-cols-12">
|
||||
<div className="relative h-96 lg:h-[500px] lg:col-span-6">
|
||||
<div
|
||||
className="h-full w-full bg-cover bg-center"
|
||||
style={{ backgroundImage: `url('${flagship.image}')` }}
|
||||
/>
|
||||
<div className="absolute left-4 top-4 rounded-full bg-surface/80 px-space-md py-space-xs backdrop-blur-md">
|
||||
<span className="text-label-md font-semibold uppercase text-primary">
|
||||
Flagship Brew
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-space-md p-space-xl lg:col-span-6">
|
||||
<div className="flex items-center gap-space-sm">
|
||||
<span className="rounded bg-surface-container-high px-space-sm py-space-xs text-label-md uppercase text-primary">
|
||||
{flagship.style}
|
||||
</span>
|
||||
<span className="text-body-sm text-on-surface-variant">
|
||||
ABV: {flagship.abv}% | IBU: {flagship.ibu}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-headline-lg text-on-surface">{flagship.name}</h2>
|
||||
<p className="text-body-md text-on-surface-variant">{flagship.description}</p>
|
||||
<div className="my-space-sm grid grid-cols-2 gap-space-md">
|
||||
<div className="rounded-lg bg-surface p-space-md">
|
||||
<span className="block text-label-md uppercase text-on-surface-variant">
|
||||
Tasting Notes
|
||||
</span>
|
||||
<span className="mt-1 block text-body-sm font-medium text-on-surface">
|
||||
{flagship.tasting_notes}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg bg-surface p-space-md">
|
||||
<span className="block text-label-md uppercase text-on-surface-variant">
|
||||
Suggested Pairing
|
||||
</span>
|
||||
<span className="mt-1 block text-body-sm font-medium text-on-surface">
|
||||
{flagship.pairing}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-space-md pt-space-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-space-sm rounded-lg bg-primary px-space-lg py-space-md text-label-lg font-medium text-on-primary shadow-md transition-all hover:bg-primary/90"
|
||||
onClick={(event) => {
|
||||
const button = event.currentTarget
|
||||
button.disabled = true
|
||||
const label = button.textContent
|
||||
button.textContent = 'Added to your table order!'
|
||||
setTimeout(() => {
|
||||
button.disabled = false
|
||||
button.textContent = label
|
||||
}, 2500)
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">sports_bar</span> Order
|
||||
Pint (500ml)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Core Beers Grid */}
|
||||
<section className="w-full bg-surface-container-low px-grid-margin py-space-xl">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-space-xl">
|
||||
<div className="mx-auto flex max-w-xl flex-col items-center gap-space-sm text-center">
|
||||
<span className="text-label-lg uppercase tracking-widest text-primary">
|
||||
The Permanent Taproom Lineup
|
||||
</span>
|
||||
<h2 className="font-headline-lg text-on-surface">Crafted Without Compromise</h2>
|
||||
<p className="text-body-md text-on-surface-variant">
|
||||
Each brew is an ode to traditional European brewing craft infused with modern Indian
|
||||
creativity.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-grid-gutter md:grid-cols-2 lg:grid-cols-3">
|
||||
{coreBrews.map((brew) => (
|
||||
<div
|
||||
key={brew.uid}
|
||||
className="flex flex-col justify-between overflow-hidden rounded-xl bg-surface shadow-md transition-transform hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative h-64">
|
||||
<div
|
||||
className="h-full w-full bg-cover bg-center"
|
||||
style={{ backgroundImage: `url('${brew.image}')` }}
|
||||
/>
|
||||
<div className="absolute right-4 top-4 rounded-full bg-surface/90 px-space-md py-space-xs backdrop-blur-sm">
|
||||
<span className="text-label-md font-semibold text-primary">
|
||||
ABV: {brew.abv}% • IBU: {brew.ibu}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-grow flex-col gap-space-md p-space-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-headline-md text-on-surface">{brew.name}</h3>
|
||||
<span className="text-label-md uppercase tracking-wider text-on-surface-variant">
|
||||
{brew.style}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-body-sm text-on-surface-variant">{brew.description}</p>
|
||||
<div className="mt-auto flex flex-col gap-space-xs border-t border-outline-variant/20 pt-space-md text-body-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-on-surface-variant">Notes:</span>
|
||||
<span className="font-medium text-on-surface">{brew.tasting_notes}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-on-surface-variant">Pairing:</span>
|
||||
<span className="font-medium text-on-surface">{brew.pairing}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Seasonal Fruit Beers Section */}
|
||||
<section className="w-full px-grid-margin py-space-xl">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center gap-space-xl lg:flex-row">
|
||||
<div className="flex flex-col gap-space-md lg:w-1/2">
|
||||
<span className="text-label-lg uppercase tracking-widest text-primary">
|
||||
Limited Release
|
||||
</span>
|
||||
<h2 className="font-headline-xl text-on-surface">Seasonal Fruit Infusions</h2>
|
||||
<p className="text-body-lg text-on-surface-variant">
|
||||
Our brewmaster experiments with seasonal harvest fruits to create vibrant, refreshing
|
||||
ales that capture the essence of the changing seasons.
|
||||
</p>
|
||||
<div className="my-space-sm flex flex-col gap-space-md">
|
||||
{seasonalBrews.map((brew) => (
|
||||
<div
|
||||
key={brew.uid}
|
||||
className="flex items-start gap-space-md rounded-xl bg-surface-container p-space-md"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[32px] text-primary">
|
||||
{brew.seasonal_icon || 'local_florist'}
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="font-headline-md text-[18px] text-on-surface">
|
||||
{brew.name} (ABV: {brew.abv}%)
|
||||
</h4>
|
||||
<p className="mt-1 text-body-sm text-on-surface-variant">{brew.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-[450px] w-full overflow-hidden rounded-xl shadow-2xl lg:w-1/2">
|
||||
<div
|
||||
className="h-full w-full bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('/images/seasonal-flight.jpg')" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Interactive Tasting Flight Customizer */}
|
||||
<section
|
||||
className="w-full bg-surface-container-low px-grid-margin py-space-2xl"
|
||||
id="tasting-flight"
|
||||
>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<TastingFlight brews={flightBrews} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { getVisitData } from '~/lib/data'
|
||||
import { ReservationForm } from '~/components/reservation-form'
|
||||
|
||||
export const Route = createFileRoute('/visit')({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: 'Visit & Contact | The Great Bear Microbrewery' },
|
||||
{
|
||||
name: 'description',
|
||||
content:
|
||||
'Reserve your table at Chandigarh’s premier microbrewery — SCO 32, Sector 26, Madhya Marg. Open daily 12:00 PM – 1:00 AM.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
loader: () => getVisitData(),
|
||||
component: VisitPage,
|
||||
})
|
||||
|
||||
function VisitPage() {
|
||||
const settings = Route.useLoaderData()
|
||||
const directionsUrl = `https://maps.google.com/?q=${encodeURIComponent(settings.maps_query)}`
|
||||
const embedUrl = `https://maps.google.com/maps?q=${encodeURIComponent(settings.maps_query)}&output=embed`
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{/* Hero Section with Immersive Atmosphere */}
|
||||
<div className="relative flex min-h-[500px] w-full items-center justify-center overflow-hidden bg-surface">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center opacity-40 mix-blend-luminosity"
|
||||
style={{ backgroundImage: "url('/images/visit-hero.jpg')" }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-surface via-surface/60 to-transparent" />
|
||||
<div className="relative z-10 mx-auto flex max-w-7xl flex-col items-center gap-space-md px-grid-margin py-space-2xl text-center">
|
||||
<span className="text-label-lg uppercase tracking-widest text-primary">
|
||||
Get in Touch & Experience The Craft
|
||||
</span>
|
||||
<h1 className="font-headline-xl text-headline-xl text-on-surface">
|
||||
Visit The Great Bear
|
||||
</h1>
|
||||
<p className="max-w-2xl font-body-lg text-body-lg text-on-surface-variant">
|
||||
Step into Chandigarh's premier microbrewery. Whether you're planning an evening with
|
||||
close friends or reserving a table for a celebration, our doors are open daily.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid: Reservation & Quick Info */}
|
||||
<section className="mx-auto grid w-full max-w-7xl grid-cols-1 items-start gap-grid-gutter px-grid-margin py-space-xl lg:grid-cols-12">
|
||||
{/* Left Column: Reservation Form */}
|
||||
<div className="flex flex-col gap-space-lg rounded-xl bg-surface-container p-space-lg shadow-xl lg:col-span-7">
|
||||
<div className="flex flex-col gap-space-xs">
|
||||
<span className="text-label-lg uppercase text-primary">Table Booking</span>
|
||||
<h2 className="font-headline-lg text-on-surface">Reserve Your Experience</h2>
|
||||
<p className="text-body-sm text-on-surface-variant">
|
||||
Fill out the details below and our host will confirm your table promptly.
|
||||
</p>
|
||||
</div>
|
||||
<ReservationForm />
|
||||
</div>
|
||||
|
||||
{/* Right Column: Location, Hours, Contact Card */}
|
||||
<div className="flex flex-col gap-space-lg lg:col-span-5">
|
||||
<div className="flex flex-col gap-space-md rounded-xl bg-surface-container p-space-lg shadow-xl">
|
||||
<div className="flex items-center gap-space-md border-b border-outline-variant/20 pb-space-md">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/20 text-primary">
|
||||
<span className="material-symbols-outlined">schedule</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-headline-md text-on-surface">Operating Hours</h3>
|
||||
<p className="text-body-sm font-medium text-primary">{settings.hours_line}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-space-md border-b border-outline-variant/20 pb-space-md">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/20 text-primary">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-headline-md text-on-surface">Location</h3>
|
||||
<p className="text-body-sm text-on-surface-variant">
|
||||
{settings.address_line}
|
||||
<br />
|
||||
{settings.city_line}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-space-md">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/20 text-primary">
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-headline-md text-on-surface">Direct Lines</h3>
|
||||
<p className="text-body-sm text-on-surface-variant">
|
||||
{settings.phone_primary}
|
||||
<br />
|
||||
{settings.phone_secondary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Social Callout */}
|
||||
<a
|
||||
className="group flex items-center justify-between rounded-xl bg-surface-container p-space-lg shadow-xl transition-all hover:bg-surface-container-high"
|
||||
href={settings.instagram_url}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div className="flex items-center gap-space-md">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-tr from-amber-600 via-rose-600 to-purple-600 text-white">
|
||||
<span className="material-symbols-outlined">photo_camera</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-headline-md text-on-surface transition-colors group-hover:text-primary">
|
||||
Instagram Feed
|
||||
</h3>
|
||||
<p className="text-body-sm text-on-surface-variant">{settings.instagram_handle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-on-surface-variant transition-transform group-hover:translate-x-1">
|
||||
arrow_forward
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Interactive Map Section */}
|
||||
<section className="w-full bg-surface-container-low py-space-xl">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-space-lg px-grid-margin">
|
||||
<div className="flex flex-col justify-between gap-space-md md:flex-row md:items-end">
|
||||
<div>
|
||||
<span className="text-label-lg uppercase text-primary">Find Us Easily</span>
|
||||
<h2 className="font-headline-lg text-on-surface">Sector 26, Madhya Marg Hub</h2>
|
||||
</div>
|
||||
<a
|
||||
className="flex items-center gap-space-xs self-start rounded-lg border border-primary bg-transparent px-space-md py-space-sm text-label-lg text-primary transition-all hover:bg-primary hover:text-on-primary md:self-auto"
|
||||
href={directionsUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">directions</span>
|
||||
Get Directions
|
||||
</a>
|
||||
</div>
|
||||
<div className="h-[450px] w-full overflow-hidden rounded-xl shadow-xl">
|
||||
<iframe
|
||||
title="The Great Bear Microbrewery location map"
|
||||
src={embedUrl}
|
||||
className="h-full w-full border-0"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@fontsource-variable/inter';
|
||||
@import '@fontsource-variable/playfair-display';
|
||||
|
||||
/*
|
||||
* The Great Bear — "Industrial Hospitality" design system
|
||||
* Tokens ported 1:1 from the Stitch design (design/industrial_hospitality/DESIGN.md).
|
||||
*/
|
||||
@theme {
|
||||
/* ---- Colors (Material 3 dark palette) ---- */
|
||||
--color-surface: #1a120b;
|
||||
--color-surface-dim: #1a120b;
|
||||
--color-surface-bright: #42372f;
|
||||
--color-surface-container-lowest: #150c06;
|
||||
--color-surface-container-low: #231a13;
|
||||
--color-surface-container: #271e16;
|
||||
--color-surface-container-high: #322820;
|
||||
--color-surface-container-highest: #3e332b;
|
||||
--color-surface-variant: #3e332b;
|
||||
--color-surface-tint: #ffb77d;
|
||||
--color-on-surface: #f2dfd3;
|
||||
--color-on-surface-variant: #dbc2b0;
|
||||
--color-inverse-surface: #f2dfd3;
|
||||
--color-inverse-on-surface: #392e26;
|
||||
--color-inverse-primary: #904d00;
|
||||
--color-background: #1a120b;
|
||||
--color-on-background: #f2dfd3;
|
||||
--color-outline: #a38c7c;
|
||||
--color-outline-variant: #554336;
|
||||
--color-primary: #ffb77d;
|
||||
--color-on-primary: #4d2600;
|
||||
--color-primary-container: #d97707;
|
||||
--color-on-primary-container: #432100;
|
||||
--color-secondary: #ffb68e;
|
||||
--color-on-secondary: #532200;
|
||||
--color-secondary-container: #ab4c00;
|
||||
--color-on-secondary-container: #ffe2d5;
|
||||
--color-tertiary: #96ccff;
|
||||
--color-on-tertiary: #003353;
|
||||
--color-tertiary-container: #0297e8;
|
||||
--color-on-tertiary-container: #002c48;
|
||||
--color-error: #ffb4ab;
|
||||
--color-on-error: #690005;
|
||||
--color-error-container: #93000a;
|
||||
--color-on-error-container: #ffdad6;
|
||||
--color-primary-fixed: #ffdcc3;
|
||||
--color-primary-fixed-dim: #ffb77d;
|
||||
--color-on-primary-fixed: #2f1500;
|
||||
--color-on-primary-fixed-variant: #6e3900;
|
||||
--color-secondary-fixed: #ffdbca;
|
||||
--color-secondary-fixed-dim: #ffb68e;
|
||||
--color-on-secondary-fixed: #331200;
|
||||
--color-on-secondary-fixed-variant: #763300;
|
||||
--color-tertiary-fixed: #cee5ff;
|
||||
--color-tertiary-fixed-dim: #96ccff;
|
||||
--color-on-tertiary-fixed: #001d32;
|
||||
--color-on-tertiary-fixed-variant: #004a75;
|
||||
|
||||
/* ---- Typography ---- */
|
||||
--font-headline-xl: 'Playfair Display Variable', 'Playfair Display', Georgia, serif;
|
||||
--font-headline-lg: 'Playfair Display Variable', 'Playfair Display', Georgia, serif;
|
||||
--font-headline-md: 'Playfair Display Variable', 'Playfair Display', Georgia, serif;
|
||||
--font-body: 'Inter Variable', 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-body-lg: 'Inter Variable', 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-body-md: 'Inter Variable', 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-body-sm: 'Inter Variable', 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-label-lg: 'Inter Variable', 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-label-md: 'Inter Variable', 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
--text-headline-xl: 48px;
|
||||
--text-headline-xl--line-height: 56px;
|
||||
--text-headline-xl--letter-spacing: -0.02em;
|
||||
--text-headline-xl--font-weight: 700;
|
||||
--text-headline-lg: 36px;
|
||||
--text-headline-lg--line-height: 44px;
|
||||
--text-headline-lg--letter-spacing: -0.01em;
|
||||
--text-headline-lg--font-weight: 600;
|
||||
--text-headline-md: 24px;
|
||||
--text-headline-md--line-height: 32px;
|
||||
--text-headline-md--font-weight: 600;
|
||||
--text-body-lg: 18px;
|
||||
--text-body-lg--line-height: 28px;
|
||||
--text-body-md: 16px;
|
||||
--text-body-md--line-height: 24px;
|
||||
--text-body-sm: 14px;
|
||||
--text-body-sm--line-height: 20px;
|
||||
--text-label-lg: 14px;
|
||||
--text-label-lg--line-height: 20px;
|
||||
--text-label-lg--letter-spacing: 0.05em;
|
||||
--text-label-lg--font-weight: 500;
|
||||
--text-label-md: 12px;
|
||||
--text-label-md--line-height: 16px;
|
||||
--text-label-md--letter-spacing: 0.05em;
|
||||
--text-label-md--font-weight: 500;
|
||||
|
||||
/* ---- Spacing ---- */
|
||||
--spacing-space-xs: 0.25rem;
|
||||
--spacing-space-sm: 0.5rem;
|
||||
--spacing-space-md: 1rem;
|
||||
--spacing-space-lg: 2rem;
|
||||
--spacing-space-xl: 4rem;
|
||||
--spacing-space-2xl: 6rem;
|
||||
--spacing-grid-margin: 2rem;
|
||||
--spacing-grid-gutter: 1.5rem;
|
||||
|
||||
/* ---- Radius (Stitch scale) ---- */
|
||||
--radius-sm: 0.125rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.25rem;
|
||||
--radius-xl: 0.5rem;
|
||||
--radius-2xl: 1rem;
|
||||
--radius-3xl: 1.5rem;
|
||||
--radius-full: 0.75rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-surface font-body text-on-surface antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sticky menu filter bar on the Menu page scrolls horizontally without a scrollbar */
|
||||
@utility no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Material Symbols Outlined — self-hosted, no external font CDN at runtime */
|
||||
@font-face {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-style: normal;
|
||||
font-weight: 100 700;
|
||||
font-display: block;
|
||||
src: url('/fonts/material-symbols-outlined.woff2') format('woff2');
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
font-variation-settings:
|
||||
'FILL' 0,
|
||||
'wght' 400,
|
||||
'GRAD' 0,
|
||||
'opsz' 24;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2024"],
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "ES2024",
|
||||
"allowJs": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
},
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import viteReact from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { nitro } from 'nitro/vite'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
tanstackStart({
|
||||
srcDirectory: 'src',
|
||||
}),
|
||||
viteReact(),
|
||||
nitro(),
|
||||
],
|
||||
})
|
||||