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:
@@ -162,6 +162,126 @@ export async function getDeck(
|
||||
return cards.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* A deck with no job behind it — the live demo on the entry screen.
|
||||
*
|
||||
* `getDeck` is job-scoped: it joins `jobs` for the category and the location and
|
||||
* anti-joins `swipes`. Someone who has not signed in has none of those, so this
|
||||
* centres on the launch city instead.
|
||||
*
|
||||
* The eligibility rules are deliberately IDENTICAL to getDeck's — verified,
|
||||
* accepting work, not banned, and willing to travel to the point in question.
|
||||
* Nobody should ever appear in the shop window who could not appear on a real
|
||||
* deck. If you change one, change both.
|
||||
*/
|
||||
export async function getShowcaseDeck(
|
||||
db: Db,
|
||||
args: { lat: number; lng: number; limit?: number; now?: Date },
|
||||
): Promise<DeckCard[]> {
|
||||
const limit = args.limit ?? DECK_PAGE_SIZE;
|
||||
const now = args.now ?? new Date();
|
||||
|
||||
const rows = await db.execute<{
|
||||
pro_id: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
headline: string;
|
||||
bio: string;
|
||||
hourly_rate_cents: number;
|
||||
years_experience: number;
|
||||
rating_avg: string | null;
|
||||
rating_count: number;
|
||||
completed_jobs: number;
|
||||
response_rate: string | null;
|
||||
avg_response_minutes: number | null;
|
||||
distance_m: number;
|
||||
service_radius_m: number;
|
||||
last_active_at: string | null;
|
||||
created_at: string;
|
||||
photos: string[] | null;
|
||||
categories: string[] | null;
|
||||
}>(sql`
|
||||
WITH centre AS (
|
||||
SELECT ST_SetSRID(ST_MakePoint(${args.lng}, ${args.lat}), 4326)::geography AS g
|
||||
)
|
||||
SELECT
|
||||
p.user_id AS pro_id,
|
||||
u.name,
|
||||
u.image,
|
||||
p.headline,
|
||||
p.bio,
|
||||
p.hourly_rate_cents,
|
||||
p.years_experience,
|
||||
p.rating_avg,
|
||||
p.rating_count,
|
||||
p.completed_jobs,
|
||||
p.response_rate,
|
||||
p.avg_response_minutes,
|
||||
ST_Distance(p.base_location, centre.g) AS distance_m,
|
||||
p.service_radius_m,
|
||||
u.last_active_at,
|
||||
p.created_at,
|
||||
COALESCE(
|
||||
(SELECT array_agg(m.url ORDER BY m.position)
|
||||
FROM pro_media m WHERE m.pro_id = p.user_id),
|
||||
'{}'
|
||||
) AS photos,
|
||||
COALESCE(
|
||||
(SELECT array_agg(c.name)
|
||||
FROM pro_categories pc
|
||||
JOIN categories c ON c.id = pc.category_id
|
||||
WHERE pc.pro_id = p.user_id),
|
||||
'{}'
|
||||
) AS categories
|
||||
FROM pro_profiles p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
CROSS JOIN centre
|
||||
WHERE p.verification_status = 'verified'
|
||||
AND p.is_accepting_jobs = true
|
||||
AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now()))
|
||||
AND ST_DWithin(p.base_location, centre.g, p.service_radius_m)
|
||||
ORDER BY ST_Distance(p.base_location, centre.g) ASC
|
||||
LIMIT ${CANDIDATE_POOL}
|
||||
`);
|
||||
|
||||
const cards = rows.map((r) => {
|
||||
const ratingAvg = r.rating_avg === null ? null : Number(r.rating_avg);
|
||||
const responseRate = r.response_rate === null ? null : Number(r.response_rate);
|
||||
const input: RankingInput = {
|
||||
ratingAvg,
|
||||
ratingCount: Number(r.rating_count),
|
||||
responseRate,
|
||||
distanceM: Number(r.distance_m),
|
||||
serviceRadiusM: Number(r.service_radius_m),
|
||||
lastActiveAt: r.last_active_at ? new Date(r.last_active_at) : null,
|
||||
createdAt: new Date(r.created_at),
|
||||
now,
|
||||
};
|
||||
|
||||
return {
|
||||
proId: r.pro_id,
|
||||
name: r.name,
|
||||
image: r.image,
|
||||
headline: r.headline,
|
||||
bio: r.bio,
|
||||
hourlyRateCents: Number(r.hourly_rate_cents),
|
||||
yearsExperience: Number(r.years_experience),
|
||||
ratingAvg,
|
||||
ratingCount: Number(r.rating_count),
|
||||
completedJobs: Number(r.completed_jobs),
|
||||
responseRate,
|
||||
avgResponseMinutes: r.avg_response_minutes === null ? null : Number(r.avg_response_minutes),
|
||||
distanceM: Math.round(Number(r.distance_m)),
|
||||
photos: r.photos ?? [],
|
||||
categories: r.categories ?? [],
|
||||
score: score(input),
|
||||
} satisfies DeckCard;
|
||||
});
|
||||
|
||||
cards.sort((a, b) => b.score - a.score || a.distanceM - b.distanceM);
|
||||
return cards.slice(0, limit);
|
||||
}
|
||||
|
||||
/** How many cards are left, for the "deck is running dry" empty state. */
|
||||
export async function getDeckCount(db: Db, jobId: string): Promise<number> {
|
||||
const rows = await db.execute<{ count: number }>(sql`
|
||||
|
||||
@@ -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 pro’s 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user