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