A payment platform that handles real money needs to look like it handles real money. The early versions of 0fee.dev's dashboard were functional -- they displayed data, accepted input, and processed payments. But they used native HTML selects, emoji icons, unstyled code blocks, and exposed email addresses. Over several sessions, we transformed the dashboard from functional to premium -- the kind of interface that makes developers trust the platform with their payment processing.
This article covers five major polish initiatives: replacing emojis with professional SVG icons, building a custom Select component for 30+ dropdowns, applying premium card styling across 19 pages, protecting email addresses from spam bots, and adding syntax highlighting with line numbers.
From Emojis to Professional SVG Icons (Session 051)
The initial dashboard used emoji characters as icons. The AI features page had a brain emoji for Smart Routing, a shield emoji for Fraud Detection, and a sparkles emoji for Coming Soon. The About page used target, globe, and lightbulb emojis for company values.
Emojis have three problems in a professional application:
- Platform inconsistency. The same emoji looks different on iOS, Android, Windows, and Linux
- Size limitations. Emojis cannot be scaled, colored, or animated like SVGs
- Professional perception. Emojis signal casual communication, not enterprise payment processing
Session 051 replaced every emoji with a custom SVG icon component:
tsx// Before (emoji)
<span class="text-2xl">🧠</span>
<span>Smart Routing</span>
// After (SVG component)
<BrainIcon class="w-8 h-8 text-emerald-400" />
<span>Smart Routing</span>SVG Icon Components
Each icon is a SolidJS component wrapping an inline SVG:
tsxfunction BrainIcon(props: { class?: string }) {
return (
<svg
class={props.class}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 2a4 4 0 0 1 4 4c0 1.1-.9 2-2 2h-4a2 2 0 0 1-2-2 4 4 0 0 1 4-4z" />
<path d="M12 8v14" />
<path d="M8 12h8" />
{/* ... additional paths */}
</svg>
);
}Icons replaced across two pages:
| Page | Emojis Replaced | SVG Icons Added |
|---|---|---|
| AI.tsx | 6 | BrainIcon, ShieldIcon, RefreshIcon, ChartIcon, RobotIcon, SparklesIcon |
| About.tsx | 6 | TargetIcon, GlobeIcon, LightbulbIcon, HandshakeIcon, BoltIcon, UnlockIcon |
The SVG icons inherit the text color via stroke="currentColor", meaning they automatically adapt to dark mode without any additional styling.
Status Page Fix
During the same session, a bug was discovered on the Status page where provider.icon was undefined, causing render failures. The fix used provider.letter (first letter of the provider name) with colored backgrounds instead:
tsx// Before (broken)
<span>{provider.icon}</span>
// After (working)
<div class={`w-10 h-10 rounded-full flex items-center justify-center
text-white font-bold ${getProviderColor(provider.id)}`}>
{provider.letter}
</div>Custom Select Component: 30+ Native Selects Replaced (Session 056)
The HTML <select> element is one of the hardest elements to style consistently across browsers. On Chrome, it looks like a Chrome dropdown. On Safari, it looks like a Safari dropdown. On Firefox, it looks like a Firefox dropdown. None of them match a custom design system.
Session 056 created a custom Select component and replaced all 30 native <select> elements across 16 files.
The Select Component
tsx// frontend/src/components/ui/Select.tsx
interface SelectOption {
value: string;
label: string;
icon?: string;
badge?: string;
description?: string;
color?: string;
}
interface SelectProps {
label?: string;
placeholder?: string;
options: SelectOption[];
value: string | undefined;
onChange: (value: string) => void;
error?: string;
disabled?: boolean;
}
function Select(props: SelectProps) {
const [isOpen, setIsOpen] = createSignal(false);
const [search, setSearch] = createSignal("");
let dropdownRef: HTMLDivElement;
// Close on click outside
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
setIsOpen(false);
}
};
onMount(() => document.addEventListener("click", handleClickOutside));
onCleanup(() => document.removeEventListener("click", handleClickOutside));
// Keyboard navigation
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
if (e.key === "ArrowDown") navigateOptions(1);
if (e.key === "ArrowUp") navigateOptions(-1);
if (e.key === "Enter") selectHighlighted();
};
const selectedOption = () =>
props.options.find(o => o.value === props.value);
return (
<div ref={dropdownRef} class="relative" onKeyDown={handleKeyDown}>
{/* Label */}
<Show when={props.label}>
<label class="block text-sm font-medium text-gray-700
dark:text-gray-300 mb-1">
{props.label}
</label>
</Show>
{/* Trigger */}
<button
type="button"
class="w-full flex items-center justify-between px-3 py-2
bg-white dark:bg-gray-800
border border-gray-300 dark:border-gray-600
rounded-lg text-sm cursor-pointer
hover:border-gray-400 dark:hover:border-gray-500
focus:ring-2 focus:ring-emerald-500"
onClick={() => setIsOpen(!isOpen())}
disabled={props.disabled}
>
<span class="flex items-center gap-2 truncate">
<Show when={selectedOption()?.icon}>
<span>{selectedOption().icon}</span>
</Show>
<Show when={selectedOption()?.color}>
<span
class="w-3 h-3 rounded-full"
style={{ background: selectedOption().color }}
/>
</Show>
<span class={selectedOption()
? "text-gray-900 dark:text-gray-100"
: "text-gray-500 dark:text-gray-400"
}>
{selectedOption()?.label || props.placeholder || "Select..."}
</span>
</span>
<ChevronDownIcon class={`w-4 h-4 text-gray-400 transition-transform
${isOpen() ? "rotate-180" : ""}`} />
</button>
{/* Dropdown */}
<Show when={isOpen()}>
<div class="absolute z-50 w-full mt-1
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-lg shadow-lg
max-h-60 overflow-y-auto
animate-fade-in">
<For each={props.options}>
{(option) => (
<button
type="button"
class={`w-full text-left px-3 py-2.5 text-sm flex items-center gap-2
${option.value === props.value
? "bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
onClick={() => {
props.onChange(option.value);
setIsOpen(false);
}}
>
<Show when={option.icon}>
<span class="text-base">{option.icon}</span>
</Show>
<Show when={option.color}>
<span
class="w-3 h-3 rounded-full shrink-0"
style={{ background: option.color }}
/>
</Show>
<div class="flex-1 min-w-0">
<span class="block truncate">{option.label}</span>
<Show when={option.description}>
<span class="block text-xs text-gray-500 dark:text-gray-500 truncate">
{option.description}
</span>
</Show>
</div>
<Show when={option.badge}>
<span class="ml-auto shrink-0 px-1.5 py-0.5 text-xs rounded
bg-gray-100 dark:bg-gray-700
text-gray-600 dark:text-gray-400">
{option.badge}
</span>
</Show>
</button>
)}
</For>
</div>
</Show>
</div>
);
}Features
| Feature | Implementation |
|---|---|
| Icons | Optional icon per option (flags, status icons) |
| Color badges | Colored dots for status indicators |
| Descriptions | Secondary text below option label |
| Badges | Right-aligned badge text (counts, labels) |
| Keyboard navigation | Arrow keys, Enter, Escape |
| Click outside to close | Document click listener with cleanup |
| Dark mode | 44+ dark utility classes |
| Truncation | Long labels truncated with ellipsis |
Replacement Scope
The custom Select replaced native selects in 16 files:
| File | Selects Replaced | Context |
|---|---|---|
| DeveloperConsole.tsx | 3 | App, API Key, Currency |
| Transactions.tsx | 2 | Status filter, Provider filter |
| Apps.tsx | 5 | Status, Environment, Provider, Roles |
| Customers.tsx | 2 | Country, Sort |
| Settings.tsx | 1 | Timezone |
| Webhooks.tsx | 2 | App, Alert threshold |
| AddFunds.tsx | 2 | Currency, Country |
| PaymentLinks.tsx | 1 | Currency |
| PaymentMethods.tsx | 2 | Operator, Country |
| Invoices.tsx | 1 | Transaction |
| Countries.tsx | 1 | Region |
| FeatureRequests.tsx | 4 | Status, Category, Sort, Priority |
| Contact.tsx | 1 | Subject |
| PaymentLinkPage.tsx | 2 | Country, Currency |
| PaymentMethodsPage.tsx | 1 | Category |
| CountriesCovered.tsx | 1 | Region |
Premium Card Styling (Session 068)
Session 068 applied consistent card styling across all 19 dashboard pages, transforming the visual quality from functional to premium:
The Pattern
tsx// Before: Inconsistent card styling
<div class="bg-white rounded-lg shadow p-4">
// After: Premium card styling
<div class="bg-white dark:bg-gray-900
rounded-xl
border border-gray-300 dark:border-gray-700
shadow-sm p-6">The key changes:
| Property | Before | After |
|---|---|---|
| Border radius | rounded-lg (8px) | rounded-xl (12px) |
| Border | None (shadow only) | border border-gray-300 |
| Shadow | shadow (medium) | shadow-sm (subtle) |
| Padding | p-4 (16px) | p-6 (24px) |
| Background | bg-white only | bg-white dark:bg-gray-900 |
Additional Polish
Session 068 also refined the sidebar and header:
Sidebar: - Menu items received larger icons (lg size) and taller touch targets (py-3.5) - "Dashboard" renamed to "Dash" for compact display - Collapsed width standardized to 72px - Scrollbar changed to overlay style
Header: - Search bar repositioned to center with flex layout - Visual separators added between action button groups - Button heights unified to h-8 - Subtle bottom border and shadow added
App background:
- Changed from bg-gray-50 to bg-gray-100 (light mode)
- Added dark:bg-gray-950 (dark mode)
Email Protection from Spam Bots (Session 052)
Email addresses displayed on the marketing pages (Contact, About, Pricing) were vulnerable to harvesting by spam bots that crawl HTML for mailto: links and @ symbols.
Session 052 created a ProtectedEmail component:
tsxfunction ProtectedEmail(props: { user: string; domain: string; class?: string }) {
const [revealed, setRevealed] = createSignal(false);
const email = () => `${props.user}@${props.domain}`;
return (
<span
class={`cursor-pointer ${props.class}`}
onClick={() => {
setRevealed(true);
window.location.href = `mailto:${email()}`;
}}
>
<Show
when={revealed()}
fallback={<span>{props.user} [at] {props.domain}</span>}
>
{email()}
</Show>
</span>
);
}The strategy:
1. Display user [at] domain in static HTML (bots cannot parse this)
2. Construct the real email via JavaScript on click (bots do not execute JS)
3. Open the mailto link dynamically
Additionally, helper functions in company.ts were updated:
typescriptexport const SUPPORT_EMAIL_PARTS = { user: "0fee", domain: "zerosuite.dev" };
export function getObfuscatedEmail(): string {
return `${SUPPORT_EMAIL_PARTS.user} [at] ${SUPPORT_EMAIL_PARTS.domain}`;
}Syntax Highlighting with Line Numbers (Session 052)
Code blocks on the documentation pages, GetStarted page, and DeveloperConsole were unstyled <pre><code> blocks with monospace text on a gray background. Session 052 created a comprehensive SyntaxHighlighter component:
tsxfunction SyntaxHighlighter(props: {
code: string;
language: string;
showLineNumbers?: boolean;
}) {
const highlighted = () => tokenize(props.code, props.language);
return (
<div class="relative rounded-lg overflow-hidden bg-slate-900">
{/* Copy button */}
<button
class="absolute top-2 right-2 px-2 py-1 text-xs
bg-slate-800 hover:bg-slate-700
text-gray-400 rounded"
onClick={() => navigator.clipboard.writeText(props.code)}
>
Copy
</button>
<div class="flex overflow-x-auto max-h-96">
{/* Line numbers */}
<Show when={props.showLineNumbers !== false}>
<div class="sticky left-0 select-none px-3 py-4
text-right text-xs text-gray-600
bg-slate-900 border-r border-slate-800">
<For each={props.code.split("\n")}>
{(_, i) => <div>{i() + 1}</div>}
</For>
</div>
</Show>
{/* Code content */}
<pre class="flex-1 px-4 py-4 text-sm overflow-x-auto">
<code>
<For each={highlighted()}>
{(token) => (
<span class={getTokenColor(token.type)}>
{token.value}
</span>
)}
</For>
</code>
</pre>
</div>
</div>
);
}Color Scheme
| Token Type | Color | Tailwind Class |
|---|---|---|
| Keywords | Purple | text-purple-400 |
| Strings | Emerald | text-emerald-400 |
| Numbers, booleans, null | Amber | text-amber-400 |
| Function calls | Blue | text-blue-400 |
| Object properties | Cyan | text-cyan-400 |
| Operators | Pink | text-pink-400 |
| Comments | Slate | text-slate-500 |
The tokenizer supports TypeScript, Python, Bash/cURL, and JSON -- the four languages most commonly used in 0fee's documentation and code examples.
The Cumulative Effect
Each polish session built on the previous ones:
| Session | Change | Impact |
|---|---|---|
| 051 | SVG icons replace emojis | Professional appearance |
| 052 | Syntax highlighting + email protection | Developer trust, spam prevention |
| 056 | Custom Select component | Consistent, branded dropdowns |
| 068 | Premium card styling | Premium visual quality |
| 078 | Dark mode across all pages | Developer preference support |
By Session 078, the dashboard had transformed from a functional prototype to a premium-quality interface comparable to Stripe's dashboard, Twilio's console, or Google Cloud's management panel.
What We Learned
UI polish taught us three things:
- Polish is not one big effort -- it is many small ones. No single session transformed the dashboard. Each session addressed one category of visual improvement. The cumulative effect was dramatic, but each individual session was manageable.
- Shared components multiply polish. The custom Select component was built once and deployed to 30+ locations. The SyntaxHighlighter appears in four different contexts. Building reusable, polished components is more efficient than polishing individual pages.
- Professional details build trust in financial products. Developers evaluating a payment platform look at the dashboard quality as a proxy for engineering quality. If the UI is sloppy, they wonder if the payment processing is sloppy too. Polish is not vanity -- it is a trust signal.
This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.