Files
greatbear/README.md
T
tanshu 1c78fc5103 Deploy: push before building, and tag images with the commit
deploy.sh no longer needs a manual push before it, and no made-up tag:

- It pushes the current branch, refuses a dirty tracked tree, and requires
  HEAD == origin/main before anything is built, so the image can only be
  built for a commit the remote actually has. --force and --no-push skip the
  guards.
- The image is tagged :<commit> as well as :latest, and ansible takes
  -e image_tag=<commit>, so a deploy pins the exact revision; rollback is the
  same play with an older commit.
- Makefile builds `git archive HEAD` instead of cloning the remote (the push
  guard makes the local commit the remote state) and defaults PLATFORM to
  linux/arm64, because emily is aarch64 — the same default mozimo uses. Drop
  build-check-local; build-check already builds the committed tree.
2026-10-16 10:14:02 +05:30

245 lines
11 KiB
Markdown

# 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 image running both services, as a rootless Podman Quadlet unit |
| Proxy | Caddy (automatic HTTPS), also a rootless Podman Quadlet unit |
| Deploys | `deploy.sh [tag]` (Podman build + push) → Ansible playbook |
## 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/
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: register, greatbear, caddy)
Makefile Podman build targets (build-production / build-check)
Dockerfile Multi-stage image build (deps → builder → pb-downloader → runner)
docker-entrypoint.sh Container supervisor: PocketBase + site server
deploy.sh Build & push image, then run the Ansible deploy
.containerignore Build-context excludes (Podman/Buildah; .dockerignore fallback)
```
## Local development
Frontend only (renders from bundled seed content; forms return errors):
```bash
cd app
npm install
npm run dev # http://localhost:3000
```
Full stack with PocketBase (recommended — from the repo root):
```bash
# Terminal 1 — PocketBase on :8090 (data in ./pb_data, schema auto-applied
# from pb/migrations on first boot)
./.tmp-pb/pocketbase serve --http=127.0.0.1:8090 --dir=./pb_data --migrationsDir=./pb/migrations
# Terminal 2 — one-time setup: create a local admin and seed the content
./.tmp-pb/pocketbase superuser upsert dev@greatbear.in devpass12345 --dir=./pb_data --migrationsDir=./pb/migrations
PB_URL=http://127.0.0.1:8090 PB_SUPERUSER_EMAIL=dev@greatbear.in PB_SUPERUSER_PASSWORD=devpass12345 \
node scripts/seed.mjs
# Terminal 2 — the site (talks to PocketBase on 127.0.0.1:8090 automatically)
cd app && npm run dev
```
- PocketBase admin UI: `http://127.0.0.1:8090/_/` (dev@greatbear.in) — edits show up on the
site on the next page load
- Dev data lives in `./pb_data` (git-ignored); delete it for a factory reset
- The PB binary in `.tmp-pb/` was fetched during setup — if it's missing, grab a release from
https://github.com/pocketbase/pocketbase/releases (or run PocketBase however you like; only
the port matters)
`npm run build` produces `.output/server/index.mjs`, started with `npm start`. To run the
**production image** locally instead:
```bash
podman build -t localhost/greatbear:dev .
podman run --rm -p 3000:3000 -p 8090:8090 \
-e PB_SUPERUSER_EMAIL=dev@greatbear.in -e PB_SUPERUSER_PASSWORD=devpass12345 \
-v greatbear_dev_data:/app/pb_data localhost/greatbear:dev
```
## Deploying
The image is built with **Podman/Buildah** (not Docker) from the committed tree
(`git archive HEAD`) and tagged with its **commit id**, so what runs on the host
is exactly what git knows about. `deploy.sh` pushes the commit first, refuses a
dirty tracked tree and requires `HEAD == origin/main`. Log in to the registry
once per machine: `podman login registry.tanshu.com`.
```bash
./deploy.sh # push, build <commit>, deploy it to emily
./deploy.sh --no-push # skip the push (you already pushed)
./deploy.sh --force # skip the guards
```
Or run the pieces yourself:
```bash
make build-production # podman build HEAD, push :<commit> + :latest
make build-check # compile check from HEAD, no push
cd ansible && ansible-playbook -u tanshu playbook.yml -e "image_tag=$(git rev-parse --short HEAD)"
```
The image is built for **linux/arm64** by default because `emily` is aarch64.
Podman has no built-in QEMU, so cross-building from an x86_64 controller needs
binfmt there (`qemu-user-binfmt`, already installed on this machine); override
with `PLATFORM=linux/amd64`.
### Ansible deployment
Requirements on the controller: `ansible` (no collections — the play uses
built-in modules plus the `podman` / `systemctl --user` CLI). The target host
must already run the fleet's shared Caddy **rootless Quadlet** setup: the
`caddy/playbook.yml` from the `ansible-playbooks` repo owns the Caddy unit, the
rootless prerequisites (Podman, `loginctl enable-linger`, the low-port sysctl)
and the Caddyfile this play appends to.
The play targets the inventory host `emily` (present in `/etc/ansible/hosts`),
connects as the unprivileged container user and **does not `become`**: the
Quadlet units belong to that user, and the SSH connection's PAM session is what
makes `systemctl --user` work.
```bash
cd ansible
ansible-playbook -u tanshu playbook.yml -e "image_tag=<commit>"
```
All deployment variables live in `ansible/vars/default.yml` (registry, image
tag, domains, PocketBase admin credentials). The play:
1. Writes `~/.config/containers/systemd/greatbear.network`
(`NetworkName=greatbear_net`) and a drop-in that attaches the shared Caddy
unit to it, so Caddy can resolve `greatbear` by container name.
2. Logs in to the private registry, pulls
`registry.tanshu.com/tanshu/greatbear:{image_tag}` (rollback is the same play
with an older commit), writes
`~/podman-data/greatbear/.env`, installs
`~/.config/containers/systemd/greatbear.container`, starts it as a **systemd
user service** and waits for the image's healthcheck (PocketBase
`/api/health` + the site).
3. Appends the site blocks to the shared Caddyfile
(`~/podman-data/caddy/conf/Caddyfile`) and reloads Caddy:
- `www.greatbear.in` (+ apex `greatbear.in`) — frontend on port 3000,
PocketBase API/admin proxied under `/api/*` and `/_/*`
- `admin.greatbear.in` — redirects to the PocketBase admin UI
Nothing is published on the host; Caddy reaches the container over
`greatbear_net`. The container runs as uid 0, so the unit maps it onto the podman
user with `UserNS=keep-id:uid=0,gid=0`: `~/podman-data/greatbear` stays owned by
`tanshu`, and PocketBase's files need no `podman unshare` to inspect or back up.
DNS records for all three hosts must point at the server; 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` (templated from `pb_admin_email` / `pb_admin_password` in
`ansible/vars/default.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/`.
## Updating the food menu
The food menu is managed in code: `app/src/data/menu-data.mjs` (11 categories, ~137 items,
single source for the PocketBase sync **and** the site's offline fallback).
```bash
# 1. Edit app/src/data/menu-data.mjs, commit & push, deploy (if the image changed)
# 2. Sync the menu into PocketBase — from anywhere with admin credentials:
PB_URL=https://admin.greatbear.in \
PB_SUPERUSER_EMAIL=admin@greatbear.in \
PB_SUPERUSER_PASSWORD=... \
node scripts/update-menu.mjs
```
The script **replaces** all `menu_categories` / `ingredients` / `menu_items` records (deletes
old, inserts new; item ingredient uids are mapped to the fresh ingredient record ids) and
touches nothing else.
### `menu_items` schema (POS-oriented)
- `uid` — stable slug identity
- `category` — slug → `menu_categories`
- `name` / `variant` — variants are **separate products** whenever their price differs
("Penne Arrabiata" + variant "Veg" ₹589 / "Chicken" ₹639); the POS can group by `name`
- `description`
- `price` — number, rupees with optional paise after the decimal point
(449 = ₹449, 449.5 = ₹449.50)
- `ingredients` — many-to-many relation to the `ingredients` collection (Nuts, Dairy,
Gluten, Soy, Mushroom, Egg, Chicken, Fish, Prawns, Pork, Mutton). Each ingredient has a
name, an emoji `icon` (rendered as chips on menu cards) and a `diet_class`
(`veg`/`nonveg`); an item's green/red FSSAI badge is **derived** — it shows non-veg when
any linked ingredient is non-veg
- `diet` — select: `veg` / `nonveg` / `mixed` (a bool can't be null in PocketBase, so mixed
items like Caesar Salad use `mixed`; the site renders no badge for it)
- `pairing` — optional beer pairing
- `tag` — badge chip; also carries same-price choice lists (e.g. "Malai / Angara")
- `image` — optional URL
- `sku` — unique POS code, `GB-<CAT>-<NNN>` (e.g. GB-MNC-014)
- `available` — false hides the item from the site (86'd) without deleting it
- `tax_category` — default `food-5-gst`; adjust per item for POS GST mapping
Admin edits to menu records are overwritten by the next sync — treat `menu-data.mjs` as the
menu's source of truth.
## Local development (IDE)
`.vscode/launch.json` has run configurations for VS Code / Antigravity:
- **GreatBear (full stack)** — compound: PocketBase (:8091) + Vite dev server (:3000)
- `PocketBase (dev, :8091)` — serves `./pb_data` with `./pb/migrations`
- `Site (Vite dev, :3000)` — the website against the local PocketBase
- `Site (production build + preview)` — builds `.output` and previews it
- `Sync menu into PocketBase` — runs `scripts/update-menu.mjs` against the local PB
Dev PocketBase runs on **8091** (8090 is occupied by another service on this machine).
First-time setup is in the comments at the top of `launch.json`.
## Container environment
| Variable | Default | Purpose |
| ----------------------- | ------------------------ | -------------------------------------- |
| `PORT` | `3000` | Site server port |
| `HOST` | `0.0.0.0` | Site server bind address |
| `PB_URL` | `http://127.0.0.1:8090` | PocketBase URL used by the site server |
| `PB_DATA_DIR` | `/app/pb_data` | PocketBase data directory (volume) |
| `PB_SUPERUSER_EMAIL` | — | Initial admin (bootstrapped at start) |
| `PB_SUPERUSER_PASSWORD` | — | Initial admin password |