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

+20
View File
@@ -0,0 +1,20 @@
# Keep the build context small and secret-free
.git
.gitignore
.dockerignore
.env
.npm-cache
.tmp-pb
design
*.zip
# Node artifacts
**/node_modules
app/.output
app/.nitro
app/dist
# Ops assets not needed inside the image
ansible
scripts/create-collections.sh
README.md
+8
View File
@@ -0,0 +1,8 @@
# Copy to .env and fill in real values. .env is git-ignored.
REGISTRY=registry.tanshu.com
REGISTRY_USER=ta-registry
REGISTRY_PASSWORD=changeme
# Initial PocketBase admin (applied on first container start; idempotent)
PB_SUPERUSER_EMAIL=admin@greatbear.in
PB_SUPERUSER_PASSWORD=changeme
+25
View File
@@ -0,0 +1,25 @@
# Dependencies / builds
node_modules/
app/node_modules/
app/.output/
app/.nitro/
app/dist/
.npm-cache/
# Secrets
.env
# Local PocketBase data & scratch
pb_data/
.tmp-pb/
scripts/*.log
# Design sources (kept out of the repo; see stitch export zip)
design/
stitch_the_great_bear_microbrewery.zip
# Editor / OS
.DS_Store
*.log
.idea/
.vscode/
+63
View File
@@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1
# The Great Bear — single image running TanStack Start (site) + PocketBase (CMS/API)
ARG NODE_VERSION=22
ARG PB_VERSION=0.40.2
# ---------- Stage 1: build the TanStack Start app ----------
FROM node:${NODE_VERSION}-alpine AS build
WORKDIR /app
COPY app/package.json app/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY app/ ./
RUN npm run build
# ---------- Stage 2: runtime ----------
FROM node:${NODE_VERSION}-alpine AS runtime
ARG PB_VERSION
ARG TARGETARCH
ENV NODE_ENV=production \
PORT=3000 \
PB_URL=http://127.0.0.1:8090
WORKDIR /app
RUN apk add --no-cache curl unzip \
&& addgroup -S app && adduser -S app -G app
# Site server build output
COPY --from=build /app/.output ./.output
# Content seed (shared with the frontend fallback) + process supervisor
COPY app/src/data/content.json ./scripts/content.json
COPY scripts/seed.mjs scripts/run-all.mjs ./scripts/
# PocketBase schema migrations
COPY pb/migrations ./pb_migrations
RUN mkdir -p pb_public
# PocketBase binary (static Go binary, runs fine on Alpine)
RUN case "${TARGETARCH:-amd64}" in \
arm64) PB_ARCH=arm64 ;; \
*) PB_ARCH=amd64 ;; \
esac \
&& curl -fsSL -o /tmp/pb.zip \
"https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_${PB_ARCH}.zip" \
&& unzip -o /tmp/pb.zip pocketbase -d /app \
&& chmod +x /app/pocketbase \
&& rm /tmp/pb.zip \
&& mkdir -p /data \
&& chown -R app:app /app /data
USER app
VOLUME /data
EXPOSE 3000 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=5 \
CMD curl -fsS http://127.0.0.1:8090/api/health -o /dev/null || exit 1
CMD ["node", "scripts/run-all.mjs"]
+118
View File
@@ -0,0 +1,118 @@
# The Great Bear — Microbrewery Website
Full-stack site for The Great Bear microbrewery (SCO 32, Sector 26, Chandigarh), built from the
Stitch "Industrial Hospitality" design.
| Layer | Tech |
| --------- | ----------------------------------------------------------- |
| Frontend | [TanStack Start](https://tanstack.com/start) (React 19, Vite 8, Tailwind CSS 4, SSR) |
| Backend | [PocketBase](https://pocketbase.io) v0.40 — CMS content, form intake, admin UI |
| Runtime | Single Docker image running both services |
| Proxy | Caddy (automatic HTTPS) on the target host |
| Deploys | `deploy.sh` (build + push) → Ansible playbook (pull + run) |
## Pages
- `/` — hero, stats, featured brews, experiences, testimonials, quick table booking
- `/our-brews` — flagship banner, core lineup, seasonal releases, tasting-flight builder
- `/menu` — filterable gastropub menu with beer pairings
- `/visit` — reservation form, contact info, map
All structured content (brews, menu, testimonials, stats, contact info) is served from
PocketBase and editable in the admin UI. If PocketBase is unreachable, the site falls back to
bundled seed content so it never renders empty.
## Repository layout
```
app/ TanStack Start application
src/routes/ Pages (/, /our-brews, /menu, /visit)
src/components/ Shared UI + client forms
src/lib/ Server functions & PocketBase client
src/data/content.json Single source of truth for seed + fallback content
public/images/ Design assets (downloaded from the Stitch export)
pb/migrations/ PocketBase schema (snapshot migration, auto-applied)
scripts/
run-all.mjs Container supervisor: PocketBase + site server
seed.mjs Idempotent content seeder (create-if-missing by uid)
create-collections.sh Dev helper used to build the schema migration
ansible/ Deployment playbook (roles: app, caddy)
Dockerfile Multi-stage image build
deploy.sh Build & push image to the private registry
```
## Local development
```bash
cd app
npm install
npm run dev # http://localhost:3000 (uses bundled seed data if PocketBase is down)
```
To run PocketBase locally (optional):
```bash
./.tmp-pb/pocketbase serve --http=127.0.0.1:8090 --dir=./pb_data --migrationsDir=./pb/migrations
```
`npm run build` produces `.output/server/index.mjs`, started with `npm start`.
## Deploying
### 1. Build and push the image
```bash
cp .env.example .env # then fill in registry credentials
./deploy.sh
```
Tags pushed: `registry.tanshu.com/tanshu/greatbear:latest` and `:<git-sha>`.
### 2. Deploy with Ansible
Requirements on the controller: `ansible` + `community.docker` collection
(`ansible-galaxy collection install community.docker`), SSH access to the host.
Requirements on the host (`www.greatbear.in`): Docker and Caddy already installed.
```bash
cd ansible
# edit group_vars/greatbear/vars.yml if needed (or encrypt it with ansible-vault)
ansible-playbook site.yml # add -i inventory/hosts.yml if not using ansible.cfg
```
The playbook:
1. Logs into the registry on the host, pulls the image, and (re)creates the `greatbear`
container with ports bound to loopback (`127.0.0.1:3000`, `127.0.0.1:8090`) and a named
volume `greatbear_pb_data:/data` for the database.
2. Waits for PocketBase + site health checks.
3. Writes `/etc/caddy/caddy.d/greatbear.conf` with both site blocks and reloads Caddy:
- `www.greatbear.in` → `127.0.0.1:3000` (the site)
- `admin.greatbear.in` → `127.0.0.1:8090` (PocketBase API + admin UI)
DNS records for both domains must point at the host; Caddy obtains TLS certificates
automatically on first request.
## PocketBase admin
- URL: `https://admin.greatbear.in/_/`
- First container start bootstraps the superuser from `PB_SUPERUSER_EMAIL` /
`PB_SUPERUSER_PASSWORD` (set in `ansible/group_vars/greatbear/vars.yml`, idempotent).
Content collections: `brews`, `menu_categories`, `menu_items`, `experiences`, `testimonials`,
`stats`, `pairings`, `settings`. Reservation/contact submissions land in `reservations`
(public create only — readable by admins).
The seed only creates records that are missing (matched by `uid`), so edits made in the admin
are never overwritten by redeploys. Schema changes belong in `pb/migrations/`.
## Container environment
| Variable | Default | Purpose |
| ----------------------- | ------------------------ | -------------------------------------- |
| `PORT` | `3000` | Site server port |
| `PB_URL` | `http://127.0.0.1:8090` | PocketBase URL used by the site server |
| `PB_SUPERUSER_EMAIL` | — | Initial admin (bootstrapped at start) |
| `PB_SUPERUSER_PASSWORD` | — | Initial admin password |
+9
View File
@@ -0,0 +1,9 @@
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
host_key_checking = True
retry_files_enabled = False
interpreter_python = auto_silent
[ssh_connection]
pipelining = True
+31
View File
@@ -0,0 +1,31 @@
---
# Deployment variables for The Great Bear.
# NOTE: This file contains credentials. For anything beyond a private homelab
# repo, encrypt it with: ansible-vault encrypt group_vars/greatbear/vars.yml
# --- Domains (Caddy provisions TLS for both automatically) ---
site_domain: www.greatbear.in
admin_domain: admin.greatbear.in
# --- Container image ---
registry_url: registry.tanshu.com
registry_username: ta-registry
registry_password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f
app_image: registry.tanshu.com/tanshu/greatbear
app_image_tag: latest
# --- Container runtime ---
app_container_name: greatbear
app_volume_name: greatbear_pb_data
# Ports are bound to loopback only; Caddy proxies public traffic to them.
app_site_publish: 127.0.0.1:3000:3000
app_admin_publish: 127.0.0.1:8090:8090
# --- PocketBase initial admin (idempotent bootstrap on container start) ---
pb_superuser_email: admin@greatbear.in
pb_superuser_password: GreatBear!Admin2024
# --- Caddy ---
caddy_config_dir: /etc/caddy/caddy.d
caddyfile_path: /etc/caddy/Caddyfile
caddy_service: caddy
+9
View File
@@ -0,0 +1,9 @@
all:
children:
greatbear:
hosts:
www.greatbear.in:
ansible_user: root
# ansible_ssh_private_key_file: ~/.ssh/id_ed25519
# Override ansible_user if you log in as a non-root sudo user:
# ansible_user: ubuntu
+58
View File
@@ -0,0 +1,58 @@
---
# Pull the image from the private registry and (re)create the app container.
- name: Log in to the private registry
community.docker.docker_login:
registry_url: "{{ registry_url }}"
username: "{{ registry_username }}"
password: "{{ registry_password }}"
- name: Pull the application image
community.docker.docker_image:
name: "{{ app_image }}"
tag: "{{ app_image_tag }}"
source: pull
force_source: true
- name: Run the application container
community.docker.docker_container:
name: "{{ app_container_name }}"
image: "{{ app_image }}:{{ app_image_tag }}"
state: started
recreate: true
restart_policy: unless-stopped
published_ports:
- "{{ app_site_publish }}"
- "{{ app_admin_publish }}"
volumes:
- "{{ app_volume_name }}:/data"
env:
PORT: "3000"
PB_URL: "http://127.0.0.1:8090"
PB_SUPERUSER_EMAIL: "{{ pb_superuser_email }}"
PB_SUPERUSER_PASSWORD: "{{ pb_superuser_password }}"
comparisons:
image: ignore # recreate: true already handles image updates
container_default_behavior: compatibility
- name: Wait for PocketBase to become healthy
ansible.builtin.uri:
url: http://127.0.0.1:8090/api/health
status_code: 200
register: pb_health
retries: 30
delay: 5
until: pb_health.status == 200
- name: Wait for the site to respond
ansible.builtin.uri:
url: http://127.0.0.1:3000/
status_code: 200
register: site_health
retries: 30
delay: 5
until: site_health.status == 200
- name: Log out of the private registry
community.docker.docker_login:
registry_url: "{{ registry_url }}"
state: absent
+5
View File
@@ -0,0 +1,5 @@
---
- name: Reload Caddy
ansible.builtin.systemd:
name: "{{ caddy_service }}"
state: reloaded
+47
View File
@@ -0,0 +1,47 @@
---
# Manage Caddy entries for the site and the PocketBase admin/API host.
- name: Ensure Caddy config include directory exists
ansible.builtin.file:
path: "{{ caddy_config_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Check whether the main Caddyfile exists
ansible.builtin.stat:
path: "{{ caddyfile_path }}"
register: caddyfile_stat
- name: Ensure the main Caddyfile imports conf.d entries
ansible.builtin.lineinfile:
path: "{{ caddyfile_path }}"
line: "import {{ caddy_config_dir }}/*.conf"
insertafter: EOF
state: present
when: caddyfile_stat.stat.exists
notify: Reload Caddy
- name: Install Great Bear Caddy entries
ansible.builtin.template:
src: greatbear.conf.j2
dest: "{{ caddy_config_dir }}/greatbear.conf"
owner: root
group: root
mode: "0644"
notify: Reload Caddy
- name: Validate Caddy configuration
ansible.builtin.command:
cmd: "caddy validate --config {{ caddyfile_path }} --adapter caddyfile"
changed_when: false
when: caddyfile_stat.stat.exists
- name: Flush handlers (reload Caddy now)
ansible.builtin.meta: flush_handlers
- name: Ensure Caddy is enabled and running
ansible.builtin.systemd:
name: "{{ caddy_service }}"
state: started
enabled: true
@@ -0,0 +1,12 @@
# The Great Bear — managed by Ansible. Changes will be overwritten.
# Frontend (TanStack Start)
{{ site_domain }} {
encode zstd gzip
reverse_proxy 127.0.0.1:3000
}
# PocketBase API + admin UI (http://{{ admin_domain }}/_/)
{{ admin_domain }} {
encode zstd gzip
reverse_proxy 127.0.0.1:8090
}
+9
View File
@@ -0,0 +1,9 @@
---
- name: Deploy The Great Bear (PocketBase + TanStack Start)
hosts: greatbear
become: true
roles:
- role: app
tags: [app, deploy]
- role: caddy
tags: [caddy, web]
+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(),
],
})
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
#
# Build the Great Bear image and push it to the private registry.
#
# Reads registry credentials from the environment or from ./.env
# (REGISTRY_USER, REGISTRY_PASSWORD). Optional overrides: REGISTRY,
# IMAGE_NAME, PLATFORM.
#
set -euo pipefail
cd "$(dirname "$0")"
# shellcheck disable=SC1091
[ -f .env ] && set -a && . ./.env && set +a
REGISTRY="${REGISTRY:-registry.tanshu.com}"
IMAGE_NAME="${IMAGE_NAME:-tanshu/greatbear}"
REGISTRY_USER="${REGISTRY_USER:?REGISTRY_USER not set (define it in .env or the environment)}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:?REGISTRY_PASSWORD not set (define it in .env or the environment)}"
PLATFORM="${PLATFORM:-linux/amd64}"
IMAGE="$REGISTRY/$IMAGE_NAME"
VERSION="$(git rev-parse --short HEAD 2>/dev/null || date +%Y%m%d%H%M%S)"
echo "==> Building $IMAGE:$VERSION (and :latest) for $PLATFORM"
docker build --platform "$PLATFORM" \
-t "$IMAGE:latest" \
-t "$IMAGE:$VERSION" \
.
echo "==> Logging in to $REGISTRY"
printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USER" --password-stdin
echo "==> Pushing $IMAGE:latest"
docker push "$IMAGE:latest"
echo "==> Pushing $IMAGE:$VERSION"
docker push "$IMAGE:$VERSION"
echo
echo "Done. Deploy with:"
echo " cd ansible && ansible-playbook -i inventory/hosts.yml site.yml"
File diff suppressed because it is too large. Load diff
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# One-off helper used during development to create the Great Bear collections
# on a running PocketBase instance via the admin API.
# Usage: PB_URL=http://127.0.0.1:8099 PB_EMAIL=... PB_PASSWORD=... ./scripts/create-collections.sh
set -euo pipefail
PB_URL="${PB_URL:-http://127.0.0.1:8099}"
PB_EMAIL="${PB_EMAIL:?PB_EMAIL required}"
PB_PASSWORD="${PB_PASSWORD:?PB_PASSWORD required}"
TOKEN=$(curl -sS -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
-H "Content-Type: application/json" \
-d "{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")
echo "authenticated: ${TOKEN:0:12}..."
WORKDIR=$(mktemp -d)
trap 'rm -rf "$WORKDIR"' EXIT
cd "$WORKDIR"
content_rules='"listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null'
# Reservations: visitors may create (forms) but only admins may read/update.
reservation_rules='"listRule":null,"viewRule":null,"createRule":"","updateRule":null,"deleteRule":null'
mk() { NAME=$1; FIELDS=$2; IDX=$3; RULES=${4:-$content_rules}; cat > "$NAME.json" <<EOF
{"name":"$NAME","type":"base",$RULES,"fields":$FIELDS,"indexes":$IDX}
EOF
}
mk brews '[
{"name":"uid","type":"text","required":true},
{"name":"name","type":"text","required":true},
{"name":"style","type":"text","required":true},
{"name":"short_name","type":"text"},
{"name":"abv","type":"number"},
{"name":"ibu","type":"number"},
{"name":"description","type":"text"},
{"name":"tasting_notes","type":"text"},
{"name":"pairing","type":"text"},
{"name":"image","type":"text"},
{"name":"featured","type":"bool"},
{"name":"flagship","type":"bool"},
{"name":"on_tap","type":"bool"},
{"name":"seasonal","type":"bool"},
{"name":"seasonal_icon","type":"text"},
{"name":"flight","type":"bool"},
{"name":"sort","type":"number"}]' '["CREATE UNIQUE INDEX idx_brews_uid ON brews (uid)"]'
mk stats '[
{"name":"uid","type":"text","required":true},
{"name":"value","type":"text","required":true},
{"name":"label","type":"text","required":true},
{"name":"sort","type":"number"}]' '["CREATE UNIQUE INDEX idx_stats_uid ON stats (uid)"]'
mk experiences '[
{"name":"uid","type":"text","required":true},
{"name":"title","type":"text","required":true},
{"name":"description","type":"text"},
{"name":"icon","type":"text"},
{"name":"image","type":"text"},
{"name":"sort","type":"number"}]' '["CREATE UNIQUE INDEX idx_experiences_uid ON experiences (uid)"]'
mk testimonials '[
{"name":"uid","type":"text","required":true},
{"name":"name","type":"text","required":true},
{"name":"role","type":"text"},
{"name":"quote","type":"text","required":true},
{"name":"rating","type":"number"},
{"name":"sort","type":"number"}]' '["CREATE UNIQUE INDEX idx_testimonials_uid ON testimonials (uid)"]'
mk menu_categories '[
{"name":"uid","type":"text","required":true},
{"name":"slug","type":"text","required":true},
{"name":"name","type":"text","required":true},
{"name":"sort","type":"number"}]' '["CREATE UNIQUE INDEX idx_menu_categories_uid ON menu_categories (uid)","CREATE UNIQUE INDEX idx_menu_categories_slug ON menu_categories (slug)"]'
mk menu_items '[
{"name":"uid","type":"text","required":true},
{"name":"category","type":"text","required":true},
{"name":"name","type":"text","required":true},
{"name":"description","type":"text"},
{"name":"price","type":"number"},
{"name":"pairing","type":"text"},
{"name":"tag","type":"text"},
{"name":"image","type":"text"},
{"name":"sort","type":"number"}]' '["CREATE UNIQUE INDEX idx_menu_items_uid ON menu_items (uid)"]'
mk pairings '[
{"name":"uid","type":"text","required":true},
{"name":"title","type":"text","required":true},
{"name":"description","type":"text"},
{"name":"sort","type":"number"}]' '["CREATE UNIQUE INDEX idx_pairings_uid ON pairings (uid)"]'
mk settings '[
{"name":"uid","type":"text","required":true},
{"name":"brand","type":"text","required":true},
{"name":"footer_about","type":"text"},
{"name":"address_line","type":"text"},
{"name":"city_line","type":"text"},
{"name":"hours_line","type":"text"},
{"name":"hours_multiline","type":"text"},
{"name":"phone_primary","type":"text"},
{"name":"phone_secondary","type":"text"},
{"name":"instagram_url","type":"text"},
{"name":"instagram_handle","type":"text"},
{"name":"maps_query","type":"text"}]' '["CREATE UNIQUE INDEX idx_settings_uid ON settings (uid)"]'
mk reservations '[
{"name":"name","type":"text","required":true},
{"name":"phone","type":"text","required":true},
{"name":"source","type":"text","required":true},
{"name":"date","type":"text"},
{"name":"time","type":"text"},
{"name":"guests","type":"text"},
{"name":"requests","type":"text"},
{"name":"status","type":"select","maxSelect":1,"values":["new","confirmed","cancelled"]}]' '[]' "$reservation_rules"
for NAME in brews stats experiences testimonials menu_categories menu_items pairings settings reservations; do
CODE=$(curl -sS -o "resp_$NAME.json" -w "%{http_code}" -X POST "$PB_URL/api/collections" \
-H "Authorization: $TOKEN" -H "Content-Type: application/json" -d @"$NAME.json")
echo "$NAME -> HTTP $CODE"
if [ "$CODE" != "200" ]; then cat "resp_$NAME.json"; echo; fi
done
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* Container entrypoint supervisor.
*
* Runs PocketBase and the TanStack Start node server side by side:
* 1. starts PocketBase (data in /data, migrations from ./pb_migrations)
* 2. waits for its health endpoint
* 3. bootstraps the superuser account (idempotent upsert)
* 4. seeds initial content (idempotent, create-if-missing)
* 5. starts the site server on PORT (default 3000)
*
* If either process dies the container exits non-zero so Docker restarts it.
*/
import { spawn, spawnSync } from 'node:child_process'
const PORT = process.env.PORT ?? '3000'
const PB_HTTP = process.env.PB_HTTP ?? '0.0.0.0:8090'
const PB_DATA = process.env.PB_DATA ?? '/data'
let stopping = false
const children = []
function start(name, command, args, extraEnv = {}) {
const child = spawn(command, args, {
stdio: 'inherit',
env: { ...process.env, ...extraEnv },
})
child.name = name
children.push(child)
child.on('exit', (code) => {
if (stopping) return
console.error(`[supervisor] ${name} exited with code ${code} — shutting down`)
shutdown(code ?? 1)
})
return child
}
function run(name, command, args, extraEnv = {}) {
const result = spawnSync(command, args, {
stdio: 'inherit',
env: { ...process.env, ...extraEnv },
})
if (result.status !== 0) {
console.warn(`[supervisor] ${name} finished with status ${result.status}`)
}
return result.status ?? 0
}
function shutdown(exitCode = 0) {
if (stopping) return
stopping = true
for (const child of children) {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM')
}
setTimeout(() => process.exit(exitCode), 2000)
}
process.on('SIGINT', () => shutdown(0))
process.on('SIGTERM', () => shutdown(0))
async function waitForHealth(url, attempts = 120) {
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch(url)
if (res.ok) return
} catch {
// not up yet
}
await new Promise((resolve) => setTimeout(resolve, 500))
}
throw new Error(`health check at ${url} timed out`)
}
async function main() {
console.log('[supervisor] starting PocketBase…')
start('pocketbase', './pocketbase', [
'serve',
`--http=${PB_HTTP}`,
`--dir=${PB_DATA}`,
'--migrationsDir=./pb_migrations',
'--publicDir=./pb_public',
])
await waitForHealth('http://127.0.0.1:8090/api/health')
console.log('[supervisor] PocketBase is healthy')
if (process.env.PB_SUPERUSER_EMAIL && process.env.PB_SUPERUSER_PASSWORD) {
run('superuser upsert', './pocketbase', [
'superuser',
'upsert',
process.env.PB_SUPERUSER_EMAIL,
process.env.PB_SUPERUSER_PASSWORD,
`--dir=${PB_DATA}`,
'--migrationsDir=./pb_migrations',
])
console.log('[supervisor] superuser ensured')
run('seed', 'node', ['scripts/seed.mjs'])
} else {
console.warn('[supervisor] PB_SUPERUSER_EMAIL/PASSWORD not set — skipping superuser + seed')
}
console.log(`[supervisor] starting site server on :${PORT}…`)
start('site', 'node', ['.output/server/index.mjs'], {
PORT,
PB_URL: process.env.PB_URL ?? 'http://127.0.0.1:8090',
NODE_ENV: 'production',
})
}
main().catch((error) => {
console.error(`[supervisor] fatal: ${error.message}`)
shutdown(1)
})
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env node
/**
* Idempotent content seeder.
*
* Reads content.json (same file the frontend bundles as offline fallback)
* and creates any record that is missing in PocketBase, identified by its
* `uid` field. Existing records are NEVER overwritten — after the first
* seed, PocketBase owns the data and content is managed via the admin UI
* (admin.greatbear.in).
*
* Required env: PB_SUPERUSER_EMAIL, PB_SUPERUSER_PASSWORD. Optional: PB_URL.
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const PB_URL = process.env.PB_URL ?? 'http://127.0.0.1:8090'
const EMAIL = process.env.PB_SUPERUSER_EMAIL
const PASSWORD = process.env.PB_SUPERUSER_PASSWORD
if (!EMAIL || !PASSWORD) {
console.error('[seed] PB_SUPERUSER_EMAIL/PB_SUPERUSER_PASSWORD not set — skipping seed')
process.exit(0)
}
const content = JSON.parse(readFileSync(join(here, 'content.json'), 'utf8'))
async function auth() {
const res = await fetch(`${PB_URL}/api/collections/_superusers/auth-with-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: EMAIL, password: PASSWORD }),
})
if (!res.ok) throw new Error(`superuser auth failed (HTTP ${res.status})`)
const json = await res.json()
return json.token
}
async function ensureRecord(collection, token, item) {
const filter = encodeURIComponent(`uid="${item.uid}"`)
const found = await fetch(
`${PB_URL}/api/collections/${collection}/records?filter=${filter}`,
{ headers: { Authorization: token } },
)
if (!found.ok) throw new Error(`lookup failed for ${collection}/${item.uid} (HTTP ${found.status})`)
const list = await found.json()
if (list.items.length > 0) return 'exists'
const created = await fetch(`${PB_URL}/api/collections/${collection}/records`, {
method: 'POST',
headers: { Authorization: token, 'Content-Type': 'application/json' },
body: JSON.stringify(item),
})
if (!created.ok) {
const body = await created.text()
throw new Error(`create failed for ${collection}/${item.uid} (HTTP ${created.status}): ${body}`)
}
return 'created'
}
const COLLECTIONS = [
'settings',
'stats',
'brews',
'experiences',
'testimonials',
'menu_categories',
'menu_items',
'pairings',
]
try {
const token = await auth()
let created = 0
let existing = 0
for (const collection of COLLECTIONS) {
const items = Array.isArray(content[collection])
? content[collection]
: content[collection]
? [content[collection]]
: []
for (const item of items) {
const result = await ensureRecord(collection, token, item)
if (result === 'created') created++
else existing++
}
}
console.log(`[seed] done: ${created} created, ${existing} already present`)
} catch (error) {
console.error(`[seed] failed: ${error.message}`)
process.exit(1)
}