diff --git a/.env.example b/.env.example index 0f4d75a..6e311b7 100644 --- a/.env.example +++ b/.env.example @@ -12,12 +12,59 @@ REDIS_URL=redis://localhost:6389 # generate with: openssl rand -base64 32 AUTH_SECRET= AUTH_URL=http://localhost:3000 -# Optional. Leave blank and phone OTP is the only route: the "Continue with -# Google" button still renders, and tells the user it is not set up. +# Social sign-in. Each provider is optional and independent — leave a pair +# blank and phone OTP still works. The buttons render either way and tell the +# user when a provider is not set up, so the screen never changes shape between +# environments. Set BOTH values of a pair or neither: a half-set pair is treated +# as unset (see lib/auth.ts). +# # Authorised redirect URI: {NEXT_PUBLIC_APP_URL}/api/auth/callback/google AUTH_GOOGLE_ID= AUTH_GOOGLE_SECRET= +# Microsoft Entra ID (Azure AD). Register an app at +# https://entra.microsoft.com > App registrations, add a Web platform with +# redirect URI {NEXT_PUBLIC_APP_URL}/api/auth/callback/microsoft, then create a +# client secret under Certificates & secrets. +# +# TENANT_ID decides WHO may sign in and defaults to `common`: +# common work, school and personal Microsoft accounts +# organizations work and school only +# consumers personal only +# one organisation only +# For a consumer marketplace `common` is almost always what you want — set the +# app registration's supported account types to match, or sign-in fails at +# Microsoft's end with AADSTS50194 no matter what is set here. +AUTH_MICROSOFT_ID= +AUTH_MICROSOFT_SECRET= +AUTH_MICROSOFT_TENANT_ID=common + +# GitHub. Create an OAuth app at +# https://github.com/settings/developers > New OAuth App, with +# Authorization callback URL {NEXT_PUBLIC_APP_URL}/api/auth/callback/github. +# +# GitHub only returns a primary email if the OAuth app requests `user:email` +# AND the account has a verified one; a user whose email is private signs up +# with no address, so never assume `users.email` is reachable mail — gate +# outbound on isSyntheticEmail() from @linkder/shared, same as phone signups. +AUTH_GITHUB_ID= +AUTH_GITHUB_SECRET= + +# ---- Geocoding (Mapbox) ---- +# Turns a typed address into the coordinates the deck matches on. Without it, +# every job and every pro base falls back to the city centre and is stored with +# location_precision='city' — honest, but unmatched: ST_Distance measures a +# constant and ST_DWithin passes everyone. +# +# The token MUST be entitled for PERMANENT geocoding. We store the coordinates +# indefinitely because they are the matching primitive, and Mapbox's temporary +# endpoint forbids persistence — every request sets permanent=true, so a token +# without that entitlement returns 401/403 rather than silently working. +MAPBOX_TOKEN= +# ISO 3166-1 alpha-2. Bounds results to one country: "Carrer de Sants" matches +# in several places and the wrong continent is a worse answer than none. +MAPBOX_COUNTRY=es + # ---- Phone OTP (Twilio Verify) ---- TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= @@ -54,3 +101,7 @@ TWILIO_FROM_NUMBER= # Dev-only fixed login (+34600000000 / code 000000). MUST stay false/unset in production. ALLOW_DEV_LOGIN=false + +# Bugsink (Sentry-compatible error tracking). Write-only ingest key, safe in the +# client bundle. Leave blank to disable reporting entirely. +NEXT_PUBLIC_SENTRY_DSN= diff --git a/DESIGN.md b/DESIGN.md index 440764a..8be2134 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -301,6 +301,144 @@ buttons are 64px circles, 2px border, `ink-0` fill. --- +### 6.9 Search field + +The §6.2 input, 48px, with a 20px `Search` glyph inset 16px from the left in `ink-500`, and a +44px circular clear button on the right that exists only while the field has content. + +The label is **visible above the field**, never the placeholder — §6.2 applies here more than +anywhere, because a placeholder disappears at exactly the moment someone needs reminding what +the box searches. The placeholder carries examples of what to type, not the name of the field. + +### 6.10 Result row + +`radius-card`, `1px ink-200`, `raised` fill, 16px padding, **12px between rows**. A 56px +`radius-md` thumbnail leads; where there is no image, its initial on an `inset` fill — never an +empty grey square. Title in `h4`, one `body-sm ink-600` line, one `meta` line of figures in +`tabular-nums`, trailing chevron in `accent`. + +Hover and press move the border to `brand-500`. Rows do not lift, scale or shadow: a list of +twenty is a scanning surface, and twenty things that react is noise. + +A row is **not** a deck card. The deck card is a single-decision object with its own gesture; +reusing it in a list costs the list its scroll. + +### 6.11 Skeletons + +`inset` fill with `animate-pulse`, at the exact height and radius of whatever it stands in for. +Never more than one screenful — three rows is enough to say "loading"; twenty is a lie about +what is coming. + +While REFRESHING existing content, keep the old content on screen instead. A list that blanks on +every keystroke reads as "no results", repeatedly. + +### 6.12 Empty and no-result + +Two different states, two different messages. + +- **Empty** — nothing asked yet. Say what this screen can do and give a way in. +- **No result** — something asked, nothing found. Name what was searched and offer the single + most effective filter to relax. Never a bare "No results." + +Both use `EmptyState` (§6.3): dashed hairline, centred, `h4` title, `body-sm ink-600` body. + +### 6.13 Segmented control + +Two or three mutually exclusive views of **the same list** — "Current / Past", not navigation. +Full width, `radius-pill`, `ink-100` track, 4px inset padding. The selected segment is an +`ink-0` pill on `shadow-sm` with `ink-950` text at weight 600; unselected is `ink-600` on the +bare track. Each segment is a 40px-high target inside a 44px row. + +A segment may carry a count after its label in `tabular-nums`; a zero count is rendered, not +hidden, because "Past 0" is information and a missing number reads as a loading state. + +Use it only where the segments are the same kind of thing and the user is switching lens. Where +the destinations differ in kind, that is the tab bar's job (§6.7), and where one option is a +filter on a list that has other filters too, use chips (§6.4). Never more than three segments — +at four, the labels truncate at 360px and it becomes a worse tab bar. + +Implemented as a `radiogroup`: the selected segment carries `aria-checked`, and selection is +never signalled by fill alone (§8) — the weight change carries it too. + +### 6.14 Bottom sheet + +The only modal this product has. It rises from the bottom edge, because that is where the thumb +already is (§4) and a centred dialog on a 390px screen is just a card with the page greyed out. + +`radius-card` on the **top two corners only**, `page` surface, `shadow-lg`, full width, capped +at 85% of the viewport height with its body scrolling inside. A 36×4px `ink-200` grab handle sits +centred at the top — the affordance that says this can be dismissed downward. Behind it, a +`rgb(0 6 36 / .45)` scrim. + +Layout is title (`h3`), optional one-line body (`body-sm ink-600`), content, then actions pinned +at the bottom of the sheet: primary full-width, dismissal as a `ghost` beneath it. Actions never +scroll out of reach. + +Dismissal is by scrim tap, Escape, or the grab handle — and **all three mean the same thing**. +A sheet whose scrim tap silently confirms is a trap. Anything destructive or irreversible gets an +explicit button; the sheet closing is always "no". + +Enter and exit use `motion-slow` with the sheet translating and the scrim fading; under +`prefers-reduced-motion` both simply appear. `role="dialog"` with `aria-modal`, focus moves to the +sheet on open and returns to the trigger on close. + +Use it for a decision that needs context the current screen cannot show — picking which job to +send a pro, confirming a cancellation. Not for navigation, and not for anything with more than one +input: a form belongs on a screen. + +### 6.15 Pro profile + +What a result row (§6.10) opens onto, and the only screen in the app whose job is *reading* +rather than deciding. It is a pushed screen (§6.6): 44px back chevron top-left labelled with +where it came from, never a bare arrow. + +Order is fixed, because it is an argument and the parts only work in sequence: lead photo, +name in `h1`, headline, then one `meta tabular-nums` line of figures — rating, distance, rate, +years, jobs done. The verification line sits directly under it in `go-700` with a shield glyph; +it is the one claim this marketplace is actually selling, so it is never further down the page. + +Then titled blocks, each skipped entirely when empty rather than rendered as a heading over +nothing: **Trades** and **Specialises in** as `Tag` pills (§6.4), **About**, **Their work** as a +horizontally scrolling strip, and **Reviews**. + +Reviews are a page, not a history. The heading says so — "Showing the most recent of 47" — +because `ratingCount` in the header counts every rating and the list below it never will, and two +numbers that disagree without explanation read as a bug. Each review is a `radius-card` row: 36px +round avatar or initial, author name, five stars filled to the rating, relative date, then the +body. Stars carry their value in the accessible name (§8); an unrated pro gets the `New` pill and +never `0.0 ★`. + +The primary action is pinned to the bottom of the screen, not placed after the reviews — a page +of reviews is exactly the length that buries a button (§9). It opens the send sheet (§6.14) +rather than acting directly, because "which job?" is a question this screen cannot answer. + +### 6.16 Address field + +An address is the only input in this product that must resolve to something real: distance is +what the deck ranks on, so a typed line that never became a coordinate is not an answer. + +The §6.9 search field geometry, with a suggestion list below it and a **precision line** beneath +that. Suggestions are §6.10 result rows at 56px, no thumbnail, title plus one `meta` line, and +the list caps at five — a sixth is a scroll inside a form and nobody reads it. + +The precision line is the component's whole reason for existing and is never optional: + +| State | Line | Tone | +|---|---|---| +| Resolved to a street address | "Matched to
" | `go-600` with a check | +| Resolved to a street/area only | "Approximate — we will match from " | `sun-500` with an alert | +| Nothing resolved | "No address yet — matching from " | `ink-600`, no icon | + +Never show only a tick. "We found something" and "we found the right thing" are different +claims, and a field that renders them identically is how a placeholder gets stored as a +location. + +A "use my current location" control sits below, and on success **must** fill the text with a +reverse-geocoded label — a button that silently sets an invisible pin gives the user nothing to +check. + +--- + ## 7. Motion | Token | Duration | Easing | Use | diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 33012f2..a098e94 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,4 +1,5 @@ import { config as loadEnv } from 'dotenv'; +import { withSentryConfig } from '@sentry/nextjs'; import type { NextConfig } from 'next'; // The monorepo keeps one .env at the root; Next only looks in the app directory. @@ -21,4 +22,26 @@ const config: NextConfig = { serverExternalPackages: ['postgres'], }; -export default config; +/** + * Bugsink speaks the Sentry protocol, so the Sentry build plugin applies — but + * only the parts that make sense for a self-hosted error tracker. + * + * Source maps are uploaded so a minified production stack is readable, and then + * deleted from the build output so they are not served publicly. Everything + * tracing-related stays off: Bugsink does not ingest it. + */ +export default withSentryConfig(config, { + // Bugsink has no organisation/project slugs in the Sentry sense; the DSN + // carries the project. These are only used by the upload step, which is + // skipped entirely without an auth token. + silent: true, + disableLogger: true, + sourcemaps: { + deleteSourcemapsAfterUpload: true, + }, + // Do NOT route events through a Next rewrite: the tunnel exists to dodge ad + // blockers against sentry.io, and this DSN is our own host already. + tunnelRoute: undefined, + // The SDK's automatic Vercel Cron instrumentation has nothing to talk to here. + automaticVercelMonitors: false, +}); diff --git a/apps/web/package.json b/apps/web/package.json index adcb23b..81af4e5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,8 +14,10 @@ "dependencies": { "@linkder/api": "workspace:*", "@linkder/db": "workspace:*", + "@linkder/notify": "workspace:*", "@linkder/shared": "workspace:*", "@linkder/storage": "workspace:*", + "@sentry/nextjs": "^10.70.0", "@tanstack/react-query": "^5.62.0", "@trpc/client": "^11.18.0", "@trpc/react-query": "^11.18.0", @@ -23,14 +25,14 @@ "better-auth": "1.7.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "drizzle-orm": "0.38.4", "lucide-react": "^0.469.0", "motion": "^11.15.0", "next": "^15.1.4", "react": "^19.0.0", "react-dom": "^19.0.0", "superjson": "^2.2.6", - "tailwind-merge": "^2.6.0", - "drizzle-orm": "0.38.4" + "tailwind-merge": "^2.6.0" }, "devDependencies": { "@eslint/eslintrc": "3.2.0", diff --git a/apps/web/src/app/admin/[proId]/decision-panel.tsx b/apps/web/src/app/admin/[proId]/decision-panel.tsx new file mode 100644 index 0000000..84825a9 --- /dev/null +++ b/apps/web/src/app/admin/[proId]/decision-panel.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { api } from '@/lib/trpc'; +import { Button, Field, FormError, Textarea, useToast } from '@/components/ui'; + +/** + * Approve, reject, suspend. + * + * The only place in the product where one person's decision makes another + * person visible to customers, so nothing here is a one-tap action: approving + * asks for confirmation, and rejecting refuses to proceed without a reason — + * the server enforces that too, because a rejection the pro cannot act on + * becomes a support ticket instead of a fixed profile. + */ +export function DecisionPanel({ + proId, + status, + banned, +}: { + proId: string; + status: string; + banned: boolean; +}) { + const router = useRouter(); + const toast = useToast(); + const [notes, setNotes] = useState(''); + const [reason, setReason] = useState(''); + const [error, setError] = useState(null); + + const onError = (e: { message: string }) => setError(e.message); + const done = (message: string) => { + setError(null); + toast(message); + // The queue, the badge counts and this page all move together. + router.refresh(); + }; + + const decide = api.admin.decide.useMutation({ + onSuccess: (r) => done(r.status === 'verified' ? 'Approved — they are live.' : 'Rejected.'), + onError, + }); + const suspend = api.admin.suspend.useMutation({ + onSuccess: () => done('Suspended. They are off every surface.'), + onError, + }); + const unsuspend = api.admin.unsuspend.useMutation({ + onSuccess: () => done('Reinstated.'), + onError, + }); + + const busy = decide.isPending || suspend.isPending || unsuspend.isPending; + const canDecide = status === 'pending'; + + return ( +
+

Decision

+ + {canDecide ? ( + <> + +