feat: enhance server list and detail views with payment information
- Removed unused state for relay discovery message in ServerListView. - Added payment app count and display in ServerListView. - Updated ServerListView to include paymentEnabled and paymentLabel in server props. - Improved styling consistency across various components including ThemeToggleButton, TunnelCommandForm, and TunnelCommandModal. - Introduced payment resolution logic in metadata handling to determine payment status and labels. - Updated server detail view to conditionally display payment badges for paid apps. - Adjusted CSS variables for improved design consistency and visual hierarchy.
rabbitprincess committed
Jun 7, 2026 at 08:48 UTC
ff4cc560d00e27ca033a87c7b3db316f0d60411f
15 files changed
+284
-557
frontend/src/components/FloatingActionBar.tsx
+2
-2
@@ -58,10 +58,10 @@ export const FloatingActionBar = ({
58
59
return (
60
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 fade-in duration-200">
61
- <div className="flex items-center gap-2 px-3 py-2 bg-background rounded-xl shadow-2xl border border-foreground/20">
61
+ <div className="flex items-center gap-2 px-3 py-2 bg-background rounded-lg shadow-lg border border-foreground/20">
62
<button
63
onClick={onSelectAll}
64
- className={`px-3 h-10 text-sm font-medium rounded-lg transition-colors whitespace-nowrap ${
64
+ className={`px-3 h-10 text-sm font-medium rounded-md transition-colors whitespace-nowrap ${
65
isAllSelected
66
? "bg-primary text-primary-foreground"
67
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
frontend/src/components/Header.tsx
+7
-7
@@ -106,16 +106,16 @@ export function Header({
106
</div>
107
108
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
109
- <h2 className="min-w-0 wrap-break-word text-xl leading-none font-extrabold tracking-tight text-foreground sm:text-2xl">
109
+ <h2 className="min-w-0 wrap-break-word text-xl leading-none font-extrabold tracking-normal text-foreground sm:text-2xl">
110
{title}
111
</h2>
112
{releaseVersion && (
113
- <span className="inline-flex h-6 items-center rounded-full bg-secondary px-2.5 text-xs font-semibold text-text-muted">
113
+ <span className="inline-flex h-6 items-center rounded-md bg-secondary px-2.5 text-xs font-semibold text-text-muted">
114
{releaseVersion}
115
</span>
116
)}
117
{ensVerified && (
118
- <span className="inline-flex h-6 items-center rounded-full bg-primary/12 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/20">
118
+ <span className="inline-flex h-6 items-center rounded-md bg-primary/12 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/20">
119
ENS verified
120
</span>
121
)}
@@ -154,7 +154,7 @@ export function Header({
154
href={repoURL}
155
target="_blank"
156
rel="noopener noreferrer"
157
- className="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-border/70 bg-background/90 text-foreground shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/40 hover:text-primary"
157
+ className="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-md border border-border/70 bg-background/90 text-foreground shadow-sm transition-colors hover:border-primary/40 hover:text-primary"
158
aria-label="View source on GitHub"
159
>
160
<svg
@@ -193,14 +193,14 @@ export function Header({
193
onChange={(event) => setAdminToken(event.target.value)}
194
placeholder="Admin token"
195
autoComplete="current-password"
196
- className="h-12 w-44 rounded-full border border-border/70 bg-background/90 px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/50 sm:w-56"
196
+ className="h-12 w-44 rounded-md border border-border/70 bg-background/90 px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/50 sm:w-56"
197
disabled={isLoading}
198
/>
199
<Button
200
type="submit"
201
variant="outline"
202
disabled={isLoading}
203
- className="h-12 rounded-full border-border/70 bg-background/90 px-4 text-foreground shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/40 hover:text-primary disabled:cursor-not-allowed"
203
+ className="h-12 rounded-md border-border/70 bg-background/90 px-4 text-foreground shadow-sm transition-colors hover:border-primary/40 hover:text-primary disabled:cursor-not-allowed"
204
>
205
{isLoading ? (
206
<Loader2 className="h-5 w-5 animate-spin" />
@@ -218,7 +218,7 @@ export function Header({
218
variant="outline"
219
size="icon"
220
onClick={handleLogout}
221
- className="h-12 w-12 cursor-pointer rounded-full border-border/70 bg-background/90 text-foreground shadow-sm transition-all hover:-translate-y-0.5 hover:border-destructive/40 hover:bg-background hover:text-destructive"
221
+ className="h-12 w-12 cursor-pointer rounded-md border-border/70 bg-background/90 text-foreground shadow-sm transition-colors hover:border-destructive/40 hover:bg-background hover:text-destructive"
222
aria-label="Logout"
223
>
224
<LogOut className="h-5 w-5" />
frontend/src/components/LandingHero.tsx
+70
-410
@@ -1,304 +1,53 @@
1
-import {
2
- startTransition,
3
- useEffect,
4
- useMemo,
5
- useRef,
6
- useState,
7
- type PointerEvent as ReactPointerEvent,
8
-} from "react";
1
import { TunnelCommandForm } from "@/components/TunnelCommandForm";
2
11
-const heroDifferentiatorCards = [
3
+const coreFeatures = [
4
{
13
- key: "login",
14
- title: "No Login",
15
- description: "Run the command immediately without accounts or auth flows.",
16
- },
17
- {
18
- key: "billing",
19
- title: "No Billing",
20
- description: "No credit card, no plan gate, and no billing step before go-live.",
21
- },
22
- {
23
- key: "cloud",
24
- title: "No Cloud SaaS",
25
- description: "No dashboard, region picker, or managed cloud setup to get started.",
26
- },
27
- {
28
- key: "permissionless",
29
- title: "Permissionless",
30
- description: "Use the public registry or attach your own relay. No approval required.",
31
- },
32
-] as const;
33
-
34
-const heroFeatures = [
35
- {
36
- eyebrow: "HTTPS",
5
+ eyebrow: "Ingress",
6
title: "Public HTTPS for localhost",
7
description:
39
- "Publish local apps through TCP passthrough without opening inbound ports.",
8
+ "Publish local services through public relays without opening inbound ports.",
9
},
10
{
11
eyebrow: "TLS",
43
- title: "End-to-end TLS on your side",
12
+ title: "Keyless end-to-end tenant TLS",
13
description:
45
- "Tenant TLS terminates locally with MITM detection, so relays cannot access plaintext.",
14
+ "Relays sign handshakes without session keys; ECH hides SNI and self-probes flag suspected MITM.",
15
},
16
{
48
- eyebrow: "Setup",
49
- title: "One-command setup",
17
+ eyebrow: "Relays",
18
+ title: "Self-hosted anonymous relays",
19
description:
51
- "Start relays and tunnels with minimal setup and a short copy-paste path.",
20
+ "Use discovered public relays or run your own without a central account or operator.",
21
},
22
{
54
- eyebrow: "Relay",
55
- title: "Self-hosted relays and pools",
23
+ eyebrow: "Multi-hop",
24
+ title: "Multihop Routing (like Tor)",
25
description:
57
- "Connect to public relays, use discovered relays as a pool with failover, or run your own.",
26
+ "Route through multiple relays so each hop sees only its neighbors, not the full path.",
27
},
28
{
60
- eyebrow: "Transport",
61
- title: "Raw TCP with optional UDP",
29
+ eyebrow: "Payments",
30
+ title: "x402 for the agentic web",
31
description:
63
- "Carry web traffic and arbitrary protocols without SSH or WebSocket overlays.",
32
+ "Agents and browsers pay with Sui USDC in-flow; the tunnel enforces access to protected routes.",
33
},
34
{
66
- eyebrow: "Identity",
67
- title: "SIWE ownership with ENS support",
35
+ eyebrow: "Transport",
36
+ title: "Web traffic and raw protocols",
37
description:
69
- "Authenticate ownership with SIWE and keep identity portable with ENS-based naming support.",
38
+ "Carry HTTPS, raw TCP, and UDP workloads without SSH or WebSocket overlays.",
39
},
40
] as const;
41
42
export function LandingHero() {
74
- const carouselCardCount = heroDifferentiatorCards.length;
75
- const carouselLoopBoundaryIndex = carouselCardCount + 1;
76
- const carouselTransitionDurationMs = 700;
77
- const [reduceMotion, setReduceMotion] = useState(false);
78
- const [isDragging, setIsDragging] = useState(false);
79
- const [trackIndex, setTrackIndex] = useState(1);
80
- const [transitionEnabled, setTransitionEnabled] = useState(true);
81
- const [slideSize, setSlideSize] = useState(328);
82
- const [dragOffset, setDragOffset] = useState(0);
83
- const dragStartXRef = useRef<number | null>(null);
84
- const dragOffsetRef = useRef(0);
85
- const pointerIdRef = useRef<number | null>(null);
86
-
87
- const slideGap = 16;
88
- const carouselSlides = useMemo(
89
- () => [
90
- heroDifferentiatorCards[carouselCardCount - 1],
91
- ...heroDifferentiatorCards,
92
- heroDifferentiatorCards[0],
93
- ],
94
- [carouselCardCount]
95
- );
96
- const renderedTrackIndex = Math.min(
97
- Math.max(trackIndex, 0),
98
- carouselSlides.length - 1
99
- );
100
- const trackTranslateX = `calc(50% - ${slideSize / 2}px - ${
101
- renderedTrackIndex * (slideSize + slideGap)
102
- }px ${dragOffset >= 0 ? "+" : "-"} ${Math.abs(dragOffset)}px)`;
103
-
104
- useEffect(() => {
105
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
106
- return;
107
- }
108
-
109
- const media = window.matchMedia("(prefers-reduced-motion: reduce)");
110
- const syncReduceMotion = () => {
111
- setReduceMotion(media.matches);
112
- };
113
-
114
- syncReduceMotion();
115
-
116
- if (typeof media.addEventListener === "function") {
117
- media.addEventListener("change", syncReduceMotion);
118
- return () => media.removeEventListener("change", syncReduceMotion);
119
- }
120
-
121
- media.addListener(syncReduceMotion);
122
- return () => media.removeListener(syncReduceMotion);
123
- }, []);
124
-
125
- useEffect(() => {
126
- if (typeof window === "undefined") {
127
- return;
128
- }
129
-
130
- const updateSlideSize = () => {
131
- if (window.innerWidth >= 1024) {
132
- setSlideSize(560);
133
- return;
134
- }
135
-
136
- if (window.innerWidth >= 640) {
137
- setSlideSize(472);
138
- return;
139
- }
140
-
141
- const maxMobileWidth = Math.min(window.innerWidth - 48, 368);
142
- setSlideSize(Math.max(maxMobileWidth, 288));
143
- };
144
-
145
- updateSlideSize();
146
- window.addEventListener("resize", updateSlideSize);
147
-
148
- return () => {
149
- window.removeEventListener("resize", updateSlideSize);
150
- };
151
- }, []);
152
-
153
- useEffect(() => {
154
- if (reduceMotion || isDragging) {
155
- return;
156
- }
157
-
158
- const interval = window.setInterval(() => {
159
- startTransition(() => {
160
- setTransitionEnabled(true);
161
- setTrackIndex((current) =>
162
- current >= carouselLoopBoundaryIndex
163
- ? carouselLoopBoundaryIndex
164
- : current + 1
165
- );
166
- });
167
- }, 2200);
168
-
169
- return () => {
170
- window.clearInterval(interval);
171
- };
172
- }, [carouselLoopBoundaryIndex, isDragging, reduceMotion]);
173
-
174
- useEffect(() => {
175
- if (transitionEnabled) {
176
- return;
177
- }
178
-
179
- if (typeof window === "undefined") {
180
- return;
181
- }
182
-
183
- const frame = window.requestAnimationFrame(() => {
184
- window.requestAnimationFrame(() => {
185
- setTransitionEnabled(true);
186
- });
187
- });
188
-
189
- return () => {
190
- window.cancelAnimationFrame(frame);
191
- };
192
- }, [transitionEnabled]);
193
-
194
- useEffect(() => {
195
- if (isDragging) {
196
- return;
197
- }
198
-
199
- if (trackIndex !== 0 && trackIndex !== carouselLoopBoundaryIndex) {
200
- return;
201
- }
202
-
203
- const timer = window.setTimeout(() => {
204
- setTransitionEnabled(false);
205
- setTrackIndex(trackIndex === 0 ? carouselCardCount : 1);
206
- }, carouselTransitionDurationMs);
207
-
208
- return () => {
209
- window.clearTimeout(timer);
210
- };
211
- }, [
212
- carouselCardCount,
213
- carouselLoopBoundaryIndex,
214
- carouselTransitionDurationMs,
215
- isDragging,
216
- trackIndex,
217
- ]);
218
-
219
- const finishDrag = (shouldAdvance: boolean, direction: "next" | "prev" | null) => {
220
- dragStartXRef.current = null;
221
- dragOffsetRef.current = 0;
222
- pointerIdRef.current = null;
223
- setIsDragging(false);
224
- setTransitionEnabled(true);
225
- setDragOffset(0);
226
-
227
- if (!shouldAdvance || !direction) {
228
- return;
229
- }
230
-
231
- setTrackIndex((current) => {
232
- if (direction === "next") {
233
- return current >= carouselLoopBoundaryIndex
234
- ? carouselLoopBoundaryIndex
235
- : current + 1;
236
- }
237
-
238
- return current <= 0 ? 0 : current - 1;
239
- });
240
- };
241
-
242
- const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
243
- if (event.pointerType === "mouse" && event.button !== 0) {
244
- return;
245
- }
246
-
247
- dragStartXRef.current = event.clientX;
248
- dragOffsetRef.current = 0;
249
- pointerIdRef.current = event.pointerId;
250
- setIsDragging(true);
251
- setTransitionEnabled(false);
252
- setDragOffset(0);
253
- event.currentTarget.setPointerCapture(event.pointerId);
254
- };
255
-
256
- const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
257
- if (
258
- !isDragging ||
259
- dragStartXRef.current === null ||
260
- pointerIdRef.current !== event.pointerId
261
- ) {
262
- return;
263
- }
264
-
265
- const nextOffset = event.clientX - dragStartXRef.current;
266
- dragOffsetRef.current = nextOffset;
267
- setDragOffset(nextOffset);
268
- };
269
-
270
- const handlePointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
271
- if (!isDragging || pointerIdRef.current !== event.pointerId) {
272
- return;
273
- }
274
-
275
- if (event.currentTarget.hasPointerCapture(event.pointerId)) {
276
- event.currentTarget.releasePointerCapture(event.pointerId);
277
- }
278
-
279
- const threshold = Math.min(88, slideSize * 0.16);
280
- const shouldAdvance = Math.abs(dragOffsetRef.current) > threshold;
281
- const direction =
282
- dragOffsetRef.current < 0
283
- ? "next"
284
- : dragOffsetRef.current > 0
285
- ? "prev"
286
- : null;
287
-
288
- finishDrag(shouldAdvance, direction);
289
- };
290
-
43
return (
44
<section
45
aria-labelledby="landing-title"
294
- className="relative pt-10 sm:pt-12 lg:pt-16"
46
+ className="relative pt-10 sm:pt-12 lg:pt-14"
47
>
296
- <div
297
- aria-hidden="true"
298
- className="pointer-events-none absolute inset-0"
299
- >
48
+ <div aria-hidden="true" className="pointer-events-none absolute inset-0">
49
<div
301
- className="absolute inset-0 opacity-45 bg-size-[14px_14px] mask-[linear-gradient(to_bottom,white,transparent_82%)]"
50
+ className="absolute inset-0 opacity-45 bg-size-[14px_14px] mask-[linear-gradient(to_bottom,white,transparent_80%)]"
51
style={{
52
backgroundImage:
53
"radial-gradient(var(--hero-grid-dot) 0.8px, transparent 0.8px)",
@@ -308,130 +57,81 @@ export function LandingHero() {
57
58
<a
59
href="#live-servers"
311
- className="sr-only focus:not-sr-only focus:absolute focus:left-6 focus:top-6 focus:z-20 focus:rounded-full focus:bg-background focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground"
60
+ className="sr-only focus:not-sr-only focus:absolute focus:left-6 focus:top-6 focus:z-20 focus:rounded-md focus:bg-background focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground"
61
>
62
Skip to live servers
63
</a>
64
316
- <div className="relative mx-auto max-w-4xl px-2 text-center sm:px-4">
317
- <h1
318
- id="landing-title"
319
- className="text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl lg:text-7xl"
320
- style={{ lineHeight: 0.96 }}
321
- >
322
- <span className="block">Expose Local Apps</span>
323
- <span
324
- className="mt-2 block bg-clip-text text-transparent"
65
+ <div className="relative mx-auto max-w-6xl">
66
+ <div className="mx-auto max-w-4xl text-center">
67
+ <h1
68
+ id="landing-title"
69
+ className="text-4xl font-extrabold tracking-normal text-foreground sm:text-5xl lg:text-7xl"
70
+ style={{ lineHeight: 0.96 }}
71
+ >
72
+ <span className="block">Expose Local Apps</span>
73
+ <span className="mt-2 block text-primary">
74
+ To The Public Internet
75
+ </span>
76
+ </h1>
77
+ <p className="mx-auto mt-5 max-w-2xl text-base leading-7 text-text-muted sm:text-lg">
78
+ Use relays as the public edge while your tunnel remains the app
79
+ endpoint and owns routing behavior.
80
+ </p>
81
+ </div>
82
+
83
+ <div id="quick-start" className="relative mt-9 scroll-mt-24 sm:mt-10">
84
+ <div
85
+ className="relative mx-auto w-full max-w-150 rounded-lg border px-4 py-5 sm:px-5 sm:py-6"
86
style={{
326
- backgroundImage:
327
- "linear-gradient(90deg, var(--hero-gradient-start) 0%, var(--hero-gradient-mid) 46%, var(--hero-gradient-end) 100%)",
87
+ background: "var(--hero-terminal-bg)",
88
+ borderColor: "var(--hero-terminal-border)",
89
+ color: "var(--hero-terminal-foreground)",
90
+ boxShadow: "0 18px 44px var(--hero-terminal-shadow)",
91
}}
92
>
330
- To The Public Internet
331
- </span>
332
- </h1>
333
- </div>
334
-
335
- <div className="relative mt-10 -mx-4 w-auto sm:-mx-6 md:-mx-8">
336
- <div className="overflow-hidden border-b border-border/80 bg-transparent">
337
- <div className="relative mx-auto max-w-7xl px-3 py-6 sm:px-6 sm:py-8">
338
- <div className="pointer-events-none absolute inset-x-0 top-6 flex justify-center sm:top-8">
339
- <div className="h-28 w-28 rounded-full bg-primary/16 blur-3xl dark:bg-primary/22" />
340
- </div>
341
- <div className="pointer-events-none absolute inset-y-0 left-0 z-40 w-12 bg-linear-to-r from-background via-background/74 to-transparent dark:from-background dark:via-background/60 sm:w-24" />
342
- <div className="pointer-events-none absolute inset-y-0 right-0 z-40 w-12 bg-linear-to-l from-background via-background/74 to-transparent dark:from-background dark:via-background/60 sm:w-24" />
343
-
344
- <div className="relative h-82 sm:h-90">
345
- <div
346
- onPointerDown={handlePointerDown}
347
- onPointerMove={handlePointerMove}
348
- onPointerUp={handlePointerEnd}
349
- onPointerCancel={handlePointerEnd}
350
- className={`flex h-full items-start gap-4 px-1 pt-6 sm:px-4 sm:pt-8 ${
351
- transitionEnabled && !isDragging
352
- ? "transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]"
353
- : "transition-none"
354
- } ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
355
- style={{
356
- transform: `translateX(${trackTranslateX})`,
357
- touchAction: "pan-y",
358
- }}
359
- >
360
- {carouselSlides.map((card, index) => {
361
- const distance = Math.abs(index - trackIndex);
362
- const isActive = index === trackIndex;
363
-
364
- return (
365
- <article
366
- key={`${card.key}-${index}`}
367
- className={`relative h-61 shrink-0 overflow-hidden rounded-[1.65rem] border px-5 py-5 text-left transition-[opacity,transform,box-shadow] duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] sm:h-67 sm:px-7 sm:py-6 ${
368
- isActive
369
- ? "border-primary/24 bg-white/92 opacity-100 shadow-[0_24px_54px_rgba(15,23,42,0.08)] dark:bg-white/8 dark:shadow-[0_26px_60px_rgba(0,0,0,0.22)]"
370
- : distance === 1
371
- ? "border-border/70 bg-background/78 opacity-62 shadow-[0_14px_32px_rgba(15,23,42,0.04)] dark:bg-white/4"
372
- : "border-border/60 bg-background/70 opacity-30 shadow-none dark:bg-white/3"
373
- } ${isActive ? "translate-y-0 scale-100" : "translate-y-5 scale-[0.95]"}`}
374
- style={{ width: `${slideSize}px` }}
375
- aria-hidden={!isActive}
376
- >
377
- <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(75,195,230,0.12),transparent_36%)] dark:bg-[radial-gradient(circle_at_top_right,rgba(75,195,230,0.14),transparent_36%)]" />
378
- <div className="relative flex h-full flex-col">
379
- <div className="flex items-center gap-4">
380
- <span className="inline-flex rounded-full bg-primary/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
381
- Portal
382
- </span>
383
- </div>
384
- <div className="mt-10 space-y-3">
385
- <h3 className="max-w-[12ch] text-[1.9rem] leading-[0.92] font-semibold tracking-tight text-foreground sm:text-[2.2rem]">
386
- {card.title}
387
- </h3>
388
- <p className="max-w-[34ch] text-[0.98rem] leading-6 text-text-muted sm:text-[1rem]">
389
- {card.description}
390
- </p>
391
- </div>
392
- <div className="mt-auto pt-8">
393
- <div className="h-px w-16 bg-linear-to-r from-primary/55 to-transparent" />
394
- </div>
395
- </div>
396
- </article>
397
- );
398
- })}
93
+ <div className="mb-5 flex min-w-0 items-center justify-between gap-3">
94
+ <div className="flex min-w-0 items-center gap-3">
95
+ <span
96
+ aria-hidden="true"
97
+ className="shrink-0 font-mono text-lg leading-none"
98
+ style={{ color: "var(--hero-terminal-accent)" }}
99
+ >
100
+ {">"}
101
+ </span>
102
+ <h2
103
+ id="tunnel-preview"
104
+ className="min-w-0 text-xl font-bold tracking-normal sm:text-2xl"
105
+ >
106
+ Start a tunnel
107
+ </h2>
108
</div>
109
</div>
110
+ <TunnelCommandForm theme="terminal" mode="hero" />
111
</div>
112
</div>
113
</div>
114
405
- <div className="relative -mx-4 w-auto sm:-mx-6 md:-mx-8">
406
- <div className="mx-auto max-w-6xl px-4 pb-5 text-left sm:px-6">
407
- <div className="space-y-2">
408
- <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
409
- Core features
410
- </p>
411
- <h2 className="text-3xl font-semibold tracking-tight text-foreground">
412
- Built for real localhost publishing
413
- </h2>
414
- </div>
415
- </div>
416
- <div className="overflow-hidden border-t border-border/80 bg-border/70">
115
+ <div className="relative mt-10 -mx-4 w-auto sm:-mx-6 md:-mx-8">
116
+ <div className="overflow-hidden border-y border-border/80 bg-border/80">
117
<div className="grid gap-px sm:grid-cols-2 lg:grid-cols-3">
418
- {heroFeatures.map(({ eyebrow, title, description }) => (
118
+ {coreFeatures.map(({ eyebrow, title, description }) => (
119
<article
120
key={title}
421
- className="flex min-h-52 bg-background/88 p-6 text-left transition-colors duration-200 hover:bg-background/92 sm:min-h-56 sm:p-7"
121
+ className="flex min-h-48 bg-background p-6 text-left transition-colors duration-200 hover:bg-secondary/35 sm:min-h-52 sm:p-7"
122
>
123
<div className="flex h-full flex-col space-y-3">
424
- <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-primary/80">
124
+ <p className="text-[11px] font-semibold uppercase tracking-normal text-primary/80">
125
{eyebrow}
126
</p>
427
- <h3 className="text-[1.2rem] font-semibold tracking-tight text-foreground sm:text-[1.32rem] sm:leading-tight">
127
+ <h3 className="text-[1.2rem] font-semibold tracking-normal text-foreground sm:text-[1.32rem] sm:leading-tight">
128
{title}
129
</h3>
430
- <p className="max-w-[28ch] text-[0.95rem] leading-6 text-text-muted">
130
+ <p className="max-w-[30ch] text-[0.95rem] leading-6 text-text-muted">
131
{description}
132
</p>
133
<div className="mt-auto pt-4">
434
- <div className="h-px w-12 bg-linear-to-r from-primary/55 to-transparent" />
134
+ <div className="h-px w-10 bg-primary/45" />
135
</div>
136
</div>
137
</article>
@@ -439,46 +139,6 @@ export function LandingHero() {
139
</div>
140
</div>
141
</div>
442
-
443
- <div id="quick-start" className="relative mt-8 scroll-mt-24 sm:mt-10">
444
- <div className="mx-auto w-full max-w-6xl text-left">
445
- <div className="space-y-2">
446
- <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
447
- Quick Start
448
- </p>
449
- <h2 className="text-3xl font-semibold tracking-tight text-foreground">
450
- Expose service
451
- </h2>
452
- </div>
453
-
454
- <div
455
- className="relative mx-auto mt-4 w-full max-w-130 rounded-xl border px-4 py-5 sm:px-5 sm:py-6"
456
- style={{
457
- background: "var(--hero-terminal-bg)",
458
- borderColor: "var(--hero-terminal-border)",
459
- color: "var(--hero-terminal-foreground)",
460
- boxShadow: "0 30px 72px var(--hero-terminal-shadow)",
461
- }}
462
- >
463
- <div className="mb-5 flex min-w-0 items-center gap-3">
464
- <span
465
- aria-hidden="true"
466
- className="shrink-0 font-mono text-lg leading-none"
467
- style={{ color: "var(--hero-terminal-accent)" }}
468
- >
469
- {">"}
470
- </span>
471
- <h3
472
- id="tunnel-preview"
473
- className="min-w-0 text-xl font-bold tracking-tight sm:text-2xl"
474
- >
475
- Run this command
476
- </h3>
477
- </div>
478
- <TunnelCommandForm theme="terminal" mode="hero" />
479
- </div>
480
- </div>
481
- </div>
142
</section>
143
);
144
}
frontend/src/components/ServerCard.tsx
+48
-31
@@ -1,6 +1,7 @@
1
import { useEffect, useMemo, useState } from "react";
2
import { Link } from "react-router-dom";
3
import clsx from "clsx";
4
+import { BadgeDollarSign } from "lucide-react";
5
import {
6
Dialog,
7
DialogContent,
@@ -35,6 +36,8 @@ interface ServerCardProps {
36
ip?: string;
37
displayIP?: string;
38
isIPBanned?: boolean;
39
+ paymentEnabled?: boolean;
40
+ paymentLabel?: string;
41
onBanStatusChange?: (
42
identityKey: string,
43
isBan: boolean
@@ -72,6 +75,8 @@ export function ServerCard({
75
ip = "",
76
displayIP,
77
isIPBanned = false,
78
+ paymentEnabled = false,
79
+ paymentLabel = "",
80
onBanStatusChange,
81
onBPSChange,
82
onApproveStatusChange,
@@ -84,6 +89,9 @@ export function ServerCard({
89
const [bpsInput, setBpsInput] = useState(bps.toString());
90
const [thumbnailFailed, setThumbnailFailed] = useState(false);
91
const effectiveThumbnail = thumbnailFailed ? "" : thumbnail;
92
+ const normalizedPaymentLabel = paymentLabel.trim();
93
+ const showPaymentBadge = paymentEnabled || normalizedPaymentLabel !== "";
94
+ const effectivePaymentLabel = normalizedPaymentLabel || "Paid app";
95
96
useEffect(() => {
97
setThumbnailFailed(false);
@@ -196,7 +204,7 @@ export function ServerCard({
204
};
205
206
const formatStepLabel = (value: number): string => {
199
- if (value === 0) return "∞";
207
+ if (value === 0) return "No cap";
208
if (value >= 1000000) return `${value / 1000000}M`;
209
if (value >= 1000) return `${value / 1000}K`;
210
return value.toString();
@@ -232,7 +240,7 @@ export function ServerCard({
240
<article
241
data-hero-key={`server-bg-${serverId}`}
242
className={clsx(
235
- "relative w-full overflow-hidden rounded-3xl group border border-white/10 shadow-lg",
243
+ "relative w-full overflow-hidden rounded-lg group border border-border bg-card shadow-sm transition-shadow hover:shadow-md dark:border-white/10",
244
showAdminControls ? "h-71.5" : "h-[174.5px]"
245
)}
246
>
@@ -254,35 +262,44 @@ export function ServerCard({
262
/>
263
)}
264
257
- <div className="absolute inset-0 bg-linear-to-t from-black via-black/60 to-transparent" />
265
+ <div className="absolute inset-0 bg-linear-to-t from-black/86 via-black/58 to-black/18" />
266
267
<div className="relative z-10 flex h-full flex-col justify-between p-5">
260
- <div className="flex items-start justify-between">
261
- <div className="flex items-center gap-2 rounded-full bg-black/40 px-3 py-1 backdrop-blur-sm border border-white/5">
262
- <div
263
- className={clsx(
264
- "size-2 rounded-full",
265
- online
266
- ? "bg-primary shadow-[0_0_8px_rgba(0,219,219,0.8)] animate-pulse"
267
- : "bg-gray-500"
268
- )}
269
- />
270
- <span
271
- className={clsx(
272
- "text-[10px] font-bold uppercase tracking-wider",
273
- online ? "text-white" : "text-white/60"
274
- )}
275
- >
276
- {online ? "Online" : "Offline"}
277
- {formattedDuration && online && ` · ${formattedDuration}`}
278
- </span>
268
+ <div className="flex items-start justify-between gap-3">
269
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
270
+ <div className="flex items-center gap-2 rounded-md bg-black/45 px-2.5 py-1 backdrop-blur-sm border border-white/8">
271
+ <div
272
+ className={clsx(
273
+ "size-2 rounded-full",
274
+ online
275
+ ? "bg-primary"
276
+ : "bg-gray-500"
277
+ )}
278
+ />
279
+ <span
280
+ className={clsx(
281
+ "text-[10px] font-bold uppercase tracking-wider",
282
+ online ? "text-white" : "text-white/60"
283
+ )}
284
+ >
285
+ {online ? "Online" : "Offline"}
286
+ {formattedDuration && online && ` - ${formattedDuration}`}
287
+ </span>
288
+ </div>
289
+
290
+ {showPaymentBadge && (
291
+ <div className="inline-flex max-w-[8.5rem] items-center gap-1.5 rounded-md border border-amber-300/25 bg-amber-300/18 px-2.5 py-1 text-[10px] font-bold uppercase tracking-normal text-amber-100 backdrop-blur-sm">
292
+ <BadgeDollarSign className="size-3 shrink-0" />
293
+ <span className="min-w-0 truncate">{effectivePaymentLabel}</span>
294
+ </div>
295
+ )}
296
</div>
297
298
{showAdminControls ? (
299
<button
300
onClick={handleSelectClick}
301
className={clsx(
285
- "flex size-8 items-center justify-center rounded-full backdrop-blur-md transition-colors border border-white/5 cursor-pointer",
302
+ "flex size-8 items-center justify-center rounded-md backdrop-blur-md transition-colors border border-white/8 cursor-pointer",
303
isSelected
304
? "bg-primary text-black"
305
: "bg-black/40 text-white/70 hover:bg-primary hover:text-black"
@@ -306,7 +323,7 @@ export function ServerCard({
323
<button
324
onClick={handleFavoriteClick}
325
className={clsx(
309
- "flex size-8 items-center justify-center rounded-full backdrop-blur-md transition-colors border border-white/5 cursor-pointer",
326
+ "flex size-8 items-center justify-center rounded-md backdrop-blur-md transition-colors border border-white/8 cursor-pointer",
327
isFavorite
328
? "bg-primary text-black"
329
: "bg-black/40 text-white/70 hover:bg-primary hover:text-black"
@@ -350,7 +367,7 @@ export function ServerCard({
367
{tags.map((tag, index) => (
368
<span
369
key={index}
353
- className="rounded bg-primary/20 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-primary border border-primary/30 whitespace-nowrap"
370
+ className="rounded-sm bg-primary/16 px-2 py-0.5 text-[10px] font-bold uppercase tracking-normal text-primary border border-primary/24 whitespace-nowrap"
371
>
372
#{tag}
373
</span>
@@ -368,7 +385,7 @@ export function ServerCard({
385
386
{!showAdminControls && effectiveThumbnail && (
387
<div className="shrink-0">
371
- <div className="size-10 overflow-hidden rounded-xl border border-white/20 shadow-lg">
388
+ <div className="size-10 overflow-hidden rounded-md border border-white/20 shadow-sm">
389
<img
390
alt={`${name} avatar`}
391
className="h-full w-full object-cover"
@@ -389,7 +406,7 @@ export function ServerCard({
406
</span>
407
<button
408
onClick={handleBPSSettingsClick}
392
- className="px-3 py-1 text-[10px] rounded-full bg-white/10 hover:bg-white/20 text-white/80 transition-colors cursor-pointer border border-white/10"
409
+ className="px-3 py-1 text-[10px] rounded-md bg-white/10 hover:bg-white/20 text-white/80 transition-colors cursor-pointer border border-white/10"
410
>
411
Settings
412
</button>
@@ -409,13 +426,13 @@ export function ServerCard({
426
<div className="flex gap-2 w-full">
427
<button
428
onClick={handleApproveClick}
412
- className="flex-1 px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white bg-green-600/80 hover:bg-green-600 backdrop-blur-sm"
429
+ className="flex-1 px-4 py-2 rounded-md font-medium text-xs transition-colors cursor-pointer text-white bg-green-600/80 hover:bg-green-600 backdrop-blur-sm"
430
>
431
Approve
432
</button>
433
<button
434
onClick={handleDenyClick}
418
- className="flex-1 px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white bg-red-600/80 hover:bg-red-600 backdrop-blur-sm"
435
+ className="flex-1 px-4 py-2 rounded-md font-medium text-xs transition-colors cursor-pointer text-white bg-red-600/80 hover:bg-red-600 backdrop-blur-sm"
436
>
437
Deny
438
</button>
@@ -424,7 +441,7 @@ export function ServerCard({
441
<button
442
onClick={ip ? handleIPBanClick : handleBanClick}
443
className={clsx(
427
- "w-full px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white backdrop-blur-sm",
444
+ "w-full px-4 py-2 rounded-md font-medium text-xs transition-colors cursor-pointer text-white backdrop-blur-sm",
445
(ip ? isIPBanned : isBanned)
446
? "bg-green-600/80 hover:bg-green-600"
447
: "bg-red-600/80 hover:bg-red-600"
@@ -461,7 +478,7 @@ export function ServerCard({
478
)}
479
480
<Dialog open={showBPSModal} onOpenChange={setShowBPSModal}>
464
- <DialogContent className="max-w-sm rounded-xl">
481
+ <DialogContent className="max-w-sm rounded-lg">
482
<DialogHeader>
483
<DialogTitle>BPS Settings</DialogTitle>
484
<DialogDescription>
frontend/src/components/ServerListView.tsx
+24
-21
@@ -208,7 +208,6 @@ export function ServerListView({
208
const [relayDiscoveryLoading, setRelayDiscoveryLoading] = useState(
209
() => !isAdmin
210
);
211
- const [relayDiscoveryMessage, setRelayDiscoveryMessage] = useState("");
211
const [selectedIdentityKeys, setSelectedIdentityKeys] = useState<Set<string>>(
212
new Set()
213
);
@@ -216,6 +215,10 @@ export function ServerListView({
215
const favoriteIds = useMemo(() => new Set(favorites), [favorites]);
216
const showLandingHero = !isAdmin && landingPageEnabled;
217
const currentRelayURL = useMemo(() => readCurrentOrigin(), []);
218
+ const paymentAppCount = useMemo(
219
+ () => serverItems.filter((server) => server.paymentEnabled).length,
220
+ [serverItems]
221
+ );
222
223
const handleToggleSelect = (identityKey: string) => {
224
setSelectedIdentityKeys((prev) => {
@@ -293,10 +296,8 @@ export function ServerListView({
296
setRelayDiscoveryLoading(true);
297
setRelayReleaseVersions({});
298
setKnownRelays([]);
296
- setRelayDiscoveryMessage("");
299
300
void (async () => {
299
- let discoveryMessage = "";
301
let nextKnownRelays = normalizeKnownRelays(undefined, currentRelayURL);
302
303
try {
@@ -307,7 +308,7 @@ export function ServerListView({
308
currentRelayURL
309
);
310
} catch {
310
- discoveryMessage = "Known relay data is unavailable on this relay.";
311
+ // Keep the current relay fallback when discovery is unavailable.
312
}
313
314
if (cancelled) {
@@ -316,7 +317,6 @@ export function ServerListView({
317
318
setKnownRelays(nextKnownRelays);
319
setRelayDiscoveryLoading(false);
319
- setRelayDiscoveryMessage(discoveryMessage);
320
321
const relayURLs = nextKnownRelays.map((relay) => relay.relayURL);
322
const uniqueRelayURLs = [...new Set(relayURLs)];
@@ -614,10 +614,14 @@ export function ServerListView({
614
owner: server.owner,
615
online: server.online,
616
serverUrl: server.link,
617
+ paymentEnabled: server.paymentEnabled,
618
+ paymentLabel: server.paymentLabel,
619
}}
620
firstSeen={server.firstSeen}
621
isFavorite={favoriteIds.has(server.id)}
622
onToggleFavorite={onToggleFavorite}
623
+ paymentEnabled={server.paymentEnabled}
624
+ paymentLabel={server.paymentLabel}
625
showAdminControls={isAdmin && !!adminServer}
626
identityKey={adminServer?.identityKey}
627
address={adminServer?.address}
@@ -672,7 +676,7 @@ export function ServerListView({
676
<div className="space-y-1.5">
677
<a
678
href={ROUTE_PATHS.home}
675
- className="inline-block text-lg font-bold tracking-tight text-foreground transition-colors hover:text-primary"
679
+ className="inline-block text-lg font-bold tracking-normal text-foreground transition-colors hover:text-primary"
680
>
681
PORTAL
682
</a>
@@ -798,12 +802,12 @@ export function ServerListView({
802
>
803
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
804
<div className="space-y-2">
801
- <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
805
+ <p className="text-sm font-semibold uppercase tracking-normal text-primary">
806
Live apps
807
</p>
808
<h2
809
id="live-servers-title"
806
- className="text-3xl font-semibold tracking-tight text-foreground"
810
+ className="text-3xl font-semibold tracking-normal text-foreground"
811
>
812
Browse live apps
813
</h2>
@@ -816,6 +820,10 @@ export function ServerListView({
820
{searchBar}
821
<div className="px-1 pt-3 text-sm text-text-muted">
822
{filteredServers.length.toLocaleString()} services visible
823
+ {paymentAppCount > 0 &&
824
+ `, including ${paymentAppCount.toLocaleString()} paid app${
825
+ paymentAppCount === 1 ? "" : "s"
826
+ }`}
827
</div>
828
{serverGrid}
829
</div>
@@ -839,12 +847,12 @@ export function ServerListView({
847
>
848
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
849
<div className="space-y-2">
842
- <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
850
+ <p className="text-sm font-semibold uppercase tracking-normal text-primary">
851
Relays
852
</p>
853
<h2
854
id="public-relays-title"
847
- className="text-3xl font-semibold tracking-tight text-foreground"
855
+ className="text-3xl font-semibold tracking-normal text-foreground"
856
>
857
Public relays
858
</h2>
@@ -853,19 +861,19 @@ export function ServerListView({
861
href={OFFICIAL_REGISTRY_SOURCE_URL}
862
target="_blank"
863
rel="noopener noreferrer"
856
- className="inline-flex h-10 items-center justify-center rounded-full bg-primary/12 px-4 text-sm font-semibold text-primary transition-colors hover:bg-primary/20"
864
+ className="inline-flex h-10 items-center justify-center rounded-md bg-primary/10 px-4 text-sm font-semibold text-primary transition-colors hover:bg-primary/16"
865
>
866
Open registry.json
867
</a>
868
</div>
869
862
- <div className="mt-6 rounded-xl border border-border/80 bg-secondary/35 p-5 sm:p-6">
870
+ <div className="mt-6 rounded-lg border border-border/80 bg-secondary/25 p-5 sm:p-6">
871
{relayDiscoveryLoading ? (
864
- <div className="rounded-2xl border border-border/70 bg-background/90 px-4 py-3 text-sm text-text-muted">
872
+ <div className="rounded-md border border-border/70 bg-background px-4 py-3 text-sm text-text-muted">
873
Loading known relays...
874
</div>
875
) : knownRelays.length === 0 ? (
868
- <div className="rounded-2xl border border-border/70 bg-background/90 px-4 py-3 text-sm text-text-muted">
876
+ <div className="rounded-md border border-border/70 bg-background px-4 py-3 text-sm text-text-muted">
877
No known relays discovered from this relay.
878
</div>
879
) : (
@@ -873,7 +881,7 @@ export function ServerListView({
881
{knownRelays.map((relay) => (
882
<div
883
key={relay.relayURL}
876
- className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/90 px-4 py-3"
884
+ className="flex min-w-0 items-center justify-between gap-3 rounded-md border border-border/70 bg-background px-4 py-3"
885
>
886
<a
887
href={relay.relayURL}
@@ -884,7 +892,7 @@ export function ServerListView({
892
{relay.relayURL}
893
</a>
894
<div className="flex shrink-0 items-center gap-2">
887
- <span className="rounded-full bg-background px-2.5 py-1 font-mono text-[11px] font-medium text-text-muted ring-1 ring-border">
895
+ <span className="rounded-sm bg-secondary/70 px-2.5 py-1 font-mono text-[11px] font-medium text-text-muted ring-1 ring-border">
896
{relayReleaseLabel(
897
relayReleaseVersions,
898
relay.relayURL
@@ -895,11 +903,6 @@ export function ServerListView({
903
))}
904
</div>
905
)}
898
- {relayDiscoveryMessage ? (
899
- <div className="mt-3 text-sm text-text-muted">
900
- {relayDiscoveryMessage}
901
- </div>
902
- ) : null}
906
</div>
907
</section>
908
</main>
frontend/src/components/ThemeToggleButton.tsx
+1
-1
@@ -18,7 +18,7 @@ export function ThemeToggleButton({ className }: ThemeToggleButtonProps) {
18
size="icon"
19
onClick={toggleTheme}
20
className={clsx(
21
- "h-12 w-12 cursor-pointer rounded-full border-border/70 bg-background/90 text-foreground shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/40 hover:bg-background hover:text-primary",
21
+ "h-12 w-12 cursor-pointer rounded-md border-border/70 bg-background/90 text-foreground shadow-sm transition-colors hover:border-primary/40 hover:bg-background hover:text-primary",
22
className
23
)}
24
aria-label={`Switch to ${nextTheme} theme`}
frontend/src/components/TunnelCommandForm.tsx
+24
-24
@@ -131,7 +131,7 @@ function HeroTunnelCommandForm({
131
}[serviceStatus];
132
const isPreviewURLDisabled = serviceStatus === "waiting";
133
const heroSectionLabelClass = cn(
134
- "text-[13px] font-semibold tracking-[0.04em] sm:text-sm",
134
+ "text-[13px] font-semibold tracking-normal sm:text-sm",
135
isTerminal ? "text-slate-100" : "text-foreground/85"
136
);
137
const heroURLClass = cn(
@@ -139,14 +139,14 @@ function HeroTunnelCommandForm({
139
isTerminal ? "text-sky-300" : "text-primary"
140
);
141
const platformButtonGroupClass = cn(
142
- "flex shrink-0 rounded-lg border p-0.5",
142
+ "flex shrink-0 rounded-md border p-0.5",
143
isTerminal
144
? "border-white/6 bg-white/[0.035]"
145
: "border-border bg-border"
146
);
147
const platformButtonClass = (selected: boolean) =>
148
cn(
149
- "min-w-[72px] whitespace-nowrap rounded-md px-2.5 py-1.5 text-[11px] font-semibold transition-colors",
149
+ "min-w-[72px] whitespace-nowrap rounded-sm px-2.5 py-1.5 text-[11px] font-semibold transition-colors",
150
selected
151
? isTerminal
152
? "bg-white/[0.08] text-slate-200"
@@ -156,7 +156,7 @@ function HeroTunnelCommandForm({
156
: "text-text-muted hover:text-foreground"
157
);
158
const heroControlLabelClass = cn(
159
- "shrink-0 text-[9px] font-semibold uppercase tracking-[0.16em]",
159
+ "shrink-0 text-[9px] font-semibold uppercase tracking-normal",
160
isTerminal ? "text-slate-500" : "text-text-muted"
161
);
162
const heroControlInputClass = cn(
@@ -166,7 +166,7 @@ function HeroTunnelCommandForm({
166
: "text-foreground/85 placeholder:text-muted-foreground"
167
);
168
const heroShuffleButtonClass = cn(
169
- "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors",
169
+ "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-sm transition-colors",
170
isTerminal
171
? "text-slate-500 hover:bg-white/[0.06] hover:text-slate-200"
172
: "text-text-muted hover:bg-foreground/5 hover:text-foreground"
@@ -227,7 +227,7 @@ function HeroTunnelCommandForm({
227
onChange={(event) => setTarget(event.target.value)}
228
placeholder={DEFAULT_HOST}
229
aria-label="Local port or address"
230
- className={cn(heroControlInputClass, "w-[19 font-mono")}
230
+ className={cn(heroControlInputClass, "w-20 font-mono")}
231
/>
232
</div>
233
<div className="ml-auto flex min-w-0 items-center justify-end gap-2 sm:w-88">
@@ -252,7 +252,7 @@ function HeroTunnelCommandForm({
252
</div>
253
<div
254
className={cn(
255
- "relative min-h-37 rounded-xl border px-4 py-4 pr-14 font-mono text-sm leading-7",
255
+ "relative min-h-37 rounded-lg border px-4 py-4 pr-14 font-mono text-sm leading-7",
256
isTerminal
257
? "border-white/10 bg-black/55 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]"
258
: "border-border/80 bg-border/90 text-foreground"
@@ -262,7 +262,7 @@ function HeroTunnelCommandForm({
262
type="button"
263
onClick={handleCopy}
264
className={cn(
265
- "absolute top-4 right-4 inline-flex h-8 w-8 items-center justify-center rounded-lg transition-colors",
265
+ "absolute top-4 right-4 inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors",
266
isTerminal
267
? "text-emerald-300/75 hover:bg-emerald-400/10 hover:text-emerald-200"
268
: "text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700"
@@ -287,7 +287,7 @@ function HeroTunnelCommandForm({
287
<p className={heroSectionLabelClass}>3. Open this public URL</p>
288
<div
289
className={cn(
290
- "space-y-3 rounded-xl border px-3.5 py-3",
290
+ "space-y-3 rounded-lg border px-3.5 py-3",
291
isTerminal
292
? "border-white/8 bg-white/4.5"
293
: "border-border bg-white"
@@ -419,29 +419,29 @@ function FullTunnelCommandForm({
419
? "text-xs text-slate-400"
420
: "text-xs leading-5 text-muted-foreground dark:text-zinc-500";
421
const inlineFieldClass = isTerminal
422
- ? "flex items-center overflow-hidden rounded-xl border border-white/10 bg-white/5"
423
- : "flex items-center overflow-hidden rounded-md border border-border/80 bg-secondary/55 focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/30 dark:border-white/10 dark:bg-[#272727]";
422
+ ? "flex items-center overflow-hidden rounded-lg border border-white/10 bg-white/5"
423
+ : "flex items-center overflow-hidden rounded-md border border-border/80 bg-secondary/55 focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/30 dark:border-white/10 dark:bg-secondary/60";
424
const inlinePrefixClass = isTerminal
425
- ? "shrink-0 border-r border-white/10 px-3 text-xs font-medium uppercase tracking-[0.12em] text-slate-400"
426
- : "shrink-0 border-r border-border px-3 text-[0.82rem] font-medium uppercase tracking-[0.08em] text-text-muted dark:border-black/25 dark:text-zinc-400";
425
+ ? "shrink-0 border-r border-white/10 px-3 text-xs font-medium uppercase tracking-normal text-slate-400"
426
+ : "shrink-0 border-r border-border px-3 text-[0.82rem] font-medium uppercase tracking-normal text-text-muted dark:border-border dark:text-zinc-400";
427
const inlineInputClass = isTerminal
428
? "h-10 border-0 bg-transparent px-3 text-sm text-white placeholder:text-slate-500 shadow-none focus-visible:ring-0"
429
: "h-9 border-0 bg-transparent px-3 text-sm font-semibold text-foreground placeholder:text-text-muted shadow-none focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-500";
430
const relayFieldClass = isTerminal
431
- ? "flex min-h-12 flex-wrap items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-2.5 py-2"
431
+ ? "flex min-h-12 flex-wrap items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-2.5 py-2"
432
: "flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-border bg-background px-2 py-2 shadow-sm focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/25 dark:border-white/10 dark:bg-transparent";
433
const relayChipClass = isTerminal
434
? "inline-flex items-center gap-1 rounded-md bg-white/10 px-2.5 py-1.5 text-xs font-medium text-slate-100"
435
- : "inline-flex items-center gap-1 rounded-md bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground dark:bg-[#22312c] dark:text-[#dceee4]";
435
+ : "inline-flex items-center gap-1 rounded-md bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground dark:bg-primary/14 dark:text-primary";
436
const relayChipRemoveClass = isTerminal
437
? "hover:bg-white/10"
438
- : "text-text-muted hover:bg-background/80 dark:text-[#cfe3d8] dark:hover:bg-black/20";
438
+ : "text-text-muted hover:bg-background/80 dark:text-primary dark:hover:bg-black/20";
439
const advancedInputClass = isTerminal
440
? "h-10 rounded-lg border-white/10 bg-white/5 text-white placeholder:text-slate-500"
441
- : "h-10 rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground shadow-sm dark:border-white/10 dark:bg-[#171717] dark:text-zinc-100 dark:placeholder:text-zinc-500";
441
+ : "h-10 rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground shadow-sm dark:border-white/10 dark:bg-background dark:text-zinc-100 dark:placeholder:text-zinc-500";
442
const osGroupClass = isTerminal
443
- ? "flex rounded-xl bg-white/8 p-1"
444
- : "flex rounded-md bg-border p-1 dark:bg-[#272727]";
443
+ ? "flex rounded-lg bg-white/8 p-1"
444
+ : "flex rounded-md bg-border p-1 dark:bg-secondary/60";
445
const osButtonClass = (selected: boolean) =>
446
cn(
447
"flex-1 rounded-sm px-3 py-2 text-sm font-semibold transition-colors",
@@ -450,12 +450,12 @@ function FullTunnelCommandForm({
450
? "bg-white/8 text-slate-200"
451
: "text-slate-400 hover:text-slate-300"
452
: selected
453
- ? "bg-card text-foreground shadow-sm ring-1 ring-border/80 dark:bg-[#0b0b0b] dark:text-zinc-100 dark:ring-transparent"
453
+ ? "bg-card text-foreground shadow-sm ring-1 ring-border/80 dark:bg-background dark:text-zinc-100 dark:ring-transparent"
454
: "text-muted-foreground hover:text-foreground dark:text-zinc-500 dark:hover:text-zinc-300"
455
);
456
const commandPanelClass = isTerminal
457
- ? "relative rounded-xl border border-white/10 bg-black/30"
458
- : "relative rounded-md bg-secondary/55 shadow-sm ring-1 ring-border/70 dark:bg-[#272727] dark:ring-transparent";
457
+ ? "relative rounded-lg border border-white/10 bg-black/30"
458
+ : "relative rounded-md bg-secondary/55 shadow-sm ring-1 ring-border/70 dark:bg-secondary/60 dark:ring-transparent";
459
const commandPreClass = isTerminal
460
? "overflow-x-auto whitespace-pre-wrap break-all p-4 pr-12 font-mono text-sm leading-7 text-white"
461
: "overflow-x-auto whitespace-pre-wrap break-all p-3 pr-12 font-mono text-sm leading-7 text-foreground dark:text-zinc-100";
@@ -610,7 +610,7 @@ function FullTunnelCommandForm({
610
{showAdvanced ? "Hide advanced options" : "Advanced options"}
611
</button>
612
{showAdvanced && (
613
- <div className="mt-3 space-y-4 rounded-md border border-border bg-muted/45 px-4 py-4 dark:border-white/8 dark:bg-[#101010]">
613
+ <div className="mt-3 space-y-4 rounded-md border border-border bg-muted/45 px-4 py-4 dark:border-white/8 dark:bg-background/70">
614
<label className="flex items-center gap-2 text-sm text-foreground dark:text-zinc-300">
615
<input
616
type="checkbox"
@@ -676,7 +676,7 @@ function FullTunnelCommandForm({
676
className={advancedInputClass}
677
/>
678
{normalizedThumbnailURL && (
679
- <div className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-md border border-border bg-background dark:border-white/10 dark:bg-[#171717]">
679
+ <div className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-md border border-border bg-background dark:border-white/10 dark:bg-background">
680
<img
681
src={normalizedThumbnailURL}
682
alt="Thumbnail preview"
frontend/src/components/TunnelCommandModal.tsx
+3
-3
@@ -16,14 +16,14 @@ export function TunnelCommandModal() {
16
<DialogTrigger asChild>
17
<Button
18
type="button"
19
- className="h-10 cursor-pointer rounded-full bg-primary/12 px-4 text-sm font-semibold text-primary shadow-none transition-colors hover:bg-primary/20"
19
+ className="h-10 cursor-pointer rounded-md bg-primary/10 px-4 text-sm font-semibold text-primary shadow-none transition-colors hover:bg-primary/16"
20
>
21
Add Your Server
22
</Button>
23
</DialogTrigger>
24
- <DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto rounded-sm border border-border bg-card p-0 text-card-foreground shadow-[0_24px_64px_rgba(15,23,42,0.16)] dark:border-white/10 dark:bg-[#0b0b0b] dark:text-zinc-100 dark:shadow-[0_28px_80px_rgba(0,0,0,0.5)] [&>button]:right-5 [&>button]:top-5 [&>button]:rounded-md [&>button]:text-text-muted [&>button]:ring-offset-card [&>button]:hover:bg-foreground/5 [&>button]:hover:text-foreground [&>button]:focus:ring-ring/30 [&>button]:data-[state=open]:bg-transparent [&>button]:data-[state=open]:text-text-muted dark:[&>button]:text-zinc-500 dark:[&>button]:ring-offset-[#0b0b0b] dark:[&>button]:hover:bg-white/5 dark:[&>button]:hover:text-zinc-300 dark:[&>button]:focus:ring-white/20 dark:[&>button]:data-[state=open]:text-zinc-500">
24
+ <DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-card p-0 text-card-foreground shadow-[0_18px_48px_rgba(15,23,42,0.14)] dark:border-white/10 dark:bg-card dark:text-zinc-100 dark:shadow-[0_22px_56px_rgba(0,0,0,0.42)] [&>button]:right-5 [&>button]:top-5 [&>button]:rounded-md [&>button]:text-text-muted [&>button]:ring-offset-card [&>button]:hover:bg-foreground/5 [&>button]:hover:text-foreground [&>button]:focus:ring-ring/30 [&>button]:data-[state=open]:bg-transparent [&>button]:data-[state=open]:text-text-muted dark:[&>button]:text-zinc-500 dark:[&>button]:ring-offset-card dark:[&>button]:hover:bg-white/5 dark:[&>button]:hover:text-zinc-300 dark:[&>button]:focus:ring-white/20 dark:[&>button]:data-[state=open]:text-zinc-500">
25
<DialogHeader className="px-6 pt-6 pb-4 text-left">
26
- <DialogTitle className="flex items-center gap-2 text-[1.05rem] font-semibold tracking-tight text-foreground dark:text-zinc-100">
26
+ <DialogTitle className="flex items-center gap-2 text-[1.05rem] font-semibold tracking-normal text-foreground dark:text-zinc-100">
27
<Terminal className="h-4 w-4" />
28
Tunnel Setup Command
29
</DialogTitle>
frontend/src/components/ui/dialog.tsx
+1
-1
@@ -85,7 +85,7 @@ const DialogTitle = React.forwardRef<
85
<DialogPrimitive.Title
86
ref={ref}
87
className={cn(
88
- "text-lg font-semibold leading-none tracking-tight",
88
+ "text-lg font-semibold leading-none tracking-normal",
89
className
90
)}
91
{...props}
frontend/src/hooks/useAdmin.ts
+8
-1
@@ -3,7 +3,11 @@ import { useList, type BaseServer } from "@/hooks/useList";
3
import type { BanFilter } from "@/types/filters";
4
import { BROWSER_API_PATHS } from "@/lib/apiPaths";
5
import { APIClientError, apiClient } from "@/lib/apiClient";
6
-import { parseLeaseMetadata, resolveLeaseThumbnail } from "@/lib/metadata";
6
+import {
7
+ parseLeaseMetadata,
8
+ resolveLeasePayment,
9
+ resolveLeaseThumbnail,
10
+} from "@/lib/metadata";
11
import type {
12
ApprovalMode,
13
IPPolicyUpdate,
@@ -87,6 +91,7 @@ function toAdminServer(
91
row: PolicyLease,
92
): AdminServer {
93
const metadata = parseLeaseMetadata(row.metadata);
94
+ const payment = resolveLeasePayment(metadata);
95
const hostname = row.hostname || "";
96
const serviceName = row.name || "";
97
const address = row.address.trim();
@@ -103,6 +108,8 @@ function toAdminServer(
108
link: hostname ? `https://${hostname}/` : "",
109
lastUpdated: row.last_seen_at || undefined,
110
firstSeen: row.first_seen_at || undefined,
111
+ paymentEnabled: payment.enabled,
112
+ paymentLabel: payment.label,
113
identityKey: row.identity_key.trim(),
114
address,
115
isBanned: row.is_banned,
frontend/src/hooks/useList.ts
+8
-5
@@ -1,4 +1,4 @@
1
-import { useCallback, useEffect, useMemo, useState } from "react";
1
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
import type { SortOption, StatusFilter } from "@/types/filters";
3
4
export interface BaseServer {
@@ -13,6 +13,8 @@ export interface BaseServer {
13
link: string;
14
lastUpdated?: string;
15
firstSeen?: string;
16
+ paymentEnabled?: boolean;
17
+ paymentLabel?: string;
18
}
19
20
export interface UseListOptions<T extends BaseServer> {
@@ -95,7 +97,7 @@ export function useList<T extends BaseServer>({
97
const [favorites, setFavorites] = useState<string[]>(() =>
98
readStoredFavorites(storageKey)
99
);
98
- const [dataLoaded, setDataLoaded] = useState(false);
100
+ const hasLoadedServersRef = useRef(false);
101
102
useEffect(() => {
103
setFavorites(readStoredFavorites(storageKey));
@@ -128,9 +130,9 @@ export function useList<T extends BaseServer>({
130
131
useEffect(() => {
132
if (servers.length > 0) {
131
- setDataLoaded(true);
133
+ hasLoadedServersRef.current = true;
134
}
133
- if (!dataLoaded && servers.length === 0) {
135
+ if (!hasLoadedServersRef.current && servers.length === 0) {
136
return;
137
}
138
const validIDs = new Set(servers.map((server) => server.id));
@@ -138,7 +140,7 @@ export function useList<T extends BaseServer>({
140
const next = prev.filter((id) => validIDs.has(id));
141
return next.length === prev.length ? prev : next;
142
});
141
- }, [servers, dataLoaded]);
143
+ }, [servers]);
144
145
useEffect(() => {
146
const availableTagSet = new Set(availableTags);
@@ -170,6 +172,7 @@ export function useList<T extends BaseServer>({
172
return (
173
server.name.toLowerCase().includes(query) ||
174
server.description.toLowerCase().includes(query) ||
175
+ (server.paymentLabel || "").toLowerCase().includes(query) ||
176
server.tags.some((tag) => tag.toLowerCase().includes(query))
177
);
178
};
frontend/src/hooks/useServerList.ts
+8
-1
@@ -2,7 +2,11 @@ import { useEffect, useMemo, useState } from "react";
2
import { useList, type BaseServer } from "@/hooks/useList";
3
import { apiClient } from "@/lib/apiClient";
4
import { BROWSER_API_PATHS } from "@/lib/apiPaths";
5
-import { parseLeaseMetadata, resolveLeaseThumbnail } from "@/lib/metadata";
5
+import {
6
+ parseLeaseMetadata,
7
+ resolveLeasePayment,
8
+ resolveLeaseThumbnail,
9
+} from "@/lib/metadata";
10
import type { Lease, PublicStateResponse } from "@/types/api";
11
12
type PublicState = {
@@ -13,6 +17,7 @@ type PublicState = {
17
function convertPublicLeasesToServers(leases: Lease[]): BaseServer[] {
18
return leases.map((row) => {
19
const metadata = parseLeaseMetadata(row.metadata);
20
+ const payment = resolveLeasePayment(metadata);
21
const hostname = row.hostname || "";
22
const serviceName = row.name || "";
23
@@ -28,6 +33,8 @@ function convertPublicLeasesToServers(leases: Lease[]): BaseServer[] {
33
link: hostname ? `https://${hostname}/` : "",
34
lastUpdated: row.last_seen_at || undefined,
35
firstSeen: row.first_seen_at || undefined,
36
+ paymentEnabled: payment.enabled,
37
+ paymentLabel: payment.label,
38
};
39
});
40
}
frontend/src/index.css
+20
-48
@@ -7,9 +7,9 @@
7
--font-family-display: "Manrope", sans-serif;
8
--font-family-sans: "Open Sans", sans-serif;
9
10
- --radius: 0.875rem;
11
- --radius-lg: 1.25rem;
12
- --radius-xl: 1.75rem;
10
+ --radius: 0.5rem;
11
+ --radius-lg: 0.75rem;
12
+ --radius-xl: 1rem;
13
14
--color-background: var(--background);
15
--color-foreground: var(--foreground);
@@ -42,7 +42,7 @@
42
--card-foreground: oklch(25% 0.02 250);
43
--popover: oklch(100% 0 0);
44
--popover-foreground: oklch(25% 0.02 250);
45
- --primary: oklch(75% 0.14 195);
45
+ --primary: oklch(60% 0.11 195);
46
--primary-foreground: oklch(100% 0 0);
47
--secondary: oklch(92% 0.01 250);
48
--secondary-foreground: oklch(45% 0.02 250);
@@ -54,30 +54,18 @@
54
--destructive-foreground: oklch(100% 0 0);
55
--border: oklch(90% 0.01 250);
56
--input: oklch(95% 0.005 250);
57
- --ring: oklch(75% 0.14 195);
57
+ --ring: oklch(60% 0.11 195);
58
59
--green-status: oklch(55% 0.16 150);
60
--text-muted: oklch(55% 0.03 250);
61
- --accent-orange: oklch(60% 0.2 35);
62
- --accent-magenta: oklch(50% 0.22 350);
63
- --neon-cyan: oklch(75% 0.14 195);
64
-
65
- --hero-grid-dot: oklch(75% 0.14 195 / 0.18);
66
- --hero-gradient-start: var(--accent-orange);
67
- --hero-gradient-mid: var(--neon-cyan);
68
- --hero-gradient-end: var(--accent-magenta);
61
+
62
+ --hero-grid-dot: oklch(60% 0.11 195 / 0.14);
63
--hero-terminal-bg:
70
- linear-gradient(160deg, oklch(19% 0.005 250 / 0.98), oklch(14% 0.008 250 / 0.98));
71
- --hero-terminal-border: oklch(75% 0.14 195 / 0.22);
64
+ linear-gradient(180deg, oklch(19% 0.005 250 / 0.98), oklch(15% 0.006 250 / 0.98));
65
+ --hero-terminal-border: oklch(60% 0.11 195 / 0.22);
66
--hero-terminal-foreground: oklch(98% 0.01 0);
73
- --hero-terminal-accent: var(--neon-cyan);
74
- --hero-terminal-shadow: oklch(15% 0 0 / 0.18);
75
-
76
- --card-media-fallback-start: oklch(97% 0.015 220);
77
- --card-media-fallback-end: oklch(91% 0.04 195);
78
- --card-media-overlay:
79
- radial-gradient(circle at top left, oklch(75% 0.14 195 / 0.18), transparent 36%),
80
- linear-gradient(180deg, transparent, oklch(100% 0 0 / 0.72));
67
+ --hero-terminal-accent: var(--primary);
68
+ --hero-terminal-shadow: oklch(15% 0 0 / 0.12);
69
}
70
71
.dark {
@@ -87,7 +75,7 @@
75
--card-foreground: oklch(98% 0.01 0);
76
--popover: oklch(20% 0 0);
77
--popover-foreground: oklch(98% 0.01 0);
90
- --primary: oklch(80% 0.14 195);
78
+ --primary: oklch(78% 0.1 195);
79
--primary-foreground: oklch(10% 0 0);
80
--secondary: oklch(25% 0.02 195);
81
--secondary-foreground: oklch(98% 0.01 0);
@@ -99,30 +87,18 @@
87
--destructive-foreground: oklch(98% 0 0);
88
--border: oklch(100% 0 0 / 0.1);
89
--input: oklch(22% 0.01 0);
102
- --ring: oklch(80% 0.14 195);
90
+ --ring: oklch(78% 0.1 195);
91
92
--green-status: oklch(75% 0.16 150);
93
--text-muted: oklch(65% 0.02 0);
106
- --accent-orange: oklch(65% 0.2 35);
107
- --accent-magenta: oklch(55% 0.25 350);
108
- --neon-cyan: oklch(80% 0.14 195);
109
-
110
- --hero-grid-dot: oklch(80% 0.14 195 / 0.22);
111
- --hero-gradient-start: var(--neon-cyan);
112
- --hero-gradient-mid: var(--accent-magenta);
113
- --hero-gradient-end: var(--accent-orange);
94
+
95
+ --hero-grid-dot: oklch(78% 0.1 195 / 0.18);
96
--hero-terminal-bg:
115
- linear-gradient(160deg, oklch(19% 0 0 / 0.78), oklch(12% 0.01 250 / 0.88));
116
- --hero-terminal-border: oklch(80% 0.14 195 / 0.3);
97
+ linear-gradient(180deg, oklch(18% 0 0 / 0.9), oklch(12% 0.01 250 / 0.94));
98
+ --hero-terminal-border: oklch(78% 0.1 195 / 0.28);
99
--hero-terminal-foreground: oklch(98% 0.01 0);
118
- --hero-terminal-accent: var(--neon-cyan);
100
+ --hero-terminal-accent: var(--primary);
101
--hero-terminal-shadow: oklch(0% 0 0 / 0.28);
120
-
121
- --card-media-fallback-start: oklch(26% 0.03 235);
122
- --card-media-fallback-end: oklch(18% 0.02 260);
123
- --card-media-overlay:
124
- radial-gradient(circle at top left, oklch(80% 0.14 195 / 0.26), transparent 36%),
125
- linear-gradient(180deg, transparent, oklch(10% 0 0 / 0.78));
102
}
103
104
* {
@@ -143,9 +119,7 @@
119
body {
120
min-height: 100vh;
121
background:
146
- radial-gradient(circle at top center, oklch(75% 0.14 195 / 0.16), transparent 36%),
147
- radial-gradient(circle at 12% 18%, oklch(60% 0.2 35 / 0.08), transparent 22%),
148
- linear-gradient(180deg, oklch(100% 0 0) 0%, oklch(97% 0.005 250) 54%, oklch(95% 0.005 250) 100%);
122
+ linear-gradient(180deg, oklch(100% 0 0) 0%, oklch(97% 0.005 250) 62%, oklch(95% 0.005 250) 100%);
123
color: var(--foreground);
124
font-family: var(--font-family-sans);
125
}
@@ -162,8 +136,6 @@
136
137
.dark body {
138
background:
165
- radial-gradient(circle at top center, oklch(80% 0.14 195 / 0.16), transparent 32%),
166
- radial-gradient(circle at 82% 10%, oklch(55% 0.25 350 / 0.12), transparent 20%),
139
linear-gradient(180deg, oklch(16% 0 0) 0%, oklch(13% 0.01 250) 54%, oklch(11% 0.01 250) 100%);
140
}
141
@@ -174,7 +146,7 @@
146
h5,
147
h6 {
148
font-family: var(--font-family-display);
177
- letter-spacing: -0.03em;
149
+ letter-spacing: 0;
150
}
151
152
button {
frontend/src/lib/metadata.ts
+35
@@ -7,6 +7,11 @@ interface Metadata {
7
owner: string;
8
}
9
10
+export interface PaymentDisplay {
11
+ enabled: boolean;
12
+ label: string;
13
+}
14
+
15
const EMPTY_METADATA: Metadata = {
16
description: "",
17
tags: [],
@@ -68,3 +73,33 @@ export function resolveLeaseThumbnail(metadata: Metadata, hostname: string): str
73
}
74
return `${BROWSER_API_PATHS.thumbnail.prefix}${encodeURIComponent(normalizedHostname)}`;
75
}
76
+
77
+export function resolveLeasePayment(metadata: Metadata): PaymentDisplay {
78
+ const normalizedTags = new Set(
79
+ metadata.tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean)
80
+ );
81
+ const description = metadata.description.toLowerCase();
82
+ const enabled =
83
+ normalizedTags.has("x402") ||
84
+ normalizedTags.has("payment") ||
85
+ normalizedTags.has("paid") ||
86
+ normalizedTags.has("usdc") ||
87
+ /\bx402\b|\bpaid\b|\bpayment\b|\busdc\b/.test(description);
88
+
89
+ if (!enabled) {
90
+ return { enabled: false, label: "" };
91
+ }
92
+
93
+ const labelParts: string[] = [];
94
+ if (normalizedTags.has("x402") || description.includes("x402")) {
95
+ labelParts.push("x402");
96
+ }
97
+ if (normalizedTags.has("usdc") || description.includes("usdc")) {
98
+ labelParts.push("USDC");
99
+ }
100
+
101
+ return {
102
+ enabled: true,
103
+ label: labelParts.length > 0 ? labelParts.join(" ") : "Paid app",
104
+ };
105
+}
frontend/src/pages/ServerDetail.tsx
+25
-2
@@ -1,6 +1,7 @@
1
import { SsgoiTransition } from "@ssgoi/react";
2
import { useEffect } from "react";
3
import { useLocation, useNavigate } from "react-router-dom";
4
+import { BadgeDollarSign } from "lucide-react";
5
6
interface ServerDetailState {
7
id: string;
@@ -11,6 +12,8 @@ interface ServerDetailState {
12
owner: string;
13
online: boolean;
14
serverUrl: string;
15
+ paymentEnabled?: boolean;
16
+ paymentLabel?: string;
17
}
18
19
export function ServerDetail() {
@@ -70,7 +73,19 @@ export function ServerDetail() {
73
return null;
74
}
75
73
- const { id, thumbnail, name, online, description, tags, owner } = server;
76
+ const {
77
+ id,
78
+ thumbnail,
79
+ name,
80
+ online,
81
+ description,
82
+ tags,
83
+ owner,
84
+ paymentEnabled,
85
+ paymentLabel = "",
86
+ } = server;
87
+ const normalizedPaymentLabel = paymentLabel.trim();
88
+ const showPaymentBadge = paymentEnabled || normalizedPaymentLabel !== "";
89
90
// Base size multiplier (1 = default, 2 = 2x size)
91
const basicSize = 2.5;
@@ -84,7 +99,7 @@ export function ServerDetail() {
99
>
100
{/* Content overlay - Full screen */}
101
<div className="absolute inset-0 flex items-center justify-center p-6 md:p-12">
87
- <div className="w-full h-full max-w-7xl bg-background/70 backdrop-blur-sm rounded-xl flex flex-col gap-6 p-8 md:p-12 items-start text-start">
102
+ <div className="w-full h-full max-w-7xl bg-background/78 backdrop-blur-sm rounded-lg flex flex-col gap-6 p-8 md:p-12 items-start text-start">
103
<div className="w-full h-full flex flex-col justify-center gap-6 md:gap-8">
104
<div
105
className="flex flex-col"
@@ -112,6 +127,14 @@ export function ServerDetail() {
127
{online ? "Online" : "Offline"}
128
</p>
129
</div>
130
+ {showPaymentBadge && (
131
+ <div className="inline-flex w-fit max-w-full items-center gap-2 rounded-md border border-amber-400/25 bg-amber-400/10 px-4 py-2 font-display text-amber-700 dark:text-amber-100">
132
+ <BadgeDollarSign className="h-5 w-5 shrink-0" />
133
+ <span className="truncate text-base font-bold uppercase tracking-wide">
134
+ {normalizedPaymentLabel || "Paid app"}
135
+ </span>
136
+ </div>
137
+ )}
138
<p
139
className="text-foreground font-bold leading-tight"
140
style={{ fontSize: `${6 * basicSize}vw` }}