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)
|
||||||
|
}
|
||||||