Initial build: Great Bear microbrewery site

- TanStack Start (React 19, Tailwind 4) frontend recreated from Stitch design
- PocketBase backend with schema migration + idempotent content seed
- Single Docker image running both services (non-root, healthchecked)
- deploy.sh to build & push to registry.tanshu.com
- Ansible playbook (roles: app, caddy) deploying to www.greatbear.in
  with Caddy TLS entries for the site and the PocketBase admin
This commit is contained in:
2026-09-03 08:51:47 +05:30
commit 7b070fde81
66 changed files with 8375 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import {
createReservationRecord,
fetchBrews,
fetchExperiences,
fetchMenuCategories,
fetchMenuItems,
fetchPairings,
fetchSettings,
fetchStats,
fetchTestimonials,
} from './pb'
export const getSettings = createServerFn({ method: 'GET' }).handler(() => fetchSettings())
export const getHomeData = createServerFn({ method: 'GET' }).handler(async () => {
const [stats, brews, experiences, testimonials] = await Promise.all([
fetchStats(),
fetchBrews(),
fetchExperiences(),
fetchTestimonials(),
])
return {
stats,
featuredBrews: brews.filter((b) => b.featured).slice(0, 4),
experiences,
testimonials,
}
})
export const getBrewsData = createServerFn({ method: 'GET' }).handler(async () => {
const brews = await fetchBrews()
return {
flagship: brews.find((b) => b.flagship) ?? null,
coreBrews: brews.filter((b) => b.on_tap),
seasonalBrews: brews.filter((b) => b.seasonal),
flightBrews: brews.filter((b) => b.flight),
}
})
export const getMenuData = createServerFn({ method: 'GET' }).handler(async () => {
const [categories, items, pairings] = await Promise.all([
fetchMenuCategories(),
fetchMenuItems(),
fetchPairings(),
])
return { categories, items, pairings }
})
export const getVisitData = createServerFn({ method: 'GET' }).handler(() => fetchSettings())
const reservationSchema = z.object({
name: z.string().trim().min(2, 'Please tell us your name'),
phone: z.string().trim().min(7, 'Please provide a valid phone number'),
source: z.enum(['table', 'quick', 'flight']),
date: z.string().optional(),
time: z.string().optional(),
guests: z.string().optional(),
requests: z.string().optional(),
})
export const createReservation = createServerFn({ method: 'POST' })
.validator(reservationSchema)
.handler(async ({ data }) => {
await createReservationRecord(data)
return { ok: true as const }
})
+100
View File
@@ -0,0 +1,100 @@
/**
* Server-side PocketBase access.
*
* All content lives in PocketBase; this module fetches it over the local
* HTTP API. If PocketBase is unreachable (e.g. during an image build check
* or a maintenance restart) every fetch falls back to the bundled seed
* content so the site still renders.
*/
import seed from '~/data/content.json'
import type {
Brew,
Experience,
MenuCategory,
MenuItem,
Pairing,
Settings,
Stat,
Testimonial,
} from './types'
const PB_URL = process.env.PB_URL ?? 'http://127.0.0.1:8090'
const TIMEOUT_MS = 3000
interface PbRecords<T> {
items: T[]
}
async function pbList<T>(collection: string): Promise<T[] | null> {
try {
const res = await fetch(
`${PB_URL}/api/collections/${collection}/records?sort=sort&perPage=200`,
{ signal: AbortSignal.timeout(TIMEOUT_MS) },
)
if (!res.ok) return null
const json = (await res.json()) as PbRecords<T>
return json.items
} catch {
return null
}
}
function orFallback<T>(items: T[] | null, fallback: T[]): T[] {
return items && items.length > 0 ? items : fallback
}
export async function fetchSettings(): Promise<Settings> {
const items = await pbList<Settings>('settings')
return items?.[0] ?? seed.settings
}
export async function fetchStats(): Promise<Stat[]> {
return orFallback(await pbList<Stat>('stats'), seed.stats)
}
export async function fetchBrews(): Promise<Brew[]> {
return orFallback(await pbList<Brew>('brews'), seed.brews)
}
export async function fetchExperiences(): Promise<Experience[]> {
return orFallback(await pbList<Experience>('experiences'), seed.experiences)
}
export async function fetchTestimonials(): Promise<Testimonial[]> {
return orFallback(await pbList<Testimonial>('testimonials'), seed.testimonials)
}
export async function fetchMenuCategories(): Promise<MenuCategory[]> {
return orFallback(await pbList<MenuCategory>('menu_categories'), seed.menu_categories)
}
export async function fetchMenuItems(): Promise<MenuItem[]> {
return orFallback(await pbList<MenuItem>('menu_items'), seed.menu_items)
}
export async function fetchPairings(): Promise<Pairing[]> {
return orFallback(await pbList<Pairing>('pairings'), seed.pairings)
}
export interface CreateReservationPayload {
name: string
phone: string
source: string
date?: string
time?: string
guests?: string
requests?: string
}
export async function createReservationRecord(payload: CreateReservationPayload): Promise<void> {
const res = await fetch(`${PB_URL}/api/collections/reservations/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(TIMEOUT_MS),
})
if (!res.ok) {
throw new Error(`PocketBase rejected the reservation (HTTP ${res.status})`)
}
}
+97
View File
@@ -0,0 +1,97 @@
export interface Settings {
uid: string
brand: string
footer_about: string
address_line: string
city_line: string
hours_line: string
hours_multiline: string
phone_primary: string
phone_secondary: string
instagram_url: string
instagram_handle: string
maps_query: string
}
export interface Stat {
uid: string
value: string
label: string
sort: number
}
export interface Brew {
uid: string
name: string
style: string
short_name: string
abv: number
ibu: number
description: string
tasting_notes: string
pairing: string
image: string
featured: boolean
flagship: boolean
on_tap: boolean
seasonal: boolean
seasonal_icon: string
flight: boolean
sort: number
}
export interface Experience {
uid: string
title: string
description: string
icon: string
image: string
sort: number
}
export interface Testimonial {
uid: string
name: string
role: string
quote: string
rating: number
sort: number
}
export interface MenuCategory {
uid: string
slug: string
name: string
sort: number
}
export interface MenuItem {
uid: string
category: string
name: string
description: string
price: number
pairing: string
tag: string
image: string
sort: number
}
export interface Pairing {
uid: string
title: string
description: string
sort: number
}
export type ReservationSource = 'table' | 'quick' | 'flight'
export interface ReservationInput {
name: string
phone: string
source: ReservationSource
date?: string
time?: string
guests?: string
requests?: string
}