Components
Everything in components/. All of it is server-rendered only — see
ARCHITECTURE.md §3
before adding interactivity.
| File | Export | Kind | Used by |
|---|---|---|---|
BusinessCard.tsx |
BusinessCard (named) |
component | /o/[uuid] |
LinksPage.tsx |
LinksPage (named) |
component | /o/[uuid] |
ShowcasePage.tsx |
ShowcasePage (named) |
component | /o/[uuid] |
ErrorDisplay.tsx |
ErrorDisplay (default) |
component | /o/[uuid], _404, _500 |
QRCode.tsx |
QRCodeSVG (named) |
plain function | /o/[uuid] |
LinkPageView.tsx |
LinkPageView, LinkUnavailable (named) |
component | /o/[uuid], /builder/preview |
ErrorDisplay is the only default export; the rest are named. Match the
surrounding style when adding files — named exports for components that take a
link prop.
Two subtrees have their own conventions and are documented elsewhere:
components/alpha/ (ALPHA.md) and components/builder/
(BUILDER.md).
BusinessCard
interface BusinessCardProps {
link: BusinessCardLink;
qrCodeSvg: string;
}
Renders one person's contact card. The qrCodeSvg string is produced by the
route, not by this component, and is injected raw:
<div
class="bg-white p-3 rounded-xl shadow-sm border border-slate-100"
dangerouslySetInnerHTML={{ __html: qrCodeSvg }}
/>;
This is the only use of dangerouslySetInnerHTML in the codebase. It is safe
because the SVG is generated server-side by qrcode-svg from a URL the server
itself constructs — no user input reaches it. If the QR source ever becomes
data-driven, this becomes an injection point.
Derived values at the top of the component:
const fullName = `${data.firstName} ${data.lastName}`;
const whatsappUrl = `https://wa.me/${data.phone.replace("+", "")}`;
const phoneUrl = `tel:${data.phone}`;
const emailUrl = `mailto:${data.email}`;
const vcardUrl = `/o/${link.uuid}/vcard`;
vcardUrl uses link.uuid — the field inside the JSON, not the filename. They
must match (see DATA-MODEL.md).
.replace("+", "") replaces only the first occurrence, which is correct for
E.164 but differs from the global .replace(/\+/g, "") used in LinksPage.
Local icon components: WhatsAppIcon, PhoneIcon, EmailIcon,
SaveContactIcon — each a bare
<svg viewBox="0 0 24 24" class="w-5 h-5 fill-current"> with one <path>.
Layout notes:
- Header is
sticky top-0 z-10, heighth-28→md:h-40, backgroundconfig.primaryColor. - The logo sits on a white rounded plate so dark and light logos both read against the coloured header.
- Save Contact is
fixed bottom-0 left-0 right-0on mobile,md:relativeon desktop; the scroll region carriespb-16 md:pb-6to clear it. - The "Powered by onnne.link" strip is
hidden md:block— invisible on phones.
LinksPage
interface LinksPageProps {
link: LinksPageLink;
}
A two-column grid of action tiles. Contains two local helpers.
Icons
Both grids draw from the shared ICON_PATHS map in
components/icons.ts — 14 names, each a single 24x24
SVG path string:
globe · phone · email · whatsapp · instagram · linkedin · twitter
· facebook · youtube · snapchat · map · download · calendar · menu
getIconPath(name, fallback) takes the fallback as an argument rather than
hardcoding one, because the two grids genuinely disagree: a links-page tile
falls back to globe, a showcase-page social icon to instagram.
getActionUrl(action)
Switches over the discriminated action union to produce an href:
case "link": return action.url;
case "phone": return `tel:${action.value}`;
case "email": return `mailto:${action.value}`;
case "whatsapp": return `https://wa.me/${action.value.replace(/\+/g, "")}`;
default: return "#";
isExternal is action.type === "link", and drives target="_blank" +
rel="noopener noreferrer".
WhatsAppButton
The floating chat button is the shared
components/WhatsAppButton.tsx, rendered
whenever config.whatsapp is set. It builds its URL through waMeUrl() from
lib/phone.ts, which normalises the number once for every
caller.
ShowcasePage
interface ShowcasePageProps {
link: ShowcasePageLink;
}
Full-width campaign page. The most structurally complex component: two stacked sticky bars above a responsive card grid.
Shares ICON_PATHS and WhatsAppButton with LinksPage; it passes
"instagram" as its getIconPath fallback, where the tile grid passes
"globe". Only five of the fourteen icons are meaningful here — the social
platforms instagram, linkedin, twitter, facebook and snapchat.
Structural details:
- Header
sticky top-0 z-20, innermax-w-7xl mx-auto, logos in ajustify-betweenflex — main logo left,secondaryLogoright when present. - Social bar
sticky top-[72px] md:top-[88px] z-10. These offsets are hardcoded to the header's rendered height. Changing header padding or logo size without updating them causes the bars to overlap. - Card grid:
grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6. - Cards are
flex flex-col; the features<ul>takesgrowand the button takesmt-auto, so buttons align across a row regardless of description length. - Feature checkmarks are a 20×20
viewBox="0 0 20 20"inline SVG tinted withstyle={{ color: config.primaryColor }}— note this differs from the 24×24 viewBox used everywhere else. - Footer is
bg-slate-900— dark, unlike the light footer onLinksPage.
This is the only component that uses JSX comments ({/* … */}) to label
sections.
ErrorDisplay
interface ErrorDisplayProps {
title: string;
message: string;
code?: string | number;
}
Default export. Used in three places: _404.tsx, _500.tsx, and the not-found
branch of /o/[uuid].
The embedded Lottie animation
Roughly 200 lines of the file are errorAnimation, an inline Lottie JSON object
— a red circle that scales in, then a rotating X mark. It is serialised into a
data URI at render time:
<lottie-player
src={`data:application/json;base64,${btoa(JSON.stringify(errorAnimation))}`}
background="transparent"
speed="1"
style="width: 192px; height: 192px; margin: 0 auto;"
loop
autoplay
/>;
Three things make this work:
lottie-playeris declared as a JSX intrinsic element intypes.d.ts.- It is listed in
jsxPrecompileSkipElementsindeno.json, so the JSX precompiler does not try to optimise the unknown tag. - The web component itself is loaded from unpkg in
routes/_app.tsx.
btoa + JSON.stringify run on every error render. Cheap, but avoidable —
the string is constant and could be hoisted to a module-level const.
One link, no button
The card offers a single "Go Home" <a href="/">.
There used to be a "Go Back" button beside it with an
onClick={() => globalThis.history.back()} handler. In a server-only component
with an empty islands/, that handler was never shipped to the browser: the
button rendered, styled, hovered — and did nothing. It has been removed rather
than converted into the project's first island, which would have meant shipping
a hydration bundle on error pages to duplicate the browser's own back button.
It remains the clearest illustration of why the no-islands rule matters: nothing failed loudly.
QRCodeSVG
Not a component. A plain function returning a string:
export const QRCodeSVG = ({ url, size = 200 }: QRCodeProps): string => {
const qr = new QRCodeSVGLib({
content: url,
width: size,
height: size,
padding: 1,
color: "#1a1a1a",
background: "#ffffff",
ecl: "M",
});
return qr.svg();
};
Called from the route, not from JSX, and its result is passed to BusinessCard
as a prop. The default size is 200 but the only caller passes 160.
The import goes through the imports map in deno.json, with a @ts-types
directive pointing at lib/qrcode-svg.d.ts — the
package ships no declarations and has no @types counterpart, so without that
directive deno check cannot resolve the module.
LinkPageView
Given a validated link, returns its <Head> — title, description and
<SocialMeta> — and the component for its type. For alpha it first
re-validates the node tree through the registry, falling back to
LinkUnavailable if the registry no longer has a node the stored document
names.
It exists so that routes/o/[uuid].tsx and the
builder's /builder/preview render through one code path. A preview that
re-implements the dispatch is a preview that will eventually disagree with the
page it claims to show, and the disagreement would only surface after someone
published. Loading, status codes and the mikro branch stay in the route,
because they are about answering a request rather than rendering a link.
LinkUnavailable is exported separately because two callers need exactly that
markup: a record that fails parseLink at load, and one that passes but whose
document the registry then rejects.
components/builder/
The builder's form controls — Field.tsx (labelled inputs), ImageField.tsx
(upload or URL, with a thumbnail), Repeater.tsx (any list, with
add/remove/reorder), Shell.tsx (the frame), the per-type data forms, and
custom/ for the block-tree editor. Every one is presentational and
stateless: a value in, a change out, with all state in
islands/builder/Builder.tsx. A form that owns no state cannot hold a value
that disagrees with the record being built.
They are the one place in components/ where onClick is expected to work,
because they are only ever rendered inside that island. They also import nothing
from the rest of the app — that boundary is enforced by
tests/builder_boundary_test.ts. Full design: BUILDER.md.
Shell.tsx carries a warning worth repeating: the min-h-0 classes on its flex
children are load-bearing. Without them an overflow-y-auto pane never becomes
a scroll container and the editor is clipped at the viewport, which is how the
first version shipped.
Shared conventions
Patterns every component follows. Keep to them.
Icons are inline SVG, never a library. Each icon is a 24×24 viewBox with a
single hardcoded <path>, either as a tiny local component (BusinessCard) or
as an entry in a getIconPath map (LinksPage, ShowcasePage). This keeps the
pages dependency-free and zero-request, at the cost of long path strings in
source and the duplication noted above.
Theming is inline style, not Tailwind classes. config.primaryColor
arrives at runtime, so Tailwind cannot generate a class for it:
<div style={{ backgroundColor: config.primaryColor }}>
See STYLING.md.
The mobile card shell. BusinessCard and LinksPage share this outer
structure:
<div class="min-h-screen bg-linear-to-br from-slate-50 to-slate-100 md:flex md:items-center md:justify-center md:p-4">
<div class="w-full md:max-w-md bg-white md:rounded-2xl md:shadow-xl overflow-hidden flex flex-col min-h-screen md:min-h-0">
Full-bleed on phones, a centred floating sheet from md up. ShowcasePage
deliberately breaks this pattern with max-w-7xl.
Optional config is guarded with &&. {config.footer && (…)},
{config.whatsapp && (…)},
{config.socialLinks && config.socialLinks.length > 0 && (…)}.
Keys on mapped lists use `${block.title}-${index}` rather than the
bare index.
class, not className. Preact accepts both; this codebase uses class
throughout.
Test coverage
Every page component here is rendered to HTML and snapshotted in
tests/render_test.ts, against synthetic fixtures
chosen to exercise the optional-field branches no live link reaches. A changed
snapshot is a rendering change.
components/builder/ has no snapshots — it renders only inside the builder
island, and what matters about it is tested through the state helpers in
tests/builder_test.ts and the preview route's
contract in tests/app_test.ts.
Related
- Rendered output per type → LINK-TYPES.md
- Tailwind setup and the precompile skip list → STYLING.md
- The builder's forms and editor → BUILDER.md
- Duplication and the dead button → ROADMAP.md