Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 9 additions & 25 deletions app/(site)/(directories)/platform-fees/[section]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
import { notFound } from 'next/navigation';
import { getRequiredDirectoryPage } from '@/lib/directory-pages';
import { getFeeDirectory } from '@/lib/platform-fees/queries';
import { sectionCheckedAt } from '@/lib/platform-fees/model';
import { sectionCheckedAt, type FeeSection } from '@/lib/platform-fees/model';
import { feeGroups, getFeeGroup, groupPath } from '@/lib/platform-fees/groups';
import { sectionPath, sectionSlug } from '@/lib/platform-fees/section-routes';
import { FeeCategoryBody, FeeFilters, FeeMatchCount } from '../filters';
import { FeeShell, FeeSourceList, SectionIcon } from '../components';
import { FeeSources } from '../sources';
import { FeeCard } from '../fee-card';
import styles from '../page.module.css';
import { FeeCategoryBody, FeeFilters, type FeeCategorySection } from '../filters';
import { FeeShell, SectionIcon } from '../components';
import { feeCardView, feeDate, feeSearchText } from '../card-view';
import { feeCategoryContent } from './content';

const dateFormat = new Intl.DateTimeFormat('en-GB', {
day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC',
});

// Build every category during the release. Unpublished categories still return 404.
export function generateStaticParams() {
return feeGroups.map(group => ({ section: group.slug }));
Expand All @@ -32,23 +26,17 @@ export default async function FeeCategoryPage({ params }: { params: Promise<{ se
const sections = directory.sections.filter(section => section.group_name === group.name);
if (!sections.length) notFound();
const path = groupPath(group.name);
const blocks = sections.map(section => {
const blocks: Array<FeeCategorySection & { icon: FeeSection['icon'] }> = sections.map(section => {
const sectionEntries = directory.entries.filter(entry => entry.section_id === section.id);
const checkedAt = sectionCheckedAt(sectionEntries);
const items = sectionEntries.map(entry => ({
id: entry.id,
text: `${entry.name} ${entry.card.tag} ${entry.summary}`.toLocaleLowerCase('en-US'),
card: <FeeCard key={entry.id} entry={entry} />,
}));
return {
anchor: sectionSlug(section.id),
href: sectionPath(section),
name: section.name,
intro: section.intro,
icon: section.icon,
checkedLabel: checkedAt ? <p key={`${section.id}-checked`} className={styles.checked}>Last checked <time dateTime={checkedAt}>{dateFormat.format(new Date(checkedAt))}</time></p> : null,
sources: <FeeSources key={`${section.id}-sources`}><FeeSourceList entries={sectionEntries} section={section} /></FeeSources>,
items,
checked: feeDate(sectionCheckedAt(sectionEntries)),
fields: section.fields.map(field => field.label),
cards: sectionEntries.map(feeCardView),
};
});

Expand All @@ -63,14 +51,10 @@ export default async function FeeCategoryPage({ params }: { params: Promise<{ se
href: block.href,
label: block.name,
icon: <SectionIcon key={block.anchor} icon={block.icon} size={15} />,
text: block.items.map(item => item.text).join('\n'),
text: block.cards.map(feeSearchText).join('\n'),
}))}
>
<FeeFilters action={path} />
<div className={styles.overview}>
<p aria-live="polite"><FeeMatchCount items={blocks.flatMap(block => block.items)} label="platforms" /></p>
<p>US / USD unless labelled</p>
</div>
<FeeCategoryBody path={path} sections={blocks} />
</FeeShell>;
}
70 changes: 70 additions & 0 deletions app/(site)/(directories)/platform-fees/card-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { hiddenRateCount, type FeeEntry, type FeeSource, type ReceiptRate } from '@/lib/platform-fees/model';

// What a fee card and its "Sources and dates" entry render, and nothing else.
// The category page sends this compact data to the browser once. Sending the
// rendered card tree instead made each category page megabytes larger.
export type FeeDate = { iso: string; label: string };
export type FeeRateView = Pick<ReceiptRate, 'value' | 'extra' | 'percent' | 'label' | 'note'>;
export type FeeCardView = {
id: string;
name: string;
/** Searched with the name and tag; not shown on the card. */
summary: string;
url: string;
icon: string | null;
tag: string;
note?: string;
take: FeeRateView;
second?: FeeRateView;
lines: { label: string; value: string }[];
groups: { label: string; rates: FeeRateView[] }[];
/** Label of the link that opens all rates and sources. */
more: string;
checked: FeeDate | null;
sources: FeeSource[];
/** In the order of the section's field labels. `source` indexes `sources`. */
facts: { value: string; source?: number }[];
};

// Formatted on the server so the browser's date data cannot change the text during hydration.
const dateFormat = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
export function feeDate(iso: string | null): FeeDate | null {
return iso ? { iso, label: dateFormat.format(new Date(iso)) } : null;
}

function rate({ value, extra, percent, label, note }: ReceiptRate): FeeRateView {
return { value, percent, label, ...(extra ? { extra } : {}), ...(note ? { note } : {}) };
}

export function feeCardView(entry: FeeEntry): FeeCardView {
const receipt = entry.card;
const hidden = hiddenRateCount(receipt);
const sources = entry.sources.length
? entry.sources.filter((source, index, all) => all.findIndex(item => item.url === source.url) === index).map(({ url, label }) => ({ url, label }))
: [{ url: entry.website_url, label: 'Official website' }];
return {
id: entry.id,
name: entry.name,
summary: entry.summary,
url: entry.website_url,
icon: entry.icon_path,
tag: receipt.tag,
...(receipt.note ? { note: receipt.note } : {}),
take: rate(receipt.take),
...(receipt.second ? { second: rate(receipt.second) } : {}),
lines: receipt.lines.map(({ label, value }) => ({ label, value })),
groups: receipt.groups.map(group => ({ label: group.label, rates: group.rates.map(rate) })),
more: hidden > 0 ? `+ ${hidden} more rate${hidden > 1 ? 's' : ''}` : receipt.groups.length ? 'All rates and sources' : 'Sources',
checked: feeDate(entry.checked_at),
sources,
facts: entry.facts.map(fact => {
// Validation guarantees every fact source is one of the entry's sources.
const source = fact.source_url ? sources.findIndex(item => item.url === fact.source_url) : -1;
return source >= 0 ? { value: fact.value, source } : { value: fact.value };
}),
};
}

export function feeSearchText(card: Pick<FeeCardView, 'name' | 'tag' | 'summary'>): string {
return `${card.name} ${card.tag} ${card.summary}`.toLocaleLowerCase('en-US');
}
27 changes: 2 additions & 25 deletions app/(site)/(directories)/platform-fees/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { PageMeta } from "@/app/ui/site/page-meta";
import Link from 'next/link';
import type { ReactNode } from 'react';
import {
CreditCard, ShoppingCart, Files, Cpu, ExternalLink, Smartphone,
CreditCard, ShoppingCart, Files, Cpu, Smartphone,
Monitor, Watch, Gamepad2, Headset, Puzzle, Cloud, Database, Download,
Palette, Type, LayoutTemplate, Code, Box, Camera, Video, Music, BookOpen,
GraduationCap, Calendar, Users, Heart, Mail, Mic, Coffee, Briefcase, Shirt,
Expand All @@ -21,7 +21,7 @@ import { SidebarList } from '@/app/ui/site/sidebar-list';
import { sidebarPanelClassName, sidebarPanelHeadingClassName } from '@/app/ui/site/sidebar-panel';
import { feeGroups, groupPath, type FeeGroup } from '@/lib/platform-fees/groups';
import { getBreadcrumbs } from '@/lib/breadcrumbs';
import type { FeeEntry, FeeSection } from '@/lib/platform-fees/model';
import type { FeeSection } from '@/lib/platform-fees/model';
import { FeeSearch, FeeSidebarAnchors, type FeeAnchor } from './filters';
import styles from './page.module.css';

Expand All @@ -31,9 +31,6 @@ const icons = {
Camera, Video, Music, BookOpen, GraduationCap, Calendar, Files, Users,
Heart, Mail, Mic, Coffee, Briefcase, Cpu, Shirt,
};
const dateFormat = new Intl.DateTimeFormat('en-GB', {
day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC',
});

export function SectionIcon({ icon, size = 22 }: { icon: FeeSection['icon']; size?: number }) {
const Icon = icons[icon];
Expand Down Expand Up @@ -92,23 +89,3 @@ export function CategoryTile({ group, sections, listings }: { group: FeeGroup; s
<span className={styles.arrow} aria-hidden="true">→</span>
</Link>;
}

export function FeeSourceList({ entries, section }: { entries: FeeEntry[]; section: FeeSection }) {
return <div className={styles.sourceList}>{entries.map(entry => {
const sources = entry.sources.length
? entry.sources.filter((source, index, all) => all.findIndex(item => item.url === source.url) === index)
: [{ url: entry.website_url, label: 'Official website' }];
return <section key={entry.id} className={styles.sourceEntry}>
<h3>{entry.name}</h3>
<p>{entry.checked_at ? <>Checked <time dateTime={entry.checked_at}>{dateFormat.format(new Date(entry.checked_at))}</time></> : 'Not checked'}</p>
<dl className={styles.fullFacts}>{entry.facts.map(fact => <div key={fact.key}>
<dt>{section.fields.find(field => field.key === fact.key)?.label}</dt>
<dd>{fact.value}{fact.source_url && <> <a href={fact.source_url} target="_blank" rel="noopener noreferrer">Source<ExternalLink size={12} aria-hidden="true" /></a></>}</dd>
</div>)}</dl>
<ul>{sources.map(source => <li key={source.url}>
<a href={source.url} target="_blank" rel="noopener noreferrer">{source.label}<ExternalLink size={14} aria-hidden="true" /></a>
<span>{new URL(source.url).hostname.replace(/^www\./, '')}</span>
</li>)}</ul>
</section>;
})}</div>;
}
52 changes: 33 additions & 19 deletions app/(site)/(directories)/platform-fees/fee-card.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,30 @@
.card > .top { padding: 0 var(--space-9) var(--space-9); }

.identity { display: flex; gap: var(--space-5); align-items: center; min-width: 0; }
.identityText { display: grid; gap: 2px; min-width: 0; }
.identity > div { display: grid; gap: 2px; min-width: 0; }
.identity h3 { margin: 0; font-family: var(--font-display); font-size: var(--text-h3); line-height: var(--line-heading); letter-spacing: var(--tracking-display); }
.identity h3 a { display: inline-flex; gap: var(--space-3); align-items: center; color: var(--color-text); }
.identity h3 a svg { flex: none; color: var(--color-text-muted); }
.identity h3 a[target="_blank"]::after { width: 14px; height: 14px; background-color: var(--color-text-muted); }
.identity p { margin: 0; color: var(--color-text-muted); font-size: var(--text-small); line-height: var(--line-ui); }
.logo { flex: none; display: grid; place-items: center; width: 40px; height: 40px; overflow: hidden; background: var(--color-surface-2); border-radius: var(--radius); color: var(--color-text-muted); font-family: var(--font-display); font-weight: var(--weight-bold); }
.logo img { width: 30px; height: 30px; object-fit: contain; }
.identity > span { flex: none; display: grid; place-items: center; width: 40px; height: 40px; overflow: hidden; background: var(--color-surface-2); border-radius: var(--radius); color: var(--color-text-muted); font-family: var(--font-display); font-weight: var(--weight-bold); }
.identity > span img { width: 30px; height: 30px; object-fit: contain; }

.top { display: grid; align-content: start; gap: var(--space-8); }
.rates { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--space-5) var(--space-10); }
.rates > div { display: grid; gap: var(--space-2); min-width: 0; max-width: 100%; }
.rates strong { font-family: var(--font-display); font-weight: var(--weight-bold); line-height: 1; letter-spacing: -0.03em; }
.take strong { font-size: 2.6rem; }
.take strong.reason { font-size: var(--text-h2); line-height: 1.1; overflow-wrap: anywhere; }
.second strong { font-size: 1.45rem; line-height: 1.15; overflow-wrap: anywhere; color: var(--color-text-muted); }
.rates > :first-child strong { font-size: 2.6rem; }
.rates > :first-child strong.reason { font-size: var(--text-h2); line-height: 1.1; overflow-wrap: anywhere; }
.rates > :nth-child(2) strong { font-size: 1.45rem; line-height: 1.15; overflow-wrap: anywhere; color: var(--color-text-muted); }
.rates strong small { font-size: 0.45em; letter-spacing: 0; color: var(--color-text-muted); }
.rates span { color: var(--color-text-muted); font-size: var(--text-small); line-height: var(--line-ui); }

.split { display: grid; gap: var(--space-2); }
.splitBar { display: flex; height: 8px; overflow: hidden; background: var(--color-surface-2); border-radius: var(--radius); }
.keep { background: var(--color-data-chartreuse); }
.cut { background: var(--color-border-strong); }
.splitLegend { margin: 0; color: var(--color-text-muted); font-family: var(--font-mono); font-size: var(--text-small); }
.splitLegend b { color: var(--color-text); font-weight: var(--weight-regular); }
.split > div { display: flex; height: 8px; overflow: hidden; background: var(--color-surface-2); border-radius: var(--radius); }
.split > div > :first-child { background: var(--color-data-chartreuse); }
.split > div > :last-child { background: var(--color-border-strong); }
.split > p { margin: 0; color: var(--color-text-muted); font-family: var(--font-mono); font-size: var(--text-small); }
.split > p b { color: var(--color-text); font-weight: var(--weight-regular); }

/* Torn receipt edge: dashed line with a half circle cut into each side. */
.tear { position: relative; height: 0; margin: 0 var(--space-9); border-top: 2px dashed var(--color-border-strong); }
Expand All @@ -65,22 +65,36 @@
.rateTable { width: 100%; border-collapse: collapse; font-size: var(--text-small); }
.rateTable caption { margin-bottom: var(--space-2); text-align: left; font-family: var(--font-display); font-size: var(--text-h3); font-weight: var(--weight-bold); }
.rateTable thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.rateGroup th { padding: var(--space-6) 0 var(--space-2); color: var(--color-text-muted); font-weight: var(--weight-semibold); text-align: left; }
.rateTable th[scope=rowgroup] { padding: var(--space-6) 0 var(--space-2); color: var(--color-text-muted); font-weight: var(--weight-semibold); text-align: left; }
.rateTable td, .rateTable th[scope=row] { padding: var(--space-3) 0; border-bottom: 1px dotted var(--color-border-strong); vertical-align: top; }
.rateTable th[scope=row] { font-weight: var(--weight-regular); line-height: 1.35; text-align: left; }
.rateValue { width: 5rem; padding-right: var(--space-4) !important; font-family: var(--font-display); font-size: var(--text-body); font-weight: var(--weight-bold); line-height: 1.25; white-space: nowrap; }
.reasonValue { white-space: normal; overflow-wrap: anywhere; }
.rateExtra { display: block; color: var(--color-text-muted); font-family: var(--font-sans); font-size: var(--text-small); font-weight: var(--weight-regular); }
.rateNote { display: block; margin-top: 2px; color: var(--color-text-muted); font-family: var(--font-mono); font-size: var(--text-small); }
.note { margin: 0; font-size: var(--text-small); }
.rateTable td { width: 5rem; padding-right: var(--space-4); font-family: var(--font-display); font-size: var(--text-body); font-weight: var(--weight-bold); line-height: 1.25; white-space: nowrap; }
.rateTable td.reasonValue { white-space: normal; overflow-wrap: anywhere; }
.rateTable td span { display: block; color: var(--color-text-muted); font-family: var(--font-sans); font-size: var(--text-small); font-weight: var(--weight-regular); }
.rateTable th span { display: block; margin-top: 2px; color: var(--color-text-muted); font-family: var(--font-mono); font-size: var(--text-small); }
.allRates > p { margin: 0; font-size: var(--text-small); }
.sources { display: grid; gap: var(--space-2); padding-top: var(--space-6); border-top: 1px dashed var(--color-border); color: var(--color-text-muted); font-size: var(--text-small); }
.sources p { margin: 0; font-family: var(--font-mono); }
.sources .sourcesLabel { margin: var(--space-3) 0 0; font-family: var(--font-sans); font-size: var(--text-small); font-weight: var(--weight-semibold); }
.sources > p + p { margin: var(--space-3) 0 0; font-family: var(--font-sans); font-size: var(--text-small); font-weight: var(--weight-semibold); }
.sources ul { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-6); margin: 0; padding: 0; list-style: none; }
.sources a { display: inline-flex; gap: var(--space-1); align-items: center; }

/* One CSS icon marks every external link. An inline SVG per link made the fee pages megabytes larger. */
.card a[target="_blank"]::after,
.panel a[target="_blank"]::after,
.links a[target="_blank"]::after {
content: "";
flex: none;
width: 12px;
height: 12px;
background-color: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h6v6'/%3E%3Cpath d='M10 14 21 3'/%3E%3Cpath d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h6v6'/%3E%3Cpath d='M10 14 21 3'/%3E%3Cpath d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'/%3E%3C/svg%3E") center / contain no-repeat;
}

/* Side panel content. The panel itself comes from the shared DetailDialog. */
.panel { display: grid; gap: var(--space-9); }
.panelHeading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-5); }
.panelHeading .identity h3 { font-size: var(--text-h2); }
.panel .lines { padding-top: var(--space-7); border-top: 2px dashed var(--color-border-strong); }
.contents { display: contents; }
Loading
Loading