Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 98 additions & 3 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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<InstanceShape> {
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 (
<div className="mx-auto flex w-full max-w-2xl flex-col items-start gap-6 px-4 py-16 sm:px-6 sm:py-24">
<span className="mono-label rounded-sm bg-lime-soft px-2 py-1 text-lime-ink">
{t('home.setup.badge')}
</span>

<h1 className="font-display text-3xl font-semibold leading-tight text-ink sm:text-4xl">
{t('home.setup.heading')}
</h1>

<p className="max-w-xl text-lg text-body">{t('home.setup.body')}</p>

<Link href="/login" className={CTA_CLASS}>
{t('home.setup.cta')}
</Link>
</div>
);
}

return (
<div className="mx-auto flex max-w-3xl flex-col items-start gap-6 px-4 py-16 sm:px-6 sm:py-24">
<div className="mx-auto flex w-full max-w-2xl flex-col items-start gap-6 px-4 py-16 sm:px-6 sm:py-24">
<span className="mono-label rounded-sm bg-lime-soft px-2 py-1 text-lime-ink">
{t('home.badge')}
</span>

<h1 className="font-display text-4xl font-semibold leading-tight text-ink sm:text-5xl">
<h1 className="font-display text-3xl font-semibold leading-tight text-ink sm:text-4xl">
{t('home.heading')}
</h1>

<p className="max-w-xl text-lg text-body">{t('home.tagline')}</p>

<p className="max-w-xl text-body">{t('home.description')}</p>
<div className="flex w-full flex-col gap-4">
<section className={CARD_CLASS}>
<h2 className="font-display text-lg font-semibold text-ink">{t('home.book.title')}</h2>
<p className="text-body">{t('home.book.body')}</p>
</section>

<section className={CARD_CLASS}>
<h2 className="font-display text-lg font-semibold text-ink">{t('home.owner.title')}</h2>
<p className="text-body">{t('home.owner.body')}</p>
<Link href="/login" className={`${CTA_CLASS} mt-2`}>
{t('home.owner.cta')}
</Link>
</section>
</div>
</div>
);
}
34 changes: 33 additions & 1 deletion src/lib/businesses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -93,3 +93,35 @@ export async function findBusinessById(id: string): Promise<PublicBusiness | nul
]);
return row ? toBusiness(row) : null;
}
/**
* What shape of instance is this?
*
* Torim is multi-tenant, but most deployments are one salon on one box. `/` cannot
* answer "what should I show?" without knowing which of the three cases it is in, and
* the three want genuinely different pages: an empty instance wants its owner to sign
* in and create a business, a single-business instance wants to *be* that booking page,
* and a shared instance must not hint at who else is on it.
*
* `LIMIT 2` is the whole trick: two rows is all it takes to tell none from one from
* many, so this never scans a tenant list however large the instance grows — and it
* never has one in memory to leak.
*
* Uses the systemQuery path knowingly: `torim.businesses` is one of the three tables
* deliberately outside RLS (see the header of scripts/sql/001_tenancy.sql), precisely
* because it has to be readable before any tenant context exists. Nothing here is
* routing around a policy.
*/
export type InstanceShape =
| { kind: 'empty' }
| { kind: 'single'; slug: string }
| { kind: 'multi' };

export async function describeInstance(): Promise<InstanceShape> {
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' };
}
37 changes: 31 additions & 6 deletions src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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':
Expand Down
72 changes: 72 additions & 0 deletions src/lib/instance-shape.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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' });
});
});
Loading