Move the demo market to Mexico City, priced in US dollars
The showcase was a Barcelona market: Catalan names, +34 numbers, euro rates and "Carrer Example 12" on every job. Presented to a Mexican client, all of that reads as somebody else's product. City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at 19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were Barcelona literals, so an unset env quietly seeded a different city than the app rendered — they now agree. Two db tests pinned the Barcelona centre as a hardcoded constant, which is why the deck returned zero cards on the first run here: every pro was a continent outside the radius. They read the same env as the seed now, so the trap cannot recur. Money: formatCents defaults to USD/en-US, and the nine hardcoded euro signs across the card, search rows, quote strip and forms are dollars. The rate NUMBERS are unchanged and still read high for CDMX — that is a pricing decision, not a currency one, and is left alone deliberately. Seed people are Mexican, addressed on real Roma/Condesa streets rotated by index rather than one placeholder repeated. Phones moved to +52 55, which moves the demo login to +525500000000 / 000000. Also in here, from the same session: - Sending a job now confirms. The mutation always succeeded; the sheet just closed with no receipt, which from the customer's side is indistinguishable from a dead button. Dismissing that receipt resolves as 'sent', so the card does not return to the deck. - Media moves to DigitalOcean Spaces, with the public origin derived from bucket and region instead of a second env var to keep in sync. - Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM. - The client-facing project panel beside the running app. - Two profiles removed and four renamed to match their photos. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -104,7 +104,7 @@ export default async function AdminProPage({ params }: { params: Promise<{ proId
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-body-sm">
|
||||
<Row label="Status" value={pro.profile.verificationStatus} />
|
||||
<Row label="Accepting jobs" value={pro.profile.isAcceptingJobs ? 'Yes' : 'No'} />
|
||||
<Row label="Hourly rate" value={`€${(pro.profile.hourlyRateCents / 100).toFixed(0)}`} />
|
||||
<Row label="Hourly rate" value={`$${(pro.profile.hourlyRateCents / 100).toFixed(0)}`} />
|
||||
<Row label="Experience" value={`${pro.profile.yearsExperience} years`} />
|
||||
<Row
|
||||
label="Service area"
|
||||
|
||||
@@ -39,7 +39,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
<header className="sticky top-0 z-10 border-b border-hairline bg-page/95 backdrop-blur-[12px]">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-6 py-4">
|
||||
<Link href="/admin" className="font-display text-h4 text-strong">
|
||||
Linkder admin
|
||||
Linkdr admin
|
||||
</Link>
|
||||
<span className="text-meta text-faint">
|
||||
Signed in as {me.name ?? me.email ?? 'admin'}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
import { appRouter, createContext } from '@linkder/api';
|
||||
import { db } from '@linkder/db';
|
||||
import { appRouter, createContext } from '@linkdr/api';
|
||||
import { db } from '@linkdr/db';
|
||||
import { resolveSession } from '@/server/session';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import type { DeckCard } from '@linkdr/db';
|
||||
import { Deck } from '@/components/deck';
|
||||
import { api } from '@/lib/trpc';
|
||||
|
||||
|
||||
@@ -182,14 +182,14 @@ export function NewJobForm({
|
||||
|
||||
<FieldSet label="Budget (optional)">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="From €">
|
||||
<Field label="From $">
|
||||
<Input
|
||||
value={budgetMin}
|
||||
onChange={(e) => setBudgetMin(e.target.value.replace(/[^0-9.]/g, ''))}
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="To €">
|
||||
<Field label="To $">
|
||||
<Input
|
||||
value={budgetMax}
|
||||
onChange={(e) => setBudgetMax(e.target.value.replace(/[^0-9.]/g, ''))}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Wix_Madefor_Display, Wix_Madefor_Text } from 'next/font/google';
|
||||
import { TRPCProvider } from '@/lib/trpc';
|
||||
import { ToastProvider } from '@/components/ui';
|
||||
import { PhoneFrame } from '@/components/chrome/phone-frame';
|
||||
import { ProjectPanel } from '@/components/chrome/project-panel';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
/**
|
||||
@@ -24,12 +25,12 @@ const text = Wix_Madefor_Text({
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Linkder — hire a verified local pro',
|
||||
template: '%s · Linkder',
|
||||
default: 'Linkdr — hire a verified local pro',
|
||||
template: '%s · Linkdr',
|
||||
},
|
||||
description:
|
||||
'Describe the job once, then swipe through verified local plumbers, electricians and handymen. Quote, book and pay in one place.',
|
||||
appleWebApp: { capable: true, statusBarStyle: 'default', title: 'Linkder' },
|
||||
appleWebApp: { capable: true, statusBarStyle: 'default', title: 'Linkdr' },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
@@ -57,12 +58,32 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<TRPCProvider>
|
||||
<ToastProvider>
|
||||
{/*
|
||||
Two columns: the product on the left, what it is on the right.
|
||||
|
||||
The phone lives HERE, not in a page, so that every route renders
|
||||
inside the screen. Putting it in one page meant sign-in, onboarding
|
||||
and the job form all escaped the frame.
|
||||
inside the screen — sign-in, onboarding and the job form included.
|
||||
That is also what lets somebody use the WHOLE app inside the left
|
||||
column without ever leaving it, while the panel beside it stays put.
|
||||
|
||||
Below `lg` the panel drops underneath rather than squashing beside,
|
||||
and on a handset PhoneFrame collapses its bezel so the app simply
|
||||
fills the viewport with the panel below the fold.
|
||||
*/}
|
||||
<div className="flex min-h-dvh w-full items-center justify-center overflow-hidden sm:p-8">
|
||||
<PhoneFrame>{children}</PhoneFrame>
|
||||
<div className="flex min-h-dvh w-full flex-col lg:flex-row lg:items-start">
|
||||
<div
|
||||
className={
|
||||
'flex w-full shrink-0 justify-center sm:p-8 ' +
|
||||
// Pinned beside the panel: the client keeps swiping while they
|
||||
// read, so the phone must not scroll away with the text.
|
||||
'lg:sticky lg:top-0 lg:h-dvh lg:w-auto lg:items-center'
|
||||
}
|
||||
>
|
||||
<PhoneFrame>{children}</PhoneFrame>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 bg-page">
|
||||
<ProjectPanel />
|
||||
</div>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</TRPCProvider>
|
||||
|
||||
@@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic';
|
||||
* The entry screen.
|
||||
*
|
||||
* Not a marketing page. This is the product itself, running: a phone with a
|
||||
* live, draggable deck of real verified pros inside it. Linkder's whole promise
|
||||
* live, draggable deck of real verified pros inside it. Linkdr's whole promise
|
||||
* is a gesture, and a paragraph describing a gesture is worth nothing next to
|
||||
* being able to do it.
|
||||
*
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DEFAULT_SERVICE_RADIUS_M,
|
||||
MAX_SERVICE_RADIUS_M,
|
||||
MIN_SERVICE_RADIUS_M,
|
||||
} from '@linkder/shared';
|
||||
} from '@linkdr/shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { uploadFile } from '@/lib/upload';
|
||||
import {
|
||||
@@ -167,7 +167,7 @@ export function OnboardingWizard({
|
||||
|
||||
{step === 1 && (
|
||||
<Section title="About you" hint="This is what a customer reads on your card.">
|
||||
<Field label="Headline" hint="e.g. Emergency plumber, 15 years in Barcelona">
|
||||
<Field label="Headline" hint="e.g. Emergency plumber, 15 years in CDMX">
|
||||
<Input
|
||||
value={headline}
|
||||
onChange={(e) => setHeadline(e.target.value)}
|
||||
@@ -186,7 +186,7 @@ export function OnboardingWizard({
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Hourly rate (€)">
|
||||
<Field label="Hourly rate ($)">
|
||||
<Input
|
||||
value={hourlyRate}
|
||||
onChange={(e) => setHourlyRate(e.target.value.replace(/[^0-9.]/g, ''))}
|
||||
|
||||
@@ -64,7 +64,7 @@ export default async function ProHomePage() {
|
||||
}
|
||||
/>
|
||||
<Stat label="Travels up to" value={`${Math.round(profile.serviceRadiusM / 1000)} km`} />
|
||||
<Stat label="Rate" value={`€${(profile.hourlyRateCents / 100).toFixed(0)}/hr`} />
|
||||
<Stat label="Rate" value={`$${(profile.hourlyRateCents / 100).toFixed(0)}/hr`} />
|
||||
</dl>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -176,7 +176,7 @@ function ProProfile() {
|
||||
value={String(p.media.length)}
|
||||
/>
|
||||
<SettingsRow label="Trades" value={String(p.categoryIds.length)} />
|
||||
<SettingsRow label="Hourly rate" value={`€${(p.hourlyRateCents / 100).toFixed(0)}`} />
|
||||
<SettingsRow label="Hourly rate" value={`$${(p.hourlyRateCents / 100).toFixed(0)}`} />
|
||||
<SettingsRow
|
||||
label="Service area"
|
||||
value={`${Math.round(p.serviceRadiusM / 1000)} km`}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import { DEFAULT_SERVICE_RADIUS_M } from '@linkdr/shared';
|
||||
import type { DeckCard } from '@linkdr/db';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { useDebouncedValue } from '@/lib/use-debounced-value';
|
||||
import { EmptyState } from '@/components/ui';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { SettingsGroup, SettingsRow, SettingsToggle, buttonClasses } from '@/components/ui';
|
||||
import { SettingsGroup, SettingsRow, SettingsToggle, useToast } from '@/components/ui';
|
||||
import { api, type RouterOutputs } from '@/lib/trpc';
|
||||
import { SignedOut } from '@/components/chrome/signed-out';
|
||||
import { LocationGroup } from '@/components/settings/location-group';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { AccountSection } from '@/components/settings/account-section';
|
||||
import { AccountFooter } from '@/components/settings/account-footer';
|
||||
import type { PhoneTab } from '@/components/chrome/phone-tabs';
|
||||
|
||||
/**
|
||||
* The Settings tab.
|
||||
@@ -13,11 +14,15 @@ import { authClient } from '@/lib/auth-client';
|
||||
* Signed-in only. An anonymous visitor gets a sign-in prompt rather than a
|
||||
* disabled tab, so the bar does not look dead on first open.
|
||||
*/
|
||||
export function SettingsPanel() {
|
||||
export function SettingsPanel({ onNavigate }: { onNavigate?: (tab: PhoneTab) => void }) {
|
||||
const me = api.user.me.useQuery(undefined, { retry: false });
|
||||
|
||||
if (me.isLoading) {
|
||||
return <PanelShell><div className="h-40 animate-pulse rounded-card bg-inset" /></PanelShell>;
|
||||
return (
|
||||
<PanelShell>
|
||||
<LoadingSkeleton />
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
if (me.error || !me.data) {
|
||||
return (
|
||||
@@ -28,22 +33,37 @@ export function SettingsPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
return <SignedIn me={me.data} />;
|
||||
return <SignedIn me={me.data} onNavigate={onNavigate} />;
|
||||
}
|
||||
|
||||
function PanelShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-[3.25rem] pb-4">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-[3.25rem] pb-8">
|
||||
<h1 className="mb-5 text-h2">Settings</h1>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.11. Shaped like what it is waiting for — an identity card and two groups —
|
||||
* rather than one grey slab, so the layout does not jump when the data lands.
|
||||
*/
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div aria-busy className="flex flex-col gap-8">
|
||||
<div className="h-[5.5rem] animate-pulse rounded-card bg-inset" />
|
||||
<div className="h-32 animate-pulse rounded-card bg-inset" />
|
||||
<div className="h-44 animate-pulse rounded-card bg-inset" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Me = RouterOutputs['user']['me'];
|
||||
|
||||
function SignedIn({ me }: { me: Me }) {
|
||||
function SignedIn({ me, onNavigate }: { me: Me; onNavigate?: (tab: PhoneTab) => void }) {
|
||||
const utils = api.useUtils();
|
||||
const toast = useToast();
|
||||
const prefs = api.notification.get.useQuery();
|
||||
const updatePrefs = api.notification.update.useMutation({
|
||||
onMutate: async (next) => {
|
||||
@@ -55,34 +75,21 @@ function SignedIn({ me }: { me: Me }) {
|
||||
},
|
||||
onError: (_e, _next, context) => {
|
||||
if (context?.previous) utils.notification.get.setData(undefined, context.previous);
|
||||
// Without this the switch just slides back under the thumb, which reads as
|
||||
// the tap not registering rather than as the save failing.
|
||||
toast('That did not save. Check your connection and try again.', { tone: 'error' });
|
||||
},
|
||||
onSettled: () => void utils.notification.get.invalidate(),
|
||||
});
|
||||
|
||||
const sessions = api.user.sessions.useQuery();
|
||||
const requestDeletion = api.user.requestDeletion.useMutation();
|
||||
const [deletionAsked, setDeletionAsked] = useState(false);
|
||||
|
||||
const p = prefs.data;
|
||||
const isPro = me.role === 'pro';
|
||||
|
||||
return (
|
||||
<PanelShell>
|
||||
<SettingsGroup title="Account">
|
||||
<SettingsRow label="Name" value={me.name ?? 'Not set'} />
|
||||
<SettingsRow
|
||||
label="Email"
|
||||
value={me.hasContactableEmail ? me.email : 'Add an email'}
|
||||
hint={me.hasContactableEmail ? undefined : 'Needed for receipts and payout statements'}
|
||||
/>
|
||||
{/*
|
||||
Read-only by necessity, not by choice: better-auth's phoneNumber plugin
|
||||
rejects any update carrying a phone, and the number is the login
|
||||
credential and the unique key.
|
||||
*/}
|
||||
<SettingsRow label="Phone" value={me.phoneNumber ?? '—'} hint="Contact support to change" />
|
||||
<SettingsRow label="Account type" value={isPro ? 'Professional' : 'Customer'} />
|
||||
</SettingsGroup>
|
||||
<AccountSection me={me} />
|
||||
|
||||
<LocationGroup isPro={isPro} />
|
||||
|
||||
@@ -119,8 +126,21 @@ function SignedIn({ me }: { me: Me }) {
|
||||
</SettingsGroup>
|
||||
|
||||
{isPro && (
|
||||
<SettingsGroup title="Working" note="Changing your trades sends your profile back for review.">
|
||||
<SettingsRow label="Trades" hint="Edit in your profile" onClick={() => {}} />
|
||||
<SettingsGroup
|
||||
title="Working"
|
||||
note="Changing your trades sends your profile back for review."
|
||||
>
|
||||
{/*
|
||||
The chevron is conditional on the callback because the tab is owned
|
||||
by the shell above this panel. Rendered without one — in a test, or
|
||||
anywhere this panel is mounted alone — it stays a plain fact instead
|
||||
of a button that goes nowhere.
|
||||
*/}
|
||||
<SettingsRow
|
||||
label="Trades"
|
||||
hint="Edit in your profile"
|
||||
onClick={onNavigate ? () => onNavigate('profile') : undefined}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
|
||||
@@ -139,8 +159,13 @@ function SignedIn({ me }: { me: Me }) {
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Legal and data">
|
||||
<SettingsRow label="Terms of service" onClick={() => {}} />
|
||||
<SettingsRow label="Privacy policy" onClick={() => {}} />
|
||||
{/*
|
||||
No onClick, so no chevron. These two documents do not exist yet, and a
|
||||
row that opens nothing is worse than a row that says so — it is
|
||||
indistinguishable from a link that is broken.
|
||||
*/}
|
||||
<SettingsRow label="Terms of service" hint="Published before launch" />
|
||||
<SettingsRow label="Privacy policy" hint="Published before launch" />
|
||||
<SettingsRow
|
||||
label="Download my data"
|
||||
hint="Everything we hold about you, as JSON"
|
||||
@@ -150,36 +175,14 @@ function SignedIn({ me }: { me: Me }) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'linkder-data.json';
|
||||
a.download = 'linkdr-data.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={deletionAsked ? 'Deletion requested' : 'Delete my account'}
|
||||
hint={
|
||||
deletionAsked
|
||||
? 'We will action this within 30 days'
|
||||
: 'We action requests within 30 days'
|
||||
}
|
||||
danger
|
||||
disabled={deletionAsked || requestDeletion.isPending}
|
||||
onClick={() => {
|
||||
requestDeletion.mutate({}, { onSuccess: () => setDeletionAsked(true) });
|
||||
}}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await authClient.signOut();
|
||||
window.location.href = '/';
|
||||
}}
|
||||
className={buttonClasses({ variant: 'outline', size: 'md', block: true })}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
<AccountFooter />
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { X } from 'lucide-react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import type { DeckCard } from '@linkdr/db';
|
||||
import { Deck, type SwipeVerdict } from '@/components/deck';
|
||||
import { Chip, ScrollStrip } from '@/components/ui';
|
||||
import { PhoneTabs, type PhoneTab } from '@/components/chrome/phone-tabs';
|
||||
@@ -167,7 +167,7 @@ export function ShowcaseDeck({
|
||||
return (
|
||||
<div className="flex h-full w-full min-w-0 flex-col overflow-hidden">
|
||||
{tab === 'settings' ? (
|
||||
<SettingsPanel />
|
||||
<SettingsPanel onNavigate={setTab} />
|
||||
) : tab === 'profile' ? (
|
||||
<ProfilePanel />
|
||||
) : tab === 'search' ? (
|
||||
@@ -251,7 +251,16 @@ export function ShowcaseDeck({
|
||||
|
||||
{/* Inside the phone frame, not the page — the sheet belongs to this
|
||||
screen and must not cover the browser chrome around the mock. */}
|
||||
<SendJobSheet pro={hiring} open={hiring !== null} onResolved={onResolved} />
|
||||
<SendJobSheet
|
||||
pro={hiring}
|
||||
open={hiring !== null}
|
||||
onResolved={onResolved}
|
||||
// Straight to the job they just sent, on the Current segment.
|
||||
onViewJobs={() => {
|
||||
setJobs({ ...jobs, segment: 'current', view: { kind: 'list' } });
|
||||
setTab('jobs');
|
||||
}}
|
||||
/>
|
||||
|
||||
<AskSheet
|
||||
pro={asking}
|
||||
|
||||
@@ -7,7 +7,7 @@ export const metadata = { title: 'Sign in' };
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<BareShell back>
|
||||
<p className="text-overline uppercase text-accent">Linkder</p>
|
||||
<p className="text-overline uppercase text-accent">Linkdr</p>
|
||||
<h1 className="mt-3 text-h1">Sign in</h1>
|
||||
<p className="mt-3 text-body text-muted">
|
||||
We will text you a 6-digit code. No password to forget.
|
||||
|
||||
@@ -69,7 +69,7 @@ export function SignInForm() {
|
||||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
required
|
||||
placeholder="+34 600 123 456"
|
||||
placeholder="+52 55 1234 5678"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\s/g, ''))}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
BadgeCheck,
|
||||
CalendarCheck,
|
||||
Check,
|
||||
Copy,
|
||||
MapPin,
|
||||
MessagesSquare,
|
||||
Star,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The right-hand column: what the app in the phone beside it actually is.
|
||||
*
|
||||
* Static on purpose. The client reads this while USING the product in the left
|
||||
* column, so nothing here navigates, nothing here is a screenshot, and nothing
|
||||
* here moves when they swipe.
|
||||
*
|
||||
* Content lives in the arrays below rather than in the markup, so adding a
|
||||
* feature or swapping a dependency is one line and the layout is untouched.
|
||||
*
|
||||
* Both languages live in the SAME entry rather than in two parallel documents —
|
||||
* a decision and its `today` line have to move together, and the fastest way to
|
||||
* end up with a Spanish half-truth is to let two copies of this file drift.
|
||||
*/
|
||||
|
||||
type Lang = 'en' | 'es';
|
||||
|
||||
/** One string in both languages. Everything the client reads is one of these. */
|
||||
type Copy = { en: string; es: string };
|
||||
|
||||
const LANGS: { code: Lang; label: string }[] = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'es', label: 'Español' },
|
||||
];
|
||||
|
||||
const DEMO = { phone: '+525500000000', code: '000000' };
|
||||
|
||||
const UI = {
|
||||
language: { en: 'Language', es: 'Idioma' },
|
||||
copy: { en: 'Copy', es: 'Copiar' },
|
||||
copied: { en: 'copied', es: 'copiado' },
|
||||
today: { en: 'Today: ', es: 'Hoy: ' },
|
||||
} satisfies Record<string, Copy>;
|
||||
|
||||
const INTRO = {
|
||||
title: {
|
||||
en: 'Hire a tradesperson the way you swipe',
|
||||
es: 'Contrata a un profesional deslizando',
|
||||
},
|
||||
body: {
|
||||
en: 'A mobile marketplace connecting customers with verified local trades. Post a job, swipe through pros who cover your street, agree a price in chat, book the slot and review each other afterwards.',
|
||||
es: 'Un marketplace móvil que conecta a clientes con profesionales locales verificados. Publica un trabajo, desliza entre los profesionales que cubren tu calle, acuerda un precio en el chat, reserva la cita y valoraos después.',
|
||||
},
|
||||
} satisfies Record<string, Copy>;
|
||||
|
||||
const TRY = {
|
||||
heading: { en: 'Try it yourself', es: 'Pruébalo tú mismo' },
|
||||
body: {
|
||||
en: 'Sign in on the phone to the left. The whole product runs in there.',
|
||||
es: 'Inicia sesión en el móvil de al lado. El producto entero funciona ahí dentro.',
|
||||
},
|
||||
phone: { en: 'Mobile', es: 'Móvil' },
|
||||
code: { en: 'Code', es: 'Código' },
|
||||
} satisfies Record<string, Copy>;
|
||||
|
||||
const STACK: { group: Copy; items: string[] }[] = [
|
||||
{
|
||||
group: { en: 'App', es: 'App' },
|
||||
items: ['Next.js 15', 'React 19', 'TypeScript', 'Tailwind v4', 'Motion'],
|
||||
},
|
||||
{ group: { en: 'API', es: 'API' }, items: ['tRPC v11', 'Zod', 'better-auth'] },
|
||||
{
|
||||
group: { en: 'Data', es: 'Datos' },
|
||||
items: ['DO Managed Postgres', 'PostGIS', 'Drizzle ORM', 'DO Managed Redis'],
|
||||
},
|
||||
{
|
||||
group: { en: 'Services', es: 'Servicios' },
|
||||
items: ['DO Spaces (S3)', 'Mapbox', 'Twilio', 'Resend', 'Sentry'],
|
||||
},
|
||||
{ group: { en: 'Tooling', es: 'Herramientas' }, items: ['Turborepo', 'pnpm', 'Vitest'] },
|
||||
];
|
||||
|
||||
const FEATURES: { title: Copy; body: Copy; icon: typeof Zap }[] = [
|
||||
{
|
||||
icon: Zap,
|
||||
title: { en: 'Swipe to hire', es: 'Desliza para contratar' },
|
||||
body: { en: 'Send a job with one gesture.', es: 'Envía un trabajo con un solo gesto.' },
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
title: { en: 'Real distance', es: 'Distancia real' },
|
||||
body: {
|
||||
en: 'PostGIS ranks by metres, not postcodes.',
|
||||
es: 'PostGIS ordena por metros, no por códigos postales.',
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: BadgeCheck,
|
||||
title: { en: 'Verified pros', es: 'Profesionales verificados' },
|
||||
body: {
|
||||
en: 'ID, insurance and licence checked first.',
|
||||
es: 'Identidad, seguro y licencia comprobados antes de entrar.',
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: MessagesSquare,
|
||||
title: { en: 'Chat per job', es: 'Un chat por trabajo' },
|
||||
body: { en: 'Private, with photos and receipts.', es: 'Privado, con fotos y recibos.' },
|
||||
},
|
||||
{
|
||||
icon: CalendarCheck,
|
||||
title: { en: 'Quote to booking', es: 'Del presupuesto a la reserva' },
|
||||
body: {
|
||||
en: 'Agree a price, book the slot, confirm.',
|
||||
es: 'Acordáis un precio, se reserva la cita y se confirma.',
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: Star,
|
||||
title: { en: 'Blind reviews', es: 'Valoraciones a ciegas' },
|
||||
body: {
|
||||
en: 'Hidden until both sides have written.',
|
||||
es: 'Ocultas hasta que ambas partes han escrito.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The open questions, grouped.
|
||||
*
|
||||
* Every one of these has a CURRENT behaviour — nothing here is unbuilt because
|
||||
* it was forgotten. `today` says what happens if nobody decides, which is the
|
||||
* only honest way to present a decision: the client is confirming or changing
|
||||
* something, not filling in a blank.
|
||||
*/
|
||||
const DECISIONS: { group: Copy; items: { q: Copy; today: Copy }[] }[] = [
|
||||
{
|
||||
group: { en: 'Money', es: 'Dinero' },
|
||||
items: [
|
||||
{
|
||||
q: {
|
||||
en: 'Do we hold the money until the job is done, or do customers pay the pro directly?',
|
||||
es: '¿Retenemos el dinero hasta que el trabajo esté hecho, o el cliente paga directamente al profesional?',
|
||||
},
|
||||
today: {
|
||||
en: 'Nothing is charged. Quotes and bookings work; no payment is taken at any point.',
|
||||
es: 'No se cobra nada. Los presupuestos y las reservas funcionan; no se cobra en ningún momento.',
|
||||
},
|
||||
},
|
||||
{
|
||||
q: {
|
||||
en: 'What is the commission, and who pays it — the customer, the pro, or split?',
|
||||
es: '¿Cuál es la comisión y quién la paga: el cliente, el profesional o a medias?',
|
||||
},
|
||||
today: {
|
||||
en: 'Set to 15% in config, applied nowhere.',
|
||||
es: 'Fijada al 15% en la configuración, aplicada en ninguna parte.',
|
||||
},
|
||||
},
|
||||
{
|
||||
q: {
|
||||
en: 'Deposit up front, or the whole amount on completion?',
|
||||
es: '¿Señal por adelantado o el importe completo al terminar?',
|
||||
},
|
||||
today: {
|
||||
en: 'Neither. The slot is booked on a promise.',
|
||||
es: 'Ninguna de las dos. La cita se reserva con una promesa.',
|
||||
},
|
||||
},
|
||||
{
|
||||
q: {
|
||||
en: 'A customer cancels the day before — what do they owe?',
|
||||
es: 'Un cliente cancela el día antes: ¿qué debe pagar?',
|
||||
},
|
||||
today: {
|
||||
en: 'Free up to 24h before, then 25%. The rule is written and tested; no money moves.',
|
||||
es: 'Gratis hasta 24 h antes, después el 25%. La regla está escrita y probada; no se mueve dinero.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: { en: 'Scheduling', es: 'Agenda' },
|
||||
items: [
|
||||
{
|
||||
q: {
|
||||
en: 'Do pros publish real availability, or is a time agreed in the chat?',
|
||||
es: '¿Los profesionales publican disponibilidad real, o se acuerda la hora en el chat?',
|
||||
},
|
||||
today: {
|
||||
en: 'Agreed in chat. The customer picks any date and time when accepting a quote.',
|
||||
es: 'Se acuerda en el chat. El cliente elige cualquier fecha y hora al aceptar un presupuesto.',
|
||||
},
|
||||
},
|
||||
{
|
||||
q: {
|
||||
en: 'Should the system stop a pro being double-booked?',
|
||||
es: '¿Debe el sistema impedir que un profesional tenga dos reservas a la vez?',
|
||||
},
|
||||
today: {
|
||||
en: 'No check. Two customers can book the same pro for the same hour.',
|
||||
es: 'No hay ninguna comprobación. Dos clientes pueden reservar al mismo profesional a la misma hora.',
|
||||
},
|
||||
},
|
||||
{
|
||||
q: {
|
||||
en: 'If a customer never confirms the work is finished, should it auto-confirm?',
|
||||
es: 'Si el cliente nunca confirma que el trabajo está terminado, ¿debe confirmarse solo?',
|
||||
},
|
||||
today: {
|
||||
en: 'It waits forever. A 72-hour rule is written but nothing runs it.',
|
||||
es: 'Espera para siempre. Hay una regla de 72 horas escrita, pero nada la ejecuta.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: { en: 'Trust and safety', es: 'Confianza y seguridad' },
|
||||
items: [
|
||||
{
|
||||
q: {
|
||||
en: 'Should we block phone numbers and emails in chat?',
|
||||
es: '¿Bloqueamos teléfonos y correos en el chat?',
|
||||
},
|
||||
today: {
|
||||
en: 'Anything can be sent. Two people can agree to take the job off the platform.',
|
||||
es: 'Se puede enviar cualquier cosa. Dos personas pueden acordar sacar el trabajo de la plataforma.',
|
||||
},
|
||||
},
|
||||
{
|
||||
q: {
|
||||
en: 'What happens when the two sides disagree about finished work?',
|
||||
es: '¿Qué pasa cuando las dos partes no se ponen de acuerdo sobre un trabajo terminado?',
|
||||
},
|
||||
today: {
|
||||
en: 'A disputed state exists in the model. Nothing can reach it.',
|
||||
es: 'Existe un estado «en disputa» en el modelo. Nada puede llegar a él.',
|
||||
},
|
||||
},
|
||||
{
|
||||
q: {
|
||||
en: 'ID checks — automated, or a person reviewing documents?',
|
||||
es: 'Verificación de identidad: ¿automática o revisada por una persona?',
|
||||
},
|
||||
today: {
|
||||
en: 'A person. Documents are uploaded and reviewed by hand in the admin queue.',
|
||||
es: 'Una persona. Los documentos se suben y se revisan a mano en la cola de administración.',
|
||||
},
|
||||
},
|
||||
{
|
||||
q: {
|
||||
en: 'A pro’s insurance expires. Do they come off the platform automatically?',
|
||||
es: 'El seguro de un profesional caduca. ¿Sale de la plataforma automáticamente?',
|
||||
},
|
||||
today: {
|
||||
en: 'The expiry date is stored. Nothing checks it.',
|
||||
es: 'La fecha de caducidad se guarda. Nada la comprueba.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: { en: 'Launch', es: 'Lanzamiento' },
|
||||
items: [
|
||||
{
|
||||
q: {
|
||||
en: 'Launch with the trades we have supply for, or all fifty?',
|
||||
es: '¿Lanzamos con los oficios para los que hay oferta, o con los cincuenta?',
|
||||
},
|
||||
today: {
|
||||
en: 'Fifty trades listed; eight have any pros. The rest look empty to a customer.',
|
||||
es: 'Hay cincuenta oficios listados; ocho tienen profesionales. El resto se ven vacíos para un cliente.',
|
||||
},
|
||||
},
|
||||
{
|
||||
q: {
|
||||
en: 'One city, or several from the start?',
|
||||
es: '¿Una ciudad o varias desde el principio?',
|
||||
},
|
||||
today: {
|
||||
en: 'One. The city is a setting, so a second is configuration rather than a rebuild.',
|
||||
es: 'Una. La ciudad es un ajuste, así que una segunda es configuración, no rehacer nada.',
|
||||
},
|
||||
},
|
||||
{
|
||||
q: {
|
||||
en: 'Which events are worth an SMS, given each one costs money?',
|
||||
es: '¿Qué eventos merecen un SMS, teniendo en cuenta que cada uno cuesta dinero?',
|
||||
},
|
||||
today: {
|
||||
en: 'A pro is texted about a new job and an answer. Messages and bookings are silent.',
|
||||
es: 'Al profesional se le avisa por SMS de un trabajo nuevo y de una respuesta. Los mensajes y las reservas son silenciosos.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Running costs, paid to DigitalOcean rather than to us.
|
||||
*
|
||||
* Listed per line rather than as one number because they scale independently —
|
||||
* the database is the first thing that needs a bigger tier, and storage is the
|
||||
* only one that grows with use.
|
||||
*/
|
||||
const HOSTING: { item: Copy; detail: Copy; usd: number }[] = [
|
||||
{
|
||||
item: { en: 'Managed Postgres', es: 'Postgres gestionado' },
|
||||
detail: { en: 'The database, with PostGIS', es: 'La base de datos, con PostGIS' },
|
||||
usd: 15,
|
||||
},
|
||||
{
|
||||
item: { en: 'Managed Redis', es: 'Redis gestionado' },
|
||||
detail: { en: 'Sessions, caching, job queue', es: 'Sesiones, caché y cola de trabajos' },
|
||||
usd: 15,
|
||||
},
|
||||
{
|
||||
item: { en: 'App Platform', es: 'App Platform' },
|
||||
detail: { en: 'Runs the app itself', es: 'Ejecuta la propia aplicación' },
|
||||
usd: 24,
|
||||
},
|
||||
{
|
||||
item: { en: 'Spaces', es: 'Spaces' },
|
||||
detail: { en: 'Photos and documents', es: 'Fotos y documentos' },
|
||||
usd: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const HOSTING_TOTAL = HOSTING.reduce((sum, h) => sum + h.usd, 0);
|
||||
|
||||
const SECTIONS = {
|
||||
features: { en: 'What it does', es: 'Qué hace' },
|
||||
stack: { en: 'Built with', es: 'Hecho con' },
|
||||
decisions: { en: 'Still to decide', es: 'Aún por decidir' },
|
||||
decisionsBody: {
|
||||
en: 'Everything below already has a behaviour. These are the ones worth choosing deliberately rather than inheriting.',
|
||||
es: 'Todo lo de abajo ya tiene un comportamiento. Estas son las decisiones que conviene tomar a propósito en lugar de heredarlas.',
|
||||
},
|
||||
cost: { en: 'Delivery and cost', es: 'Entrega y coste' },
|
||||
build: { en: 'To build and launch', es: 'Construirlo y lanzarlo' },
|
||||
buildNote: {
|
||||
en: 'One-off. Where it lands depends on the answers above.',
|
||||
es: 'Pago único. Dónde caiga depende de las respuestas de arriba.',
|
||||
},
|
||||
timeline: { en: 'Timeline', es: 'Plazo' },
|
||||
timelineValue: { en: '6–10 weeks', es: '6–10 semanas' },
|
||||
timelineNote: {
|
||||
en: 'Six if the open questions are settled early, ten if they are not.',
|
||||
es: 'Seis si las preguntas abiertas se cierran pronto, diez si no.',
|
||||
},
|
||||
hosting: { en: 'Hosting, per month', es: 'Alojamiento, al mes' },
|
||||
total: { en: 'Total', es: 'Total' },
|
||||
perMonth: { en: '/mo', es: '/mes' },
|
||||
} satisfies Record<string, Copy>;
|
||||
|
||||
/** The three caveats under the hosting table — lead sentence, then the rest. */
|
||||
const HOSTING_NOTES: { lead: Copy; rest: Copy }[] = [
|
||||
{
|
||||
lead: { en: 'You pay DigitalOcean directly.', es: 'Pagas directamente a DigitalOcean.' },
|
||||
rest: {
|
||||
en: ' This is not part of our fee and there is no markup on it — the account is yours, so you can see the bill and change the plan without going through us.',
|
||||
es: ' No forma parte de nuestros honorarios y no lleva ningún recargo: la cuenta es tuya, así que puedes ver la factura y cambiar de plan sin pasar por nosotros.',
|
||||
},
|
||||
},
|
||||
{
|
||||
lead: {
|
||||
en: `$${HOSTING_TOTAL} is the smallest tier of each.`,
|
||||
es: `${HOSTING_TOTAL} $ es el plan más pequeño de cada uno.`,
|
||||
},
|
||||
rest: {
|
||||
en: ' Enough to launch on and to run while the platform is finding its first customers.',
|
||||
es: ' Suficiente para lanzar y para funcionar mientras la plataforma consigue sus primeros clientes.',
|
||||
},
|
||||
},
|
||||
{
|
||||
lead: {
|
||||
en: 'Costs rise with use, unevenly.',
|
||||
es: 'Los costes suben con el uso, de forma desigual.',
|
||||
},
|
||||
rest: {
|
||||
en: ' The database is the first thing that will need a larger plan; storage creeps up slowly as photos accumulate; the app itself can stay where it is for a long time.',
|
||||
es: ' La base de datos es lo primero que necesitará un plan mayor; el almacenamiento crece despacio a medida que se acumulan fotos; la aplicación en sí puede quedarse donde está mucho tiempo.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function ProjectPanel() {
|
||||
const [lang, setLang] = useState<Lang>('en');
|
||||
const t = (copy: Copy) => copy[lang];
|
||||
|
||||
return (
|
||||
// `lang` on the wrapper, not only in state: it is what tells a screen reader
|
||||
// which voice to read this in and a browser which dictionary to hyphenate by.
|
||||
<div lang={lang} className="flex flex-col gap-10 px-6 py-10 lg:px-12 lg:py-14">
|
||||
{/* Above the title it changes, so the client sees the switch before they
|
||||
have started reading. Right-aligned: it is a control on the panel, not
|
||||
a heading of it, and the title keeps the left edge to itself. */}
|
||||
<div className="flex justify-end">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t(UI.language)}
|
||||
className="inline-flex gap-0.5 rounded-pill border border-hairline bg-raised p-1"
|
||||
>
|
||||
{LANGS.map(({ code, label }) => (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
lang={code}
|
||||
onClick={() => setLang(code)}
|
||||
aria-pressed={lang === code}
|
||||
className={cn(
|
||||
'rounded-pill px-3 py-1 text-meta',
|
||||
'transition-colors duration-[120ms] ease-standard',
|
||||
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-brand-200',
|
||||
lang === code
|
||||
? 'bg-accent-soft font-semibold text-accent'
|
||||
: 'text-muted hover:text-strong',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header>
|
||||
<p className="text-overline uppercase text-accent">Linkdr</p>
|
||||
<h1 className="mt-2 text-h1">{t(INTRO.title)}</h1>
|
||||
<p className="mt-3 max-w-[60ch] text-body text-muted text-pretty">{t(INTRO.body)}</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-h3">{t(TRY.heading)}</h2>
|
||||
<p className="mb-4 max-w-[56ch] text-body-sm text-muted">{t(TRY.body)}</p>
|
||||
{/* Side by side: two short values do not need two full-width rows.
|
||||
The code column is content-width so the number keeps the room. */}
|
||||
<div className="flex max-w-lg flex-wrap gap-2">
|
||||
<CopyRow
|
||||
label={t(TRY.phone)}
|
||||
value={DEMO.phone}
|
||||
copyLabel={t(UI.copy)}
|
||||
copiedLabel={t(UI.copied)}
|
||||
className="min-w-56 flex-1"
|
||||
/>
|
||||
<CopyRow
|
||||
label={t(TRY.code)}
|
||||
value={DEMO.code}
|
||||
copyLabel={t(UI.copy)}
|
||||
copiedLabel={t(UI.copied)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-4 text-h3">{t(SECTIONS.features)}</h2>
|
||||
{/* Tight two-column grid: small icon, title and its line on one row.
|
||||
Read standing up, mid-sentence — so it has to scan, not be read. */}
|
||||
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||
{FEATURES.map((f) => (
|
||||
// Keyed on the English string throughout: the key has to survive the
|
||||
// toggle, or React remounts every row on a language change.
|
||||
<div key={f.title.en} className="flex items-start gap-2.5">
|
||||
<f.icon className="mt-0.5 h-4 w-4 shrink-0 text-accent" aria-hidden />
|
||||
<span className="min-w-0">
|
||||
<dt className="inline font-semibold text-body-sm text-strong">{t(f.title)}</dt>
|
||||
<dd className="inline text-body-sm text-muted"> — {t(f.body)}</dd>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-4 text-h3">{t(SECTIONS.stack)}</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{STACK.map((row) => (
|
||||
<div key={row.group.en} className="flex flex-wrap items-baseline gap-x-3 gap-y-2">
|
||||
{/* w-20, not w-16: "Herramientas" is twice the width of "Tooling". */}
|
||||
<span className="w-20 shrink-0 text-meta text-faint">{t(row.group)}</span>
|
||||
{row.items.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="rounded-pill border border-hairline px-3 py-1 text-meta text-strong"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-1 text-h3">{t(SECTIONS.decisions)}</h2>
|
||||
<p className="mb-4 max-w-[56ch] text-body-sm text-muted">{t(SECTIONS.decisionsBody)}</p>
|
||||
<div className="flex flex-col gap-6">
|
||||
{DECISIONS.map((section) => (
|
||||
<div key={section.group.en}>
|
||||
<h3 className="mb-2 text-overline uppercase text-faint">{t(section.group)}</h3>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.q.en} className="border-l-2 border-hairline pl-3">
|
||||
<p className="text-body-sm font-semibold text-strong text-pretty">
|
||||
{t(item.q)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-meta text-muted text-pretty">
|
||||
<span className="text-faint">{t(UI.today)}</span>
|
||||
{t(item.today)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Last, and deliberately after the decisions: the range IS the answer to
|
||||
those questions, so quoting a single number above them would be a
|
||||
promise made before the scope exists. */}
|
||||
<section>
|
||||
<h2 className="mb-4 text-h3">{t(SECTIONS.cost)}</h2>
|
||||
|
||||
<div className="mb-5 flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex-1 rounded-card border border-hairline bg-raised p-5">
|
||||
<p className="text-meta text-faint">{t(SECTIONS.build)}</p>
|
||||
<p className="mt-1 font-display text-h2 text-strong tabular-nums">$4,400–6,000</p>
|
||||
<p className="mt-1 text-body-sm text-muted">{t(SECTIONS.buildNote)}</p>
|
||||
</div>
|
||||
<div className="flex-1 rounded-card border border-hairline bg-raised p-5">
|
||||
<p className="text-meta text-faint">{t(SECTIONS.timeline)}</p>
|
||||
<p className="mt-1 font-display text-h2 text-strong tabular-nums">
|
||||
{t(SECTIONS.timelineValue)}
|
||||
</p>
|
||||
<p className="mt-1 text-body-sm text-muted">{t(SECTIONS.timelineNote)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-2 text-overline uppercase text-faint">{t(SECTIONS.hosting)}</h3>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{HOSTING.map((h) => (
|
||||
<li key={h.item.en} className="flex items-baseline gap-3 text-body-sm">
|
||||
<span className="font-semibold text-strong">{t(h.item)}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-meta text-muted">{t(h.detail)}</span>
|
||||
<span className="shrink-0 text-strong tabular-nums">${h.usd}</span>
|
||||
</li>
|
||||
))}
|
||||
<li className="mt-1.5 flex items-baseline gap-3 border-t border-hairline pt-2 text-body-sm">
|
||||
<span className="flex-1 font-semibold text-strong">{t(SECTIONS.total)}</span>
|
||||
<span className="shrink-0 font-display text-h4 text-strong tabular-nums">
|
||||
${HOSTING_TOTAL}
|
||||
{t(SECTIONS.perMonth)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-3 flex max-w-[60ch] flex-col gap-1.5 text-meta text-muted">
|
||||
{HOSTING_NOTES.map((note) => (
|
||||
<p key={note.lead.en}>
|
||||
<span className="font-semibold text-strong">{t(note.lead)}</span>
|
||||
{t(note.rest)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A value to hand over verbatim, with one tap to copy.
|
||||
*
|
||||
* Reading a phone number off a screen into a form while somebody watches is a
|
||||
* small humiliation; mistyping one in front of a client is a worse one.
|
||||
*/
|
||||
function CopyRow({
|
||||
label,
|
||||
value,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border border-hairline bg-raised px-4 py-2.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 text-meta text-faint">{label}</span>
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-body-sm text-strong tabular-nums">
|
||||
{value}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1600);
|
||||
} catch {
|
||||
// Clipboard can be refused (insecure origin). The value is on screen
|
||||
// and readable, which is the fallback that always works.
|
||||
}
|
||||
}}
|
||||
// The state is in the accessible name too, not only the icon — §8.
|
||||
aria-label={copied ? `${label} ${copiedLabel}` : `${copyLabel} ${label.toLowerCase()}`}
|
||||
className={cn(
|
||||
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg',
|
||||
'transition-colors duration-[120ms] ease-standard',
|
||||
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-brand-200',
|
||||
copied ? 'text-go-600' : 'text-muted hover:bg-inset hover:text-accent',
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion, useMotionValue, useTransform } from 'motion/react';
|
||||
import { Check, Eye, MapPin, MessageCircle, Star, Undo2, X } from 'lucide-react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import type { DeckCard } from '@linkdr/db';
|
||||
import { cn, formatDistance, formatResponseTime } from '@/lib/utils';
|
||||
|
||||
/** Horizontal drag past this many pixels commits the swipe. */
|
||||
@@ -264,7 +264,7 @@ export function Card({
|
||||
<MapPin className="h-4 w-4" aria-hidden />
|
||||
{formatDistance(card.distanceM)}
|
||||
</span>
|
||||
<span>€{(card.hourlyRateCents / 100).toFixed(0)}/hr</span>
|
||||
<span>${(card.hourlyRateCents / 100).toFixed(0)}/hr</span>
|
||||
{card.completedJobs > 0 && <span>{card.completedJobs} jobs done</span>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import type { DeckCard } from '@linkdr/db';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Banner, Button, Sheet, Textarea } from '@/components/ui';
|
||||
import { setPendingHire } from '@/lib/pending-hire';
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, Check } from 'lucide-react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import { AlertTriangle, Check, CheckCircle2 } from 'lucide-react';
|
||||
import type { DeckCard } from '@linkdr/db';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { SocialSignIn } from '@/components/auth/social-sign-in';
|
||||
import { Banner, Button, Sheet } from '@/components/ui';
|
||||
@@ -22,6 +22,7 @@ export function SendJobSheet({
|
||||
pro,
|
||||
open,
|
||||
onResolved,
|
||||
onViewJobs,
|
||||
}: {
|
||||
pro: DeckCard | null;
|
||||
open: boolean;
|
||||
@@ -30,9 +31,20 @@ export function SendJobSheet({
|
||||
* `dismissed` when nothing happened and the card should come back.
|
||||
*/
|
||||
onResolved: (outcome: 'sent' | 'dismissed') => void;
|
||||
/** Take them to the conversation they just started. */
|
||||
onViewJobs: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [chosen, setChosen] = useState<string | null>(null);
|
||||
/**
|
||||
* What was just sent, and how long they have to answer.
|
||||
*
|
||||
* The sheet used to close the instant the mutation resolved, which is
|
||||
* indistinguishable from nothing happening: the card is gone, the sheet is
|
||||
* gone, and the one thing the person wanted to know — did it work — is the
|
||||
* one thing not on screen. Sending a job to a stranger deserves a receipt.
|
||||
*/
|
||||
const [sent, setSent] = useState<{ jobTitle: string; expiresInHours: number } | null>(null);
|
||||
|
||||
const me = api.user.me.useQuery(undefined, { retry: false });
|
||||
const sendable = api.deck.sendable.useQuery(
|
||||
@@ -44,10 +56,15 @@ export function SendJobSheet({
|
||||
|
||||
const utils = api.useUtils();
|
||||
const swipe = api.deck.swipe.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: (result, variables) => {
|
||||
void utils.job.mine.invalidate();
|
||||
void utils.deck.sendable.invalidate();
|
||||
onResolved('sent');
|
||||
|
||||
const job = sendable.data?.jobs.find((j) => j.id === variables.jobId);
|
||||
setSent({
|
||||
jobTitle: job?.title ?? 'your job',
|
||||
expiresInHours: result.requested ? result.expiresInHours : 0,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -60,9 +77,58 @@ export function SendJobSheet({
|
||||
const close = () => {
|
||||
setChosen(null);
|
||||
swipe.reset();
|
||||
onResolved('dismissed');
|
||||
// A sheet dismissed AFTER a successful send must not put the card back —
|
||||
// the pro really does have the job now.
|
||||
onResolved(sent ? 'sent' : 'dismissed');
|
||||
setSent(null);
|
||||
};
|
||||
|
||||
/* ── sent ── */
|
||||
if (sent) {
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={close}
|
||||
title={`Sent to ${name}`}
|
||||
body={sent.jobTitle}
|
||||
actions={
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="lg"
|
||||
block
|
||||
onClick={() => {
|
||||
onResolved('sent');
|
||||
setSent(null);
|
||||
onViewJobs();
|
||||
}}
|
||||
>
|
||||
See it in your jobs
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" block onClick={close}>
|
||||
Keep swiping
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3 rounded-card border border-go-100 bg-go-50 p-4">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-go-600" aria-hidden />
|
||||
<div className="min-w-0 text-body-sm text-ink-800">
|
||||
<p className="font-semibold text-ink-950">{name} has your job.</p>
|
||||
<p className="mt-1">
|
||||
{sent.expiresInHours > 0
|
||||
? `They have ${sent.expiresInHours} hours to answer. If they accept, a private conversation opens and you can agree a price there.`
|
||||
: 'If they accept, a private conversation opens and you can agree a price there.'}
|
||||
</p>
|
||||
<p className="mt-1 text-muted">
|
||||
You can send the same job to other pros while you wait — whoever answers first is
|
||||
not automatically the one you book.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── anonymous ── */
|
||||
if (!me.isLoading && (me.error || !me.data)) {
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CalendarClock, CheckCircle2, FileText } from 'lucide-react';
|
||||
import { formatCents } from '@linkder/shared';
|
||||
import { formatCents } from '@linkdr/shared';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Banner, Button, Input, Sheet, Textarea } from '@/components/ui';
|
||||
import { cn, formatWhen } from '@/lib/utils';
|
||||
@@ -265,7 +265,7 @@ function QuoteSheet({
|
||||
}
|
||||
>
|
||||
<label className="mb-4 flex flex-col gap-2">
|
||||
<span className="text-body-sm text-strong">Price (€)</span>
|
||||
<span className="text-body-sm text-strong">Price ($)</span>
|
||||
<Input
|
||||
value={amount}
|
||||
inputMode="decimal"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { CalendarClock, ChevronLeft, ChevronRight, MessageSquare, Search, Star } from 'lucide-react';
|
||||
import { PAST_JOB_STATUSES } from '@linkder/shared';
|
||||
import { PAST_JOB_STATUSES } from '@linkdr/shared';
|
||||
import { api, type RouterOutputs } from '@/lib/trpc';
|
||||
import { Banner, buttonClasses, EmptyState } from '@/components/ui';
|
||||
import { cn, formatRelativeTime, formatWhen } from '@/lib/utils';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { CalendarClock, ChevronRight, MessageSquare, Users } from 'lucide-react';
|
||||
import type { JobStatus } from '@linkder/shared';
|
||||
import type { JobStatus } from '@linkdr/shared';
|
||||
import { cn, formatRelativeTime, formatWhen } from '@/lib/utils';
|
||||
|
||||
export type Perspective = 'client' | 'pro';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft, MapPin, ShieldCheck, Star } from 'lucide-react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import type { DeckCard } from '@linkdr/db';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Button, EmptyState, ScrollStrip, Tag } from '@/components/ui';
|
||||
import { ReviewList } from './review-list';
|
||||
@@ -106,7 +106,7 @@ export function ProProfilePanel({
|
||||
<MapPin className="h-3.5 w-3.5" aria-hidden />
|
||||
{formatDistance(pro.distanceM)}
|
||||
</span>
|
||||
<span>€{((p?.hourlyRateCents ?? pro.hourlyRateCents) / 100).toFixed(0)}/hr</span>
|
||||
<span>${((p?.hourlyRateCents ?? pro.hourlyRateCents) / 100).toFixed(0)}/hr</span>
|
||||
<span>{p?.yearsExperience ?? pro.yearsExperience} yrs experience</span>
|
||||
{completedJobs > 0 && <span>{completedJobs} jobs done</span>}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { MAX_SKILL_LENGTH, MAX_SKILLS } from '@linkder/shared';
|
||||
import { MAX_SKILL_LENGTH, MAX_SKILLS } from '@linkdr/shared';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Button, FormError, Input, SettingsGroup } from '@/components/ui';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronRight, Star } from 'lucide-react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import type { DeckCard } from '@linkdr/db';
|
||||
import { formatDistance } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
@@ -59,7 +59,7 @@ export function ResultRow({ pro, onOpen }: { pro: DeckCard; onOpen: (proId: stri
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-meta text-faint tabular-nums">
|
||||
<span>{formatDistance(pro.distanceM)}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>€{(pro.hourlyRateCents / 100).toFixed(0)}/hr</span>
|
||||
<span>${(pro.hourlyRateCents / 100).toFixed(0)}/hr</span>
|
||||
{pro.categories[0] && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { MAX_SEARCH_QUERY_LENGTH } from '@linkder/shared';
|
||||
import { MAX_SEARCH_QUERY_LENGTH } from '@linkdr/shared';
|
||||
import { Input } from '@/components/ui';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { SlidersHorizontal } from 'lucide-react';
|
||||
import type { SearchSort } from '@linkder/shared';
|
||||
import { MAX_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M } from '@linkder/shared';
|
||||
import type { SearchSort } from '@linkdr/shared';
|
||||
import { MAX_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M } from '@linkdr/shared';
|
||||
import { Chip } from '@/components/ui';
|
||||
import type { Category } from '@/app/showcase-deck';
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Banner, Button, Sheet } from '@/components/ui';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
|
||||
/**
|
||||
* The two ways out, at the bottom where the thumb is. §9.
|
||||
*
|
||||
* Deletion used to be a red row inside "Legal and data", between the privacy
|
||||
* policy and nothing — one tap, no confirmation, and styled like the two links
|
||||
* above it. It is now the last thing on the screen, it is quieter than Sign out
|
||||
* rather than louder, and the irreversible half happens inside a sheet, which
|
||||
* is the rule §6.14 already stated and this was the case that broke it.
|
||||
*/
|
||||
export function AccountFooter() {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [requested, setRequested] = useState(false);
|
||||
|
||||
const requestDeletion = api.user.requestDeletion.useMutation({
|
||||
onSuccess: () => {
|
||||
setConfirming(false);
|
||||
setRequested(true);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
block
|
||||
onClick={async () => {
|
||||
await authClient.signOut();
|
||||
window.location.href = '/';
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
|
||||
{requested ? (
|
||||
<Banner tone="warning" role="status" title="Deletion requested">
|
||||
We will action this within 30 days. Contact support if you change your mind.
|
||||
</Banner>
|
||||
) : (
|
||||
// A text button, not a filled red one. A big red button at the end of a
|
||||
// scroll is a target; this has to be looked for.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
className="mx-auto h-11 rounded-pill px-4 text-body-sm text-danger hover:underline"
|
||||
>
|
||||
Delete my account
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Sheet
|
||||
open={confirming}
|
||||
onClose={() => setConfirming(false)}
|
||||
title="Delete your account?"
|
||||
body="We action deletion requests within 30 days. Your jobs, messages and reviews go with it, and none of it can be brought back."
|
||||
actions={
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="danger"
|
||||
size="lg"
|
||||
block
|
||||
busy={requestDeletion.isPending}
|
||||
onClick={() => requestDeletion.mutate({})}
|
||||
>
|
||||
Request deletion
|
||||
</Button>
|
||||
<Button variant="ghost" size="md" block onClick={() => setConfirming(false)}>
|
||||
Keep my account
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
FormError,
|
||||
Input,
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
Sheet,
|
||||
useToast,
|
||||
} from '@/components/ui';
|
||||
import { api, type RouterOutputs } from '@/lib/trpc';
|
||||
|
||||
/**
|
||||
* Who you are, at the top of the screen that is about you.
|
||||
*
|
||||
* This replaces four flat rows that read "Name — Not set". Both of the things
|
||||
* worth changing here already had a mutation on the server and no way in from
|
||||
* the UI, so the work was never "add editing" — it was to stop the settings
|
||||
* screen presenting an editable fact as a fixed one.
|
||||
*/
|
||||
|
||||
type Me = RouterOutputs['user']['me'];
|
||||
|
||||
/** Month and year only. A join date is context, not a timestamp. */
|
||||
const MONTH_YEAR = new Intl.DateTimeFormat('en-GB', { month: 'long', year: 'numeric' });
|
||||
|
||||
export function AccountSection({ me }: { me: Me }) {
|
||||
const [editing, setEditing] = useState<'name' | 'email' | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IdentityCard me={me} onEdit={() => setEditing('name')} />
|
||||
|
||||
<SettingsGroup title="Account">
|
||||
<SettingsRow
|
||||
label="Email"
|
||||
value={me.hasContactableEmail ? me.email : 'Not set'}
|
||||
hint={me.hasContactableEmail ? undefined : 'Needed for receipts and payout statements'}
|
||||
onClick={() => setEditing('email')}
|
||||
/>
|
||||
{/*
|
||||
Read-only by necessity, not by choice: better-auth's phoneNumber plugin
|
||||
rejects any update carrying a phone, and the number is the login
|
||||
credential and the unique key.
|
||||
*/}
|
||||
<SettingsRow label="Phone" value={me.phoneNumber ?? '—'} hint="Contact support to change" />
|
||||
</SettingsGroup>
|
||||
|
||||
<NameSheet
|
||||
open={editing === 'name'}
|
||||
initial={me.name ?? ''}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
<EmailSheet open={editing === 'email'} onClose={() => setEditing(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The card the screen opens on.
|
||||
*
|
||||
* Tappable, and the chevron says so — the name is the one thing here anybody
|
||||
* actually wants to change, and burying it in a list below its own display was
|
||||
* how it ended up uneditable in the first place.
|
||||
*
|
||||
* The role is plain text rather than a pill on purpose. §4 principle 4: a pill
|
||||
* is clickable. This is a fact about the account, and dressing a fact as a
|
||||
* control is the same lie the chevron rows were telling.
|
||||
*/
|
||||
function IdentityCard({ me, onEdit }: { me: Me; onEdit: () => void }) {
|
||||
const since = MONTH_YEAR.format(me.createdAt);
|
||||
const role = me.role === 'pro' ? 'Professional' : 'Customer';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label="Edit your name"
|
||||
className={
|
||||
'mb-8 flex w-full items-center gap-4 rounded-card border border-hairline bg-raised p-4 ' +
|
||||
'text-left transition-colors duration-[120ms] ease-standard hover:bg-sunken'
|
||||
}
|
||||
>
|
||||
{me.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- remote avatar, no loader configured
|
||||
<img src={me.image} alt="" className="h-14 w-14 shrink-0 rounded-pill object-cover" />
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-pill bg-inset font-display text-h4 text-muted"
|
||||
>
|
||||
{me.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-display text-h4 text-strong">
|
||||
{me.name ?? 'Add your name'}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-meta text-muted">
|
||||
{role} · joined {since}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<ChevronRight className="h-5 w-5 shrink-0 text-faint" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NameSheet({
|
||||
open,
|
||||
initial,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
initial: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [name, setName] = useState(initial);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// The sheet stays mounted so it can animate out, so its draft has to be reset
|
||||
// on the way in — otherwise a cancelled edit is still sitting there next time.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initial);
|
||||
setError(null);
|
||||
}, [open, initial]);
|
||||
|
||||
const save = api.user.updateProfile.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.user.me.invalidate();
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
|
||||
const trimmed = name.trim();
|
||||
const unchanged = trimmed === initial.trim();
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Your name"
|
||||
body="This is what pros see when you send them a job."
|
||||
actions={
|
||||
<Button
|
||||
block
|
||||
size="lg"
|
||||
busy={save.isPending}
|
||||
disabled={trimmed.length === 0 || unchanged}
|
||||
onClick={() => save.mutate({ name: trimmed })}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Field label="Name">
|
||||
<Input
|
||||
value={name}
|
||||
maxLength={80}
|
||||
autoComplete="name"
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
{error && <FormError>{error}</FormError>}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asking for an address, not setting one.
|
||||
*
|
||||
* `requestEmailChange` parks the address until a token comes back, and it
|
||||
* deliberately never reports a collision — so there is no failure state to show
|
||||
* here beyond a malformed address, and no "saved" state either. What happened
|
||||
* happened in an inbox, which is exactly the outcome a toast is for.
|
||||
*/
|
||||
function EmailSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const toast = useToast();
|
||||
const [email, setEmail] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setEmail('');
|
||||
setError(null);
|
||||
}, [open]);
|
||||
|
||||
const request = api.user.requestEmailChange.useMutation({
|
||||
onSuccess: () => {
|
||||
toast('Check your inbox — we sent a link to confirm the address.', { tone: 'success' });
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
|
||||
const trimmed = email.trim();
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Your email"
|
||||
body="We send a link to confirm it. Nothing changes until you follow it."
|
||||
actions={
|
||||
<Button
|
||||
block
|
||||
size="lg"
|
||||
busy={request.isPending}
|
||||
disabled={trimmed.length === 0}
|
||||
onClick={() => request.mutate({ email: trimmed })}
|
||||
>
|
||||
Send confirmation
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Field label="Email address">
|
||||
<Input
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
{error && <FormError>{error}</FormError>}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
DEFAULT_SERVICE_RADIUS_M,
|
||||
MAX_SERVICE_RADIUS_M,
|
||||
MIN_SERVICE_RADIUS_M,
|
||||
} from '@linkder/shared';
|
||||
} from '@linkdr/shared';
|
||||
import { api } from '@/lib/trpc';
|
||||
import {
|
||||
AddressField,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, Check, LocateFixed, MapPin } from 'lucide-react';
|
||||
import type { LocationInput } from '@linkder/shared';
|
||||
import type { LocationInput } from '@linkdr/shared';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { useDebouncedValue } from '@/lib/use-debounced-value';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -64,7 +64,7 @@ export function Banner({ tone = 'info', title, children, className, role }: Bann
|
||||
/** Inline form error. §6.2 — always announced. */
|
||||
export function FormError({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p role="alert" className="text-body-sm text-stop-500">
|
||||
<p role="alert" className="text-body-sm text-danger">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -62,7 +62,7 @@ export function Field({
|
||||
{hint && <span className="text-meta text-muted">{hint}</span>}
|
||||
{children}
|
||||
{error && (
|
||||
<span role="alert" className="text-body-sm text-stop-500">
|
||||
<span role="alert" className="text-body-sm text-danger">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** A titled group of rows. Settings is a list of lists. */
|
||||
/**
|
||||
* A titled group of rows. Settings is a list of lists.
|
||||
*
|
||||
* The gap below is 32px rather than 24px because these are the "blocks within a
|
||||
* screen" of §4, not siblings in a list — at 24px the group titles stopped
|
||||
* reading as titles and the page became one undifferentiated stack of boxes.
|
||||
*/
|
||||
export function SettingsGroup({
|
||||
title,
|
||||
note,
|
||||
@@ -14,7 +21,7 @@ export function SettingsGroup({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-2 px-1 text-overline uppercase text-faint">{title}</h2>
|
||||
<div className="divide-y divide-hairline overflow-hidden rounded-card border border-hairline bg-raised">
|
||||
{children}
|
||||
@@ -24,11 +31,23 @@ export function SettingsGroup({
|
||||
);
|
||||
}
|
||||
|
||||
/** A read-only or navigational row. */
|
||||
/** Shared by every row shape below, so a group never looks stitched together. */
|
||||
const rowClasses = 'flex w-full items-center gap-3 px-4 py-3.5 text-left';
|
||||
const interactiveClasses = 'transition-colors duration-[120ms] ease-standard hover:bg-sunken';
|
||||
|
||||
/**
|
||||
* A read-only, navigational, or linking row.
|
||||
*
|
||||
* `href` and `onClick` are alternatives, and one of them must be present for the
|
||||
* row to draw a chevron. A row that shows the chevron and does nothing is the
|
||||
* worst state available here — it is indistinguishable from a broken link, so
|
||||
* the affordance is tied to the destination rather than set by hand.
|
||||
*/
|
||||
export function SettingsRow({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
href,
|
||||
onClick,
|
||||
danger,
|
||||
disabled,
|
||||
@@ -36,34 +55,48 @@ export function SettingsRow({
|
||||
label: string;
|
||||
value?: React.ReactNode;
|
||||
hint?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const interactive = Boolean(onClick) && !disabled;
|
||||
const Tag = interactive ? 'button' : 'div';
|
||||
const interactive = Boolean(href ?? onClick) && !disabled;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
{...(interactive ? { type: 'button' as const, onClick } : {})}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-3.5 text-left',
|
||||
interactive && 'transition-colors duration-[120ms] ease-standard hover:bg-sunken',
|
||||
disabled && 'opacity-45',
|
||||
)}
|
||||
>
|
||||
const inner = (
|
||||
<>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={cn('block text-body-sm', danger ? 'text-stop-500' : 'text-strong')}>
|
||||
<span className={cn('block text-body-sm', danger ? 'text-danger' : 'text-strong')}>
|
||||
{label}
|
||||
</span>
|
||||
{hint && <span className="mt-0.5 block text-meta text-muted">{hint}</span>}
|
||||
</span>
|
||||
{value !== undefined && (
|
||||
<span className="shrink-0 text-body-sm text-muted">{value}</span>
|
||||
// Shrinkable and truncating, NOT shrink-0: an email long enough to need
|
||||
// the room used to take it from the label, which collapsed to nothing
|
||||
// while the value it was labelling ran on past the bezel.
|
||||
<span className="min-w-0 truncate text-right text-body-sm text-muted">{value}</span>
|
||||
)}
|
||||
{interactive && <ChevronRight className="h-4 w-4 shrink-0 text-faint" aria-hidden />}
|
||||
</Tag>
|
||||
</>
|
||||
);
|
||||
|
||||
if (href && !disabled) {
|
||||
return (
|
||||
<Link href={href} className={cn(rowClasses, interactiveClasses)}>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (onClick && !disabled) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={cn(rowClasses, interactiveClasses)}>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cn(rowClasses, disabled && 'opacity-45')}>{inner}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,8 +124,8 @@ export function SettingsToggle({
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-3.5 text-left',
|
||||
'transition-colors duration-[120ms] ease-standard hover:bg-sunken',
|
||||
rowClasses,
|
||||
interactiveClasses,
|
||||
disabled && 'pointer-events-none opacity-45',
|
||||
)}
|
||||
>
|
||||
@@ -105,7 +138,10 @@ export function SettingsToggle({
|
||||
className={cn(
|
||||
'relative h-[1.6rem] w-[2.75rem] shrink-0 rounded-pill',
|
||||
'transition-colors duration-[120ms] ease-standard',
|
||||
checked ? 'bg-brand-500' : 'bg-ink-300',
|
||||
// ink-500, not ink-300: an off switch is a UI component and owes 3:1
|
||||
// against the row behind it (§8). ink-300 measured 1.78:1 on white,
|
||||
// which made "off" read as "disabled" as much as it read as a state.
|
||||
checked ? 'bg-brand-500' : 'bg-ink-500',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -2,8 +2,8 @@ import { betterAuth } from 'better-auth';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { admin, phoneNumber } from 'better-auth/plugins';
|
||||
import { nextCookies } from 'better-auth/next-js';
|
||||
import { db, schema } from '@linkder/db';
|
||||
import { isE164 } from '@linkder/shared';
|
||||
import { db, schema } from '@linkdr/db';
|
||||
import { isE164 } from '@linkdr/shared';
|
||||
import { sendVerificationSms } from '@/server/sms';
|
||||
import { isDevLoginPhone, pinDevLoginCode } from '@/server/dev-login';
|
||||
|
||||
@@ -206,7 +206,7 @@ export const auth = betterAuth({
|
||||
* form is load-bearing: if "+34600111222" and "0034600111222" can both be
|
||||
* written, one handset holds two "unique" accounts, a ban is escapable by
|
||||
* retyping, and findPossibleDuplicates cannot see the pair. Callers must
|
||||
* send E.164 — normalise with toE164() from @linkder/shared before
|
||||
* send E.164 — normalise with toE164() from @linkdr/shared before
|
||||
* calling. This runs on both /phone-number/send-otp and
|
||||
* /sign-in/phone-number.
|
||||
*/
|
||||
@@ -217,7 +217,7 @@ export const auth = betterAuth({
|
||||
* not have one, so we mint a synthetic address on a domain we control
|
||||
* and never send to.
|
||||
*
|
||||
* ALWAYS gate outbound mail on isSyntheticEmail() from @linkder/shared.
|
||||
* ALWAYS gate outbound mail on isSyntheticEmail() from @linkdr/shared.
|
||||
* Pros are required to supply a real address during onboarding — they
|
||||
* need payout statements, tax records and dispute notices. Clients stay
|
||||
* phone-only and get SMS receipts.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* pasted into a chat, and this is nobody else's business. Not `localStorage`
|
||||
* either — an intent from last Tuesday is not an intent.
|
||||
*/
|
||||
const KEY = 'linkder:pending-hire';
|
||||
const KEY = 'linkdr:pending-hire';
|
||||
|
||||
export interface PendingHire {
|
||||
proId: string;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { httpBatchLink } from '@trpc/client';
|
||||
import { createTRPCReact, type CreateTRPCReact } from '@trpc/react-query';
|
||||
import { deserialize, serialize } from 'superjson';
|
||||
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
|
||||
import type { AppRouter } from '@linkder/api';
|
||||
import type { AppRouter } from '@linkdr/api';
|
||||
|
||||
// Explicit annotation: pnpm's strict node_modules layout means the inferred
|
||||
// type cannot be named from here (TS2742).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UploadKind } from '@linkder/storage';
|
||||
import type { UploadKind } from '@linkdr/storage';
|
||||
|
||||
interface PresignResult {
|
||||
url: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cache } from 'react';
|
||||
import { headers } from 'next/headers';
|
||||
import { appRouter, createCallerFactory, createInnerContext } from '@linkder/api';
|
||||
import { db } from '@linkder/db';
|
||||
import { appRouter, createCallerFactory, createInnerContext } from '@linkdr/api';
|
||||
import { db } from '@linkdr/db';
|
||||
import { resolveSession } from './session';
|
||||
|
||||
const createCaller = createCallerFactory(appRouter);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '@linkder/db';
|
||||
import { db, schema } from '@linkdr/db';
|
||||
|
||||
/**
|
||||
* A fixed test account for local development.
|
||||
@@ -17,7 +17,7 @@ import { db, schema } from '@linkder/db';
|
||||
*
|
||||
* Any one of them failing falls straight back to the real OTP path.
|
||||
*/
|
||||
const DEV_PHONE = '+34600000000';
|
||||
const DEV_PHONE = '+525500000000';
|
||||
const DEV_CODE = '000000';
|
||||
|
||||
export function isDevLoginEnabled(): boolean {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, eq, ne, sql } from 'drizzle-orm';
|
||||
import { db, schema } from '@linkder/db';
|
||||
import { isSyntheticEmail } from '@linkder/shared';
|
||||
import { db, schema } from '@linkdr/db';
|
||||
import { isSyntheticEmail } from '@linkdr/shared';
|
||||
|
||||
/**
|
||||
* Duplicate-account detection.
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Session, SessionResolver } from '@linkder/api';
|
||||
import { db, schema } from '@linkder/db';
|
||||
import type { Role, VerificationStatus } from '@linkder/shared';
|
||||
import type { Session, SessionResolver } from '@linkdr/api';
|
||||
import { db, schema } from '@linkdr/db';
|
||||
import type { Role, VerificationStatus } from '@linkdr/shared';
|
||||
import { auth } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* Turns an incoming request into a Linkder session.
|
||||
* Turns an incoming request into a Linkdr session.
|
||||
*
|
||||
* The auth library lives behind this one function. Everything downstream — every
|
||||
* tRPC procedure, every authorization check — is written against `Session` from
|
||||
* @linkder/api, so replacing the provider means rewriting this file and nothing
|
||||
* @linkdr/api, so replacing the provider means rewriting this file and nothing
|
||||
* else.
|
||||
*
|
||||
* Two responsibilities beyond "who is this":
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { sendSms } from '@linkder/notify';
|
||||
import { sendSms } from '@linkdr/notify';
|
||||
|
||||
/**
|
||||
* SMS delivery for one-time codes.
|
||||
*
|
||||
* The transport itself now lives in @linkder/notify, so the API package can
|
||||
* The transport itself now lives in @linkdr/notify, so the API package can
|
||||
* reach it too — a tRPC procedure cannot import from `apps/web`, and the sign-in
|
||||
* code and a "somebody wants to hire you" text have no business going out
|
||||
* through two different Twilio clients with two different failure policies.
|
||||
@@ -14,6 +14,6 @@ import { sendSms } from '@linkder/notify';
|
||||
export async function sendVerificationSms(to: string, code: string): Promise<void> {
|
||||
await sendSms(
|
||||
to,
|
||||
`${code} is your Linkder code. It expires in 5 minutes. We will never ask you for it.`,
|
||||
`${code} is your Linkdr code. It expires in 5 minutes. We will never ask you for it.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/*
|
||||
* Linkder design tokens — see DESIGN.md at the repo root.
|
||||
* Linkdr design tokens — see DESIGN.md at the repo root.
|
||||
* Hex values are sampled from wix.com and are normative. Do not hand-tune them
|
||||
* in a component; change them here or add a step to the ramp.
|
||||
*/
|
||||
@@ -138,6 +138,14 @@
|
||||
--accent: var(--color-brand-500);
|
||||
--accent-hover: var(--color-brand-600);
|
||||
--accent-soft: var(--color-brand-50);
|
||||
/*
|
||||
* Destructive TEXT, which is a different job from the destructive fill.
|
||||
* stop-500 is the error colour of §2.3 and stays the fill and icon colour —
|
||||
* as a graphical object it only owes 3:1 and it clears that. As body text it
|
||||
* measures 3.97:1 on white and misses the 4.5:1 floor in §8, so a label that
|
||||
* says "Delete my account" uses this instead. §8 is the floor; it wins.
|
||||
*/
|
||||
--danger: var(--color-stop-600);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -154,6 +162,9 @@
|
||||
--accent: var(--color-brand-400);
|
||||
--accent-hover: var(--color-brand-200);
|
||||
--accent-soft: rgb(94 151 255 / 0.14);
|
||||
/* Inverted for the same reason: stop-600 is 3.0:1 on the dark page. §2.3
|
||||
already names stop-400 "error on dark" — this is where that gets used. */
|
||||
--danger: var(--color-stop-400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +181,7 @@
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-danger: var(--danger);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
Reference in New Issue
Block a user