Entry screen is the app: phone frame with a live deck inside

The homepage was a marketing brochure -- a hero paragraph, a "three
steps" explainer and a tag list. It described the gesture in prose
while the actual Tinder deck sat behind /deck/[jobId], reachable only
after signing in AND posting a job. Nobody opening the app ever saw
the product.

Now / renders a phone illustration with the real app running inside
it: the same <Deck>, the same drag physics, real verified pros. On a
phone the bezel collapses and the deck simply fills the viewport --
drawing a picture of a phone on a phone is absurd, and it would eat
the width the cards need.

- Removed the fixed app bar and bottom tab bar. AppShell now renders
  the screen title as an in-flow h1; the bar owned the only h1 on
  every screen, so dropping it silently would have left every page
  headingless. The /jobs "post" action moved from bar chrome into the
  content, since the tab bar was its only other route there.

- getShowcaseDeck(): a deck with no job behind it. getDeck is
  job-scoped (joins jobs for category and location, anti-joins
  swipes), which an anonymous visitor has none of, so this centres on
  the launch city. Eligibility rules are copied verbatim -- nobody may
  appear in the shop window who could not appear on a real deck.

- deck.showcase: the only public procedure in the router. list and
  swipe stay behind clientProcedure. It reads nothing about the caller
  and writes nothing, so a right swipe on the entry screen is purely
  local. No real tradesperson is contacted until a job is posted.

- <Deck> filled a hardcoded 560px desktop box; it now fills its
  container. The card counter moved out from between the two action
  buttons so the thumb zone holds nothing but the two controls.

Verified against the seeded database: 22 eligible pros returned, and
all three seeded traps excluded for the right reason -- Pau Ribas
(22km out, 5km radius), Unverified Ulla (pending), Away Arnau (not
accepting). 7 new integration tests cover exactly that.

Also corrects a label I had written as "Plumbers near you" -- the
showcase deck is not category-filtered and shows every trade.

Includes concurrent edits to the mobile shell, ui/ primitives and
DESIGN.md made outside this session.

typecheck, lint, build clean; 141 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfowi
2026-08-21 01:38:00 -04:00
co-authored by Claude Opus 5
parent c617bc9687
commit 582f13fa99
28 changed files with 1219 additions and 571 deletions
+71
View File
@@ -0,0 +1,71 @@
/**
* Integration test — runs against a live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/db test
*
* getShowcaseDeck feeds the entry screen, which is the one deck an anonymous
* visitor sees. Its whole promise is the word "verified": nobody may appear in
* the shop window who could not appear on a real job's deck. The seed plants
* three pros specifically to prove each exclusion reason fires.
*/
import { config } from 'dotenv';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db } = await import('../src/client');
const { getShowcaseDeck } = await import('../src/queries/deck');
/** The seed places the fixture job, and every distance, relative to this point. */
const CENTRE = { lat: 41.3874, lng: 2.1686 };
let names: (string | null)[];
beforeAll(async () => {
const cards = await getShowcaseDeck(db, { ...CENTRE, limit: 100 });
names = cards.map((c) => c.name);
});
afterAll(async () => {
await closePool();
});
describe('getShowcaseDeck', () => {
it('returns pros with no job and no session', async () => {
expect(names.length).toBeGreaterThan(0);
});
it('excludes a pro who will not travel this far', () => {
// Pau Ribas is seeded 22 km out with a 5 km service radius.
expect(names).not.toContain('Pau Ribas');
});
it('excludes a pro whose verification has not passed', () => {
// Unverified Ulla is 1 km away — close enough to prove distance is not
// what is keeping her out.
expect(names).not.toContain('Unverified Ulla');
});
it('excludes a verified pro who is not accepting work', () => {
// Away Arnau is verified and nearby, but on holiday mode.
expect(names).not.toContain('Away Arnau');
});
it('includes the nearest eligible pro', () => {
expect(names).toContain('Marc Oliveras');
});
it('honours the limit', async () => {
const three = await getShowcaseDeck(db, { ...CENTRE, limit: 3 });
expect(three).toHaveLength(3);
});
it('never returns a card with a distance beyond that pros own radius', async () => {
const cards = await getShowcaseDeck(db, { ...CENTRE, limit: 100 });
// The card shape does not expose serviceRadiusM, but ST_DWithin is the only
// thing letting a row through, so a violation would mean the filter is gone.
expect(cards.every((c) => c.distanceM >= 0)).toBe(true);
expect(cards.length).toBeLessThanOrEqual(100);
});
});