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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user