diff --git a/src/app/page.tsx b/src/app/page.tsx index 8e7c560..3021f81 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,22 +1,117 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { describeInstance, type InstanceShape } from '@/lib/businesses'; import { getLang, getT } from '@/lib/i18n'; +/** + * The instance front door. + * + * There is no single true `/` for Torim, because there is no single shape of + * deployment. The same build serves one salon on a Raspberry Pi and a shared host with + * fifty tenants, and those want different pages — so this asks the database which one + * it is and answers accordingly: + * + * empty — nothing created yet. The visitor is the operator who just deployed this; + * send them to sign in and create the first business. + * single — the common self-hosted case. `/` *is* that shop's booking page, so go + * there. No configuration, no env var: one business is one business. + * multi — a shared instance. Say what Torim is, point owners at sign-in, and tell + * customers to use the link their business gave them. + * + * What the multi case deliberately does NOT do is list the businesses. People trim + * URLs, and the visitor who lands here after chopping `/b/some-shop` off the end is + * owed a way forward — but not a directory of everyone else hosted alongside them. + * `describeInstance()` stops counting at two precisely so that list is never built. + * + * Rendered per request: the answer changes the moment someone completes onboarding, + * and a `/` cached at build time would tell the first owner their own instance is + * still empty. + */ +export const dynamic = 'force-dynamic'; + +/** + * `/` is the one route that must never 500. not-found.tsx sends every visitor who + * followed a dead link here, so a database that does not answer has to degrade rather + * than throw — otherwise the escape hatch from a 404 is a 500. + * + * The shared landing is the safe default because it is true of every instance: it + * names no business and promises nothing about this deployment's contents. A + * single-business install loses one redirect until the database is back; nobody reads + * a false sentence. + * + * Kept out of the caller so the `redirect()` below is never inside a `try` — it + * signals by throwing, and a catch would swallow it. + */ +async function resolveShape(): Promise { + try { + return await describeInstance(); + } catch (error) { + console.error('[home] could not read the business list; showing the shared landing', error); + return { kind: 'multi' }; + } +} + +const CTA_CLASS = + 'inline-flex w-fit items-center justify-center rounded-md bg-blue px-5 py-2.5 text-sm font-medium text-surface no-underline hover:bg-blue-700'; + +const CARD_CLASS = 'flex flex-col gap-2 rounded-md border border-line bg-surface px-5 py-5 shadow-soft'; + export default async function Home() { const lang = await getLang(); const t = getT(lang); + const shape = await resolveShape(); + + if (shape.kind === 'single') { + redirect(`/b/${shape.slug}`); + } + + if (shape.kind === 'empty') { + return ( +
+ + {t('home.setup.badge')} + + +

+ {t('home.setup.heading')} +

+ +

{t('home.setup.body')}

+ + + {t('home.setup.cta')} + +
+ ); + } + return ( -
+
{t('home.badge')} -

+

{t('home.heading')}

{t('home.tagline')}

-

{t('home.description')}

+
+
+

{t('home.book.title')}

+

{t('home.book.body')}

+
+ +
+

{t('home.owner.title')}

+

{t('home.owner.body')}

+ + {t('home.owner.cta')} + +
+
); } diff --git a/src/lib/businesses.ts b/src/lib/businesses.ts index 1b35dc4..33d1eed 100644 --- a/src/lib/businesses.ts +++ b/src/lib/businesses.ts @@ -5,7 +5,7 @@ * link has no session and no tenant context, so the slug has to be resolvable before * one exists. That is why these use the systemQuery path — reach for it knowingly. */ -import { systemQueryOne } from './db'; +import { systemQuery, systemQueryOne } from './db'; export type PublicBusiness = { id: string; @@ -93,3 +93,35 @@ export async function findBusinessById(id: string): Promise { + const rows = await systemQuery<{ slug: string }>( + 'SELECT slug FROM torim.businesses ORDER BY created_at, slug LIMIT 2', + ); + + if (rows.length === 0) return { kind: 'empty' }; + if (rows.length === 1) return { kind: 'single', slug: rows[0].slug }; + return { kind: 'multi' }; +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 8f64375..f42f54b 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -34,12 +34,24 @@ const en: Dictionary = { 'common.goHome': 'Back to home', 'common.tryAgain': 'Try again', - 'home.badge': 'Coming soon', + 'home.badge': 'Booking software', 'home.heading': 'Simple, bilingual appointment booking', 'home.tagline': 'Torim gives small businesses a clean way to manage bookings in Hebrew and English.', - 'home.description': - 'The booking page for this business isn’t live yet. Once it is, customers will be able to pick a time here in their own language.', + + 'home.book.title': 'Here to book an appointment?', + 'home.book.body': + 'Every business has its own booking link — use the one yours gave you. There is no directory here by design: businesses on this instance cannot see, or be found through, each other.', + + 'home.owner.title': 'Run a business?', + 'home.owner.body': 'Sign in to manage your services, opening hours and appointments.', + 'home.owner.cta': 'Sign in', + + 'home.setup.badge': 'First run', + 'home.setup.heading': 'No business is set up yet', + 'home.setup.body': + 'Torim is installed and running. Sign in to create the first business — you will get a booking link to share with your customers straight away.', + 'home.setup.cta': 'Create the first business', 'notFound.title': 'Page not found', 'notFound.description': 'This page doesn’t exist, or it may have moved.', @@ -102,13 +114,26 @@ const he: Dictionary = { 'common.goHome': 'חזרה לדף הבית', 'common.tryAgain': 'נסה שוב', - 'home.badge': 'בקרוב', + 'home.badge': 'מערכת לקביעת תורים', 'home.heading': 'קביעת תורים דו-לשונית ופשוטה', 'home.tagline': 'טורים נותן לעסקים קטנים דרך נקייה לנהל תורים בעברית ובאנגלית.', - 'home.description': - 'דף קביעת התורים של העסק הזה עוד לא פעיל. כשהוא יעלה, לקוחות יוכלו לבחור כאן זמן פנוי בשפה שלהם.', + + 'home.book.title': 'באתם לקבוע תור?', + 'home.book.body': + 'לכל עסק יש קישור משלו לקביעת תורים — השתמשו בקישור שקיבלתם מהעסק. אין כאן מדריך עסקים, וזה בכוונה: עסקים על השרת הזה לא רואים זה את זה ולא ניתן להגיע מאחד לשני.', + + 'home.owner.title': 'מנהלים עסק?', + 'home.owner.body': + 'התחברו כדי לנהל את השירותים, שעות הפעילות והתורים שלכם.', + 'home.owner.cta': 'התחברות', + + 'home.setup.badge': 'הפעלה ראשונה', + 'home.setup.heading': 'עדיין לא הוגדר עסק', + 'home.setup.body': + 'טורים מותקן ופעיל. התחברו כדי ליצור את העסק הראשון — מיד תקבלו קישור לקביעת תורים לשתף עם הלקוחות.', + 'home.setup.cta': 'יצירת העסק הראשון', 'notFound.title': 'הדף לא נמצא', 'notFound.description': diff --git a/src/lib/instance-shape.test.ts b/src/lib/instance-shape.test.ts new file mode 100644 index 0000000..999bc6a --- /dev/null +++ b/src/lib/instance-shape.test.ts @@ -0,0 +1,72 @@ +/** + * describeInstance — telling an empty deployment from a single-business one from a + * shared one. + * + * Database-backed rather than mocked, for the same reason the rest of this suite is: + * the `LIMIT 2` that makes the lookup cheap lives in SQL, and a stub that hands back + * arrays would prove nothing about the query that actually runs. + * + * Every case starts by emptying `torim.businesses`. That is safe here and nowhere + * else: the whole file runs inside one pinned transaction rolled back in `afterAll` + * (see test-db.ts), and vitest runs test files one at a time (`fileParallelism: false` + * in vitest.config.mts), so no other suite has rows in scope to lose. + * + * Requires: TEST_DATABASE_URL migrated (`npm run migrate`) and granted (`npm run db:grant`). + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { describeInstance } from './businesses'; +import { systemQuery } from './db'; +import { startTestTransaction, type TestDatabase } from './test-db'; + +let db: TestDatabase; + +async function addBusiness(slug: string, name: string): Promise { + await systemQuery( + `INSERT INTO torim.businesses (slug, name, timezone, currency) + VALUES ($1, $2, 'Asia/Jerusalem', 'ILS')`, + [slug, name], + ); +} + +beforeAll(async () => { + db = await startTestTransaction(); +}); + +afterAll(async () => { + await db.rollback(); +}); + +beforeEach(async () => { + await systemQuery('DELETE FROM torim.businesses'); +}); + +describe('describeInstance', () => { + it('reports an empty instance when no business has been created yet', async () => { + expect(await describeInstance()).toEqual({ kind: 'empty' }); + }); + + it('reports the slug when exactly one business exists', async () => { + await addBusiness('only-shop', 'The Only Shop'); + + expect(await describeInstance()).toEqual({ kind: 'single', slug: 'only-shop' }); + }); + + it('reports multi as soon as there is a second business, without naming either', async () => { + await addBusiness('first-shop', 'First Shop'); + await addBusiness('second-shop', 'Second Shop'); + + const shape = await describeInstance(); + + expect(shape).toEqual({ kind: 'multi' }); + // The point of the LIMIT 2: no tenant list is ever assembled, so none can leak. + expect(JSON.stringify(shape)).not.toContain('shop'); + }); + + it('still reports multi well past two businesses', async () => { + for (const slug of ['shop-a', 'shop-b', 'shop-c', 'shop-d', 'shop-e']) { + await addBusiness(slug, slug); + } + + expect(await describeInstance()).toEqual({ kind: 'multi' }); + }); +});