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
This commit is contained in:
tanshu committed 2026-09-03 08:51:47 +05:30
commit 7b070fde81
66 files changed
+8375

No files matched your search

+3152
View File
File diff suppressed because it is too large. Load diff
+32
View File
@@ -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"
}
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+107
View File
@@ -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>
)
}
+66
View File
@@ -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>
)
}
+160
View File
@@ -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>
)
}
+30
View File
@@ -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>
)
}
+83
View File
@@ -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>
)
}
+114
View File
@@ -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>
)
}
+376
View File
@@ -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
}
]
}
+68
View File
@@ -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 }
})
+100
View File
@@ -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})`)
}
}
+97
View File
@@ -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
}
+122
View File
@@ -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>>
}
}
+17
View File
@@ -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>
}
}
+49
View File
@@ -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>
)
}
+241
View File
@@ -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>
)
}
+76
View File
@@ -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 &amp; 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 &amp; 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>
)
}
+239
View File
@@ -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>
)
}
+159
View File
@@ -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 &amp; 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>
)
}
+164
View File
@@ -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;
}
+21
View File
@@ -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
}
}
+22
View File
@@ -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(),
],
})