From 0a86e13ecf3017eab877957e4f57155155ec2f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Wed, 22 Jul 2026 13:00:20 +0200 Subject: [PATCH 01/17] feat: paywall generation overriding the same paywall instead of creating many of them --- .../backend/src/routes/public-file-serving.ts | 12 +- .../studio/paywalls/paywall-card-skeleton.tsx | 11 +- .../features/studio/paywalls/paywall-card.tsx | 36 ++++-- .../core/src/domain/paywallThumbnail.test.ts | 10 +- packages/core/src/domain/paywallThumbnail.ts | 22 ++-- .../PaywallThumbnailService.test.ts | 56 +++++++-- .../PaywallThumbnailService.ts | 114 ++++++++---------- packages/db/src/schema.ts | 6 +- 8 files changed, 151 insertions(+), 116 deletions(-) diff --git a/apps/backend/src/routes/public-file-serving.ts b/apps/backend/src/routes/public-file-serving.ts index a4e76426b..0a865e4fd 100644 --- a/apps/backend/src/routes/public-file-serving.ts +++ b/apps/backend/src/routes/public-file-serving.ts @@ -1,12 +1,12 @@ /** * Public asset serving — `GET /files/*`. * - * Stored public files (avatars today, written by the org/project avatar RPCs to - * `avatars///.`) are content-addressed, immutable, and - * unauthenticated: the key's sha256 is the capability. These are images served - * cross-origin as ``, so — unlike the paywall HTML routes — there is - * NO CSP sandbox; just the stored `Content-Type`, an immutable cache policy, - * permissive CORS, and `nosniff`. + * Stored public files are unauthenticated images served cross-origin as + * ``. Most use immutable content-addressed keys; mutable paywall + * thumbnails append their document sequence to the public URL as a cache + * buster. Unlike the paywall HTML routes, there is NO CSP sandbox; just the + * stored `Content-Type`, an immutable cache policy, permissive CORS, and + * `nosniff`. */ import { PublicFileStore } from "@voidhash/core/services"; import { Cause, Effect, Layer } from "effect"; diff --git a/apps/www/src/features/studio/paywalls/paywall-card-skeleton.tsx b/apps/www/src/features/studio/paywalls/paywall-card-skeleton.tsx index b21df3e90..6f067a699 100644 --- a/apps/www/src/features/studio/paywalls/paywall-card-skeleton.tsx +++ b/apps/www/src/features/studio/paywalls/paywall-card-skeleton.tsx @@ -1,12 +1,15 @@ import { Skeleton } from "@voidhash/ui"; +/** Loading placeholder matching the paywall card's thumbnail geometry. */ export function PaywallCardSkeleton() { return (
- -
- - +
+ +
+
+ +
); diff --git a/apps/www/src/features/studio/paywalls/paywall-card.tsx b/apps/www/src/features/studio/paywalls/paywall-card.tsx index 76bb50bfe..2c412ab76 100644 --- a/apps/www/src/features/studio/paywalls/paywall-card.tsx +++ b/apps/www/src/features/studio/paywalls/paywall-card.tsx @@ -102,25 +102,39 @@ export function PaywallCard({ paywall, organizationSlug, projectSlug }: PaywallC to="/studio/$organizationSlug/$projectSlug/design/$id" > {/* Preview Area */} -
- {paywall.thumbnailUrl ? ( +
+ {paywall.thumbnailUrl && ( - ) : ( -
- -
)} + +
+
+ {paywall.thumbnailUrl ? ( + {paywall.name} + ) : ( + + )} +
+
{/* Info Area */} -
-

{paywall.name}

-

{paywall.slug}

+
+

{paywall.name}

+

+ {paywall.slug} +

diff --git a/packages/core/src/domain/paywallThumbnail.test.ts b/packages/core/src/domain/paywallThumbnail.test.ts index 4ec63e1dc..694d2d5dc 100644 --- a/packages/core/src/domain/paywallThumbnail.test.ts +++ b/packages/core/src/domain/paywallThumbnail.test.ts @@ -7,9 +7,9 @@ import { } from "./paywallThumbnail.ts"; describe("derivePaywallThumbnailKey", () => { - it("is project- and paywall-scoped and seq-addressed", () => { - expect(derivePaywallThumbnailKey("proj_1", "pw_1", 7)).toBe( - "paywall-thumbnails/proj_1/pw_1/7.png", + it("is a stable project- and paywall-scoped key", () => { + expect(derivePaywallThumbnailKey("proj_1", "pw_1")).toBe( + "paywall-thumbnails/proj_1/pw_1/thumbnail.png", ); }); }); @@ -46,9 +46,9 @@ describe("thumbnail ownership guards", () => { }); it("extracts the key only for own, project+paywall-scoped URLs", () => { - const owned = `${base}/files/paywall-thumbnails/proj_1/pw_1/3.png`; + const owned = `${base}/files/paywall-thumbnails/proj_1/pw_1/thumbnail.png?v=3`; expect(paywallThumbnailKeyFromUrl(owned, "proj_1", "pw_1", base)).toBe( - "paywall-thumbnails/proj_1/pw_1/3.png", + "paywall-thumbnails/proj_1/pw_1/thumbnail.png", ); expect(paywallThumbnailKeyFromUrl(owned, "proj_1", "pw_2", base)).toBe(null); expect(paywallThumbnailKeyFromUrl(owned, "proj_2", "pw_1", base)).toBe(null); diff --git a/packages/core/src/domain/paywallThumbnail.ts b/packages/core/src/domain/paywallThumbnail.ts index c0e61941f..a4f298f08 100644 --- a/packages/core/src/domain/paywallThumbnail.ts +++ b/packages/core/src/domain/paywallThumbnail.ts @@ -2,22 +2,16 @@ * Public URL + object-key helpers for paywall thumbnails, mirroring the * ownership guards in {@link file://./paywallAssetImage.ts}. Thumbnails live in * the same public file store as paywall assets, under a distinct - * `paywall-thumbnails///.png` layout, and are served - * at `${publicBaseUrl}/files/` like every other public object. + * `paywall-thumbnails///thumbnail.png` layout, and are + * served at `${publicBaseUrl}/files/` like every other public object. */ /** - * Content-addressed-by-seq object key for one rendered thumbnail: - * `paywall-thumbnails///.png`. The `seq` in the key - * makes each render immutable, so a newer render never overwrites an older - * object in place — the previous object is deleted best-effort after the row - * flips to the new URL. + * Stable object key for a paywall's rendered thumbnail. New renders overwrite + * this object so each paywall owns at most one current thumbnail. */ -export const derivePaywallThumbnailKey = ( - projectId: string, - paywallId: string, - seq: number, -): string => `paywall-thumbnails/${projectId}/${paywallId}/${seq}.png`; +export const derivePaywallThumbnailKey = (projectId: string, paywallId: string): string => + `paywall-thumbnails/${projectId}/${paywallId}/thumbnail.png`; /** * Whether a stored thumbnail URL points at an object owned by the given @@ -48,6 +42,6 @@ export const paywallThumbnailKeyFromUrl = ( if (!url.startsWith(prefix)) { return null; } - const key = url.slice(prefix.length); - return key.startsWith(`paywall-thumbnails/${projectId}/${paywallId}/`) ? key : null; + const key = url.slice(prefix.length).split(/[?#]/, 1)[0]; + return key?.startsWith(`paywall-thumbnails/${projectId}/${paywallId}/`) ? key : null; }; diff --git a/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.test.ts b/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.test.ts index 0cfdee84d..ba2a73210 100644 --- a/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.test.ts +++ b/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.test.ts @@ -151,11 +151,20 @@ describe("PaywallThumbnailService local components", () => { const paywall = { id: paywallId, projectId, - thumbnailSeq: null, - thumbnailUrl: null, + thumbnailSeq: 11, + thumbnailUrl: "https://files.test/files/paywall-thumbnails/proj_local/pw_local/11.png", }; let rendererInput: unknown; let compileCalls = 0; + let storedObject: + | { + readonly body: Uint8Array; + readonly contentType: string | undefined; + readonly key: string; + } + | undefined; + let thumbnailUpdate: unknown; + const storageOperations: string[] = []; const db = { query: { @@ -163,13 +172,22 @@ describe("PaywallThumbnailService local components", () => { findFirst: () => Effect.succeed(paywall), }, }, - update: () => ({ - set: () => ({ - where: () => ({ - returning: () => Effect.succeed([{ id: paywallId }]), + transaction: (run: (tx: unknown) => unknown) => + run({ + select: () => ({ + from: () => ({ + where: () => ({ + for: () => Effect.succeed([paywall]), + }), + }), + }), + update: () => ({ + set: (value: unknown) => { + thumbnailUpdate = value; + return { where: () => Effect.void }; + }, }), }), - }), }; const dependencies = Layer.mergeAll( Layer.succeed(Db, db as never), @@ -211,9 +229,16 @@ describe("PaywallThumbnailService local components", () => { Layer.succeed(PublicFileStore, { publicBaseUrl: "https://files.test", publicUrl: (key) => `https://files.test/files/${key}`, - putObject: () => Effect.void, + putObject: (input) => + Effect.sync(() => { + storedObject = input; + storageOperations.push(`put:${input.key}`); + }), getObject: () => Effect.succeed(null), - deleteObject: () => Effect.void, + deleteObject: (key) => + Effect.sync(() => { + storageOperations.push(`delete:${key}`); + }), }), Layer.succeed(SnapshotImageRenderer, { render: (input) => @@ -237,5 +262,18 @@ describe("PaywallThumbnailService local components", () => { localComponentTrees: { [componentPath]: previewTrees }, snapshot, }); + expect(storedObject).toMatchObject({ + contentType: "image/png", + key: "paywall-thumbnails/proj_local/pw_local/thumbnail.png", + }); + expect(thumbnailUpdate).toEqual({ + thumbnailSeq: 12, + thumbnailUrl: + "https://files.test/files/paywall-thumbnails/proj_local/pw_local/thumbnail.png?v=12", + }); + expect(storageOperations).toEqual([ + "delete:paywall-thumbnails/proj_local/pw_local/11.png", + "put:paywall-thumbnails/proj_local/pw_local/thumbnail.png", + ]); }); }); diff --git a/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.ts b/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.ts index 3767d12d4..5005a9dfa 100644 --- a/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.ts +++ b/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.ts @@ -1,11 +1,10 @@ -import { Cause, Context, Effect, Layer, Predicate, Schema } from "effect"; +import { Context, Effect, Layer, Predicate, Schema } from "effect"; -import { Db, and, eq, isNull, lt, or, paywalls, type Paywall } from "@voidhash/db"; +import { Db, eq, paywalls, type Paywall } from "@voidhash/db"; import { hashSource } from "@voidhash/paywall-workspace"; import { derivePaywallThumbnailKey, - isOwnedPaywallThumbnailUrl, paywallThumbnailKeyFromUrl, } from "../../domain/paywallThumbnail.ts"; import { componentServingPreviewKey } from "../paywallDeploys/PaywallDeployManifest.ts"; @@ -94,8 +93,10 @@ export const collectLocalComponentSources = ( /** * `PaywallThumbnailService` owns the render vertical of the paywall-thumbnail - * feature: it renders a mimic document snapshot to a PNG and publishes it, - * gated by a monotonic `seq` guard so a late render never clobbers a newer one. + * feature: it renders a mimic document snapshot to a PNG, overwrites the + * paywall's stable object key, and versions the public URL with the document + * `seq`. A row lock and monotonic guard keep a late render from clobbering a + * newer one. * Queue consumers call `handleDocumentIdle`; list-page backfills call * `renderCurrent` so paywalls created before idle notifications were available * (or whose best-effort queue message was exhausted) can recover. @@ -226,73 +227,58 @@ export class PaywallThumbnailService extends Context.Service + Effect.gen(function* () { + // Serialize only the final overwrite. Rendering happens before this + // transaction, while the lock prevents an older completed render + // from replacing a newer thumbnail at the shared object key. + const [current] = yield* tx + .select() + .from(paywalls) + .where(eq(paywalls.id, paywall.id)) + .for("update"); - // Race-guarded write: only advance if no newer render beat us to the - // row. A concurrent render for a higher seq wins; we delete the object - // we just wrote and stop. - const updated = yield* db - .update(paywalls) - .set({ thumbnailSeq: seq, thumbnailUrl: url }) - .where( - and( - eq(paywalls.id, paywall.id), - or( - isNull(paywalls.thumbnailSeq), - lt(paywalls.thumbnailSeq, seq), - and(eq(paywalls.thumbnailSeq, seq), isNull(paywalls.thumbnailUrl)), - ), - ), - ) - .returning({ id: paywalls.id }); + if ( + current === undefined || + (current.thumbnailSeq !== null && + (current.thumbnailSeq > seq || + (current.thumbnailSeq === seq && current.thumbnailUrl !== null))) + ) { + return false; + } + + const previousKey = + current.thumbnailUrl === null + ? null + : paywallThumbnailKeyFromUrl( + current.thumbnailUrl, + paywall.projectId, + paywall.id, + publicFileStore.publicBaseUrl, + ); + if (previousKey !== null && previousKey !== key) { + yield* publicFileStore.deleteObject(previousKey); + } + + yield* publicFileStore.putObject({ body: png, contentType: "image/png", key }); + const url = `${publicFileStore.publicUrl(key)}?v=${seq}`; + yield* tx + .update(paywalls) + .set({ thumbnailSeq: seq, thumbnailUrl: url }) + .where(eq(paywalls.id, paywall.id)); - if (updated.length === 0) { + return true; + }), + ); + + if (!stored) { yield* Effect.logDebug( `Paywall ${paywall.id} thumbnail seq ${seq} lost the write race; discarding`, ); - yield* publicFileStore - .deleteObject(key) - .pipe( - Effect.catchCause((cause) => - Effect.logWarning( - `Failed to delete superseded thumbnail object ${key}: ${Cause.pretty(cause)}`, - ), - ), - ); return; } - // Best-effort cleanup of the PREVIOUS thumbnail object, scoped to keys - // we own for this project + paywall. - if ( - isOwnedPaywallThumbnailUrl( - paywall.thumbnailUrl, - paywall.projectId, - paywall.id, - publicFileStore.publicBaseUrl, - ) - ) { - const previousKey = paywallThumbnailKeyFromUrl( - paywall.thumbnailUrl!, - paywall.projectId, - paywall.id, - publicFileStore.publicBaseUrl, - ); - if (previousKey !== null && previousKey !== key) { - yield* publicFileStore - .deleteObject(previousKey) - .pipe( - Effect.catchCause((cause) => - Effect.logWarning( - `Failed to delete previous thumbnail object ${previousKey}: ${Cause.pretty(cause)}`, - ), - ), - ); - } - } - yield* Effect.log(`Rendered thumbnail for paywall ${paywall.id} at seq ${seq}`); }); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index f1b2c6a98..3156c4676 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1316,9 +1316,9 @@ export const paywalls = pgTable( projectId: varchar("project_id", { length: 255 }).notNull(), slug: varchar("slug", { length: 255 }).notNull(), source: smallint("source").notNull().default(PaywallSource.editor), - // Public URL of the most recently rendered paywall thumbnail (null until the - // first idle render lands). `thumbnailSeq` is the mimic document `seq` that - // thumbnail was rendered from — the monotonic guard that keeps a late idle + // Public URL of the most recently rendered paywall thumbnail, including its + // cache-busting `seq` query (null until the first idle render lands). + // `thumbnailSeq` also provides the monotonic guard that keeps a late idle // render from overwriting a newer one (see PaywallThumbnailService). thumbnailUrl: text("thumbnail_url"), thumbnailSeq: bigint("thumbnail_seq", { mode: "number" }), From aeda1bf7e1180735b10906f2b53ad12ec50e0446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Wed, 22 Jul 2026 13:18:00 +0200 Subject: [PATCH 02/17] feat: force thumbnail generation --- apps/backend/src/rpcs/paywall-rpcs.ts | 38 +------ apps/backend/src/testing/rpc-smoke-cases.ts | 6 - .../studio/lib/tanstack-query/paywalls.ts | 7 -- .../designer/dev-mode/dev-mode-view.tsx | 13 ++- .../dev-mode/paywall-thumbnail-admin-slot.tsx | 8 ++ .../$projectSlug/paywalls.index.tsx | 26 +---- .../security/endpoint-authorization-matrix.md | 2 +- .../PaywallThumbnailService.test.ts | 91 +++++++++++++++ .../PaywallThumbnailService.ts | 104 ++++++++++-------- packages/rpc/src/groups/PaywallRpcsDef.ts | 10 -- .../tests/ThumbnailQueue.integration.test.ts | 3 +- 11 files changed, 177 insertions(+), 131 deletions(-) create mode 100644 apps/www/src/features/studio/paywalls/designer/dev-mode/paywall-thumbnail-admin-slot.tsx diff --git a/apps/backend/src/rpcs/paywall-rpcs.ts b/apps/backend/src/rpcs/paywall-rpcs.ts index 0d782266a..8b5dc8e17 100644 --- a/apps/backend/src/rpcs/paywall-rpcs.ts +++ b/apps/backend/src/rpcs/paywall-rpcs.ts @@ -1,9 +1,4 @@ -import { - MimicHost, - PaywallReleaseService, - PaywallService, - PaywallThumbnailService, -} from "@voidhash/core/services"; +import { MimicHost, PaywallReleaseService, PaywallService } from "@voidhash/core/services"; import { PaywallRpcsDef, RpcActionForbiddenError, @@ -19,40 +14,9 @@ export const PaywallRpcsLive = PaywallRpcsDef.toLayer( Effect.gen(function* () { const paywallService = yield* PaywallService; const releaseService = yield* PaywallReleaseService; - const thumbnailService = yield* PaywallThumbnailService; const mimicHost = yield* MimicHost; return { - BackfillPaywallThumbnails: ({ projectId }) => - Effect.gen(function* () { - const projectPaywalls = yield* paywallService.getPaywalls(projectId, true); - const missing = projectPaywalls.filter((paywall) => paywall.thumbnailUrl === null); - const results = yield* Effect.forEach( - missing, - (paywall) => - thumbnailService.renderCurrent(paywall.id).pipe( - Effect.as(true), - Effect.catchTag("PaywallThumbnailServiceError", (error) => - Effect.logError("Paywall thumbnail backfill failed", { - paywallId: paywall.id, - message: error.message, - }).pipe(Effect.as(false)), - ), - ), - { concurrency: 1 }, - ); - return { - attempted: missing.length, - rendered: results.filter(Boolean).length, - }; - }).pipe( - Effect.catchTags({ - ActionForbiddenError: (error) => - Effect.fail(new RpcActionForbiddenError({ message: error.message })), - PaywallServiceError: (error) => - Effect.fail(new RpcPaywallServiceError({ cause: error.cause })), - }), - ), CreatePaywall: (input) => paywallService.createPaywall(input).pipe( Effect.catchTags({ diff --git a/apps/backend/src/testing/rpc-smoke-cases.ts b/apps/backend/src/testing/rpc-smoke-cases.ts index bf93d43ea..986edb6f8 100644 --- a/apps/backend/src/testing/rpc-smoke-cases.ts +++ b/apps/backend/src/testing/rpc-smoke-cases.ts @@ -555,12 +555,6 @@ export const rpcSmokeCases = [ role: "admin", tag: "CreatePaywall", }, - { - expected: success, - payload: ({ ids }) => ({ projectId: ids.projectId }), - role: "admin", - tag: "BackfillPaywallThumbnails", - }, { expected: success, payload: ({ ids }) => ({ includeArchived: true, projectId: ids.projectId }), diff --git a/apps/www/src/features/studio/lib/tanstack-query/paywalls.ts b/apps/www/src/features/studio/lib/tanstack-query/paywalls.ts index 535701a83..2b0e7c3cb 100644 --- a/apps/www/src/features/studio/lib/tanstack-query/paywalls.ts +++ b/apps/www/src/features/studio/lib/tanstack-query/paywalls.ts @@ -8,13 +8,6 @@ export const listPaywallsOptions = (options: { projectId: string; includeArchive queryKey: queryKeys.paywall.list(options), }); -export const backfillPaywallThumbnailsOptions = () => - eq.mutationOptions({ - mutationFn: (variables: { projectId: string }) => - VoidhashRpc.request((rpc) => rpc.BackfillPaywallThumbnails(variables)), - mutationKey: ["backfillPaywallThumbnails"], - }); - export const createPaywallOptions = () => eq.mutationOptions({ mutationFn: (variables: { projectId: string; name: string; slug: string }) => diff --git a/apps/www/src/features/studio/paywalls/designer/dev-mode/dev-mode-view.tsx b/apps/www/src/features/studio/paywalls/designer/dev-mode/dev-mode-view.tsx index 9b4695e69..45ea2ad0e 100644 --- a/apps/www/src/features/studio/paywalls/designer/dev-mode/dev-mode-view.tsx +++ b/apps/www/src/features/studio/paywalls/designer/dev-mode/dev-mode-view.tsx @@ -1,9 +1,12 @@ "use client"; import { Button, Tabs, TabsList, TabsTrigger } from "@voidhash/ui"; +import { useParams } from "@tanstack/react-router"; import { XIcon } from "lucide-react"; import { useStore } from "zustand/react"; +import { PaywallThumbnailAdminSlot } from "@/features/studio/paywalls/designer/dev-mode/paywall-thumbnail-admin-slot"; + import { usePaywallDesignerActions, usePaywallDesignerStore } from "../state/designer-store"; import type { DevModeTab } from "../state/designer-store-state"; import { setDevModeTab, toggleDevMode } from "../state/actions/dev-mode-actions"; @@ -19,6 +22,7 @@ const DEV_MODE_TABS: { value: DevModeTab; label: string }[] = [ ]; export function DevModeView() { + const { id: paywallId } = useParams({ strict: false }); const store = usePaywallDesignerStore(); const dispatch = usePaywallDesignerActions(); const activeTab = useStore(store, (state) => state.devMode.activeTab); @@ -46,9 +50,12 @@ export function DevModeView() { ))} - +
+ + +
{activeTab === "snapshot" && } diff --git a/apps/www/src/features/studio/paywalls/designer/dev-mode/paywall-thumbnail-admin-slot.tsx b/apps/www/src/features/studio/paywalls/designer/dev-mode/paywall-thumbnail-admin-slot.tsx new file mode 100644 index 000000000..2687bac72 --- /dev/null +++ b/apps/www/src/features/studio/paywalls/designer/dev-mode/paywall-thumbnail-admin-slot.tsx @@ -0,0 +1,8 @@ +interface PaywallThumbnailAdminSlotProps { + readonly paywallId: string | undefined; +} + +/** Community extension slot reserved for a host-provided thumbnail repair control. */ +export function PaywallThumbnailAdminSlot(_props: PaywallThumbnailAdminSlotProps) { + return null; +} diff --git a/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.index.tsx b/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.index.tsx index 6a1be185e..779fbef2d 100644 --- a/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.index.tsx +++ b/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.index.tsx @@ -1,18 +1,15 @@ -import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { useSuspenseQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { Button, Page, PageHeader, PageHeaderTitle } from "@voidhash/ui"; import { ArchiveIcon } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { useAuth } from "@/features/studio/components/auth-context"; import { CreatePaywallButton } from "@/features/studio/paywalls/create-paywall-button"; import { PaywallCard } from "@/features/studio/paywalls/paywall-card"; import { PaywallCardSkeleton } from "@/features/studio/paywalls/paywall-card-skeleton"; import { VoidhashErrorCard } from "@/features/studio/shell/components/voidhash-error-card"; -import { - backfillPaywallThumbnailsOptions, - listPaywallsOptions, -} from "@/features/studio/lib/tanstack-query/paywalls"; +import { listPaywallsOptions } from "@/features/studio/lib/tanstack-query/paywalls"; import { CurrentUser } from "@/features/studio/lib/utils/current-user"; const THUMBNAIL_REFRESH_INTERVAL_MS = 2_000; @@ -68,16 +65,10 @@ function PaywallsPage() { const [showArchived, setShowArchived] = useState(false); const thumbnailRefreshDeadline = useRef(Date.now() + THUMBNAIL_REFRESH_WINDOW_MS); - const thumbnailBackfillProjectId = useRef(null); - const queryClient = useQueryClient(); const paywallsQueryOptions = listPaywallsOptions({ includeArchived: true, projectId: project.id, }); - const { mutate: backfillThumbnails } = useMutation({ - ...backfillPaywallThumbnailsOptions(), - onSuccess: () => queryClient.invalidateQueries({ queryKey: paywallsQueryOptions.queryKey }), - }); // Fetch archived paywalls alongside active ones so toggling visibility is a // client-side filter — no refetch (and no skeleton flash) when flipping it. @@ -92,17 +83,6 @@ function PaywallsPage() { : false, }); - useEffect(() => { - if ( - thumbnailBackfillProjectId.current === project.id || - !allPaywalls.some((paywall) => paywall.thumbnailUrl === null) - ) { - return; - } - thumbnailBackfillProjectId.current = project.id; - backfillThumbnails({ projectId: project.id }); - }, [allPaywalls, backfillThumbnails, project.id]); - const archivedCount = allPaywalls.filter((paywall) => paywall.archivedAt != null).length; const paywalls = showArchived ? allPaywalls diff --git a/docs/security/endpoint-authorization-matrix.md b/docs/security/endpoint-authorization-matrix.md index e10f47168..dda01d923 100644 --- a/docs/security/endpoint-authorization-matrix.md +++ b/docs/security/endpoint-authorization-matrix.md @@ -74,7 +74,7 @@ database-backed cross-tenant case. “Gap” is a publication blocker. | PaywallComponent | `ListPaywallComponents`, `GetPaywallComponentVersions` | | PaywallDeploy | `ListPaywallDeploys`, `SetActivePaywallRelease` | | PaywallLocation | `ListPaywallLocations`, `CreatePaywallLocation`, `UpdatePaywallLocation`, `ArchivePaywallLocation`, `AssignPaywallLocationShowing`, `ClearPaywallLocationShowing`, `ListPaywallLocationShowings` | -| Paywall | `ListPaywalls`, `BackfillPaywallThumbnails`, `CreatePaywall`, `RenamePaywall`, `ArchivePaywall`, `RestorePaywall`, `DeletePaywall`, `RequestPaywallEditToken`, `CreatePaywallRelease`, `PublishPaywallRelease`, `GetPaywallDraftRelease` | +| Paywall | `ListPaywalls`, `CreatePaywall`, `RenamePaywall`, `ArchivePaywall`, `RestorePaywall`, `DeletePaywall`, `RequestPaywallEditToken`, `CreatePaywallRelease`, `PublishPaywallRelease`, `GetPaywallDraftRelease` | | PaywallWorkspace | `ListWorkspacePaywalls`, `ReadPaywallDocument`, `RecordComponentManifest` | | Perk | `ListPerks`, `CreatePerk`, `DeletePerk` | | ProductPerk | `ListProductPerksByProductId`, `CreateProductPerk`, `DeleteProductPerk` | diff --git a/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.test.ts b/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.test.ts index ba2a73210..693a84d22 100644 --- a/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.test.ts +++ b/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.test.ts @@ -277,3 +277,94 @@ describe("PaywallThumbnailService local components", () => { ]); }); }); + +describe("PaywallThumbnailService forced renders", () => { + it("rerenders the current document version with a fresh cache-busting URL", async () => { + const paywall = { + id: "pw_force", + projectId: "proj_force", + thumbnailSeq: 7, + thumbnailUrl: + "https://files.test/files/paywall-thumbnails/proj_force/pw_force/thumbnail.png?v=7", + }; + const snapshot = { children: [], type: "root" }; + let renderCalls = 0; + let thumbnailUpdate: { thumbnailSeq: number; thumbnailUrl: string } | undefined; + + const db = { + query: { + paywalls: { + findFirst: () => Effect.succeed(paywall), + }, + }, + transaction: (run: (tx: unknown) => unknown) => + run({ + select: () => ({ + from: () => ({ + where: () => ({ + for: () => Effect.succeed([paywall]), + }), + }), + }), + update: () => ({ + set: (value: typeof thumbnailUpdate) => { + thumbnailUpdate = value; + return { where: () => Effect.void }; + }, + }), + }), + }; + const dependencies = Layer.mergeAll( + Layer.succeed(Db, db as never), + Layer.succeed(MimicHost, { + ensurePaywallDocument: () => Effect.void, + getPaywallDocument: () => Effect.succeed({ root: snapshot, version: 7 }), + } as never), + Layer.succeed(PaywallArtifactStore, { + bucketName: "test", + getObject: () => Effect.succeed(null), + head: () => Effect.succeed(null), + putObject: () => Effect.void, + }), + Layer.succeed(ComponentCompiler, { + compileAndExtract: () => Effect.succeed({ status: "unavailable" as const }), + compileCheck: () => Effect.succeed({ status: "unavailable" as const }), + }), + Layer.succeed(ComponentManifestCacheService, { + getMany: () => Effect.succeed(new Map()), + record: () => Effect.succeed(undefined), + }), + Layer.succeed(PublicFileStore, { + deleteObject: () => Effect.void, + getObject: () => Effect.succeed(null), + publicBaseUrl: "https://files.test", + publicUrl: (key) => `https://files.test/files/${key}`, + putObject: () => Effect.void, + }), + Layer.succeed(SnapshotImageRenderer, { + render: () => + Effect.sync(() => { + renderCalls += 1; + return new Uint8Array([1, 2, 3]); + }), + }), + ); + const layer = PaywallThumbnailService.layer.pipe(Layer.provide(dependencies)); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const service = yield* PaywallThumbnailService; + const skipped = yield* service.renderCurrent(paywall.id); + const forced = yield* service.forceRenderCurrent(paywall.id); + return { forced, skipped }; + }).pipe(Effect.provide(layer)), + ); + + expect(result).toEqual({ forced: true, skipped: false }); + expect(renderCalls).toBe(1); + expect(thumbnailUpdate).toMatchObject({ thumbnailSeq: 7 }); + expect(thumbnailUpdate?.thumbnailUrl).toMatch( + /^https:\/\/files\.test\/files\/paywall-thumbnails\/proj_force\/pw_force\/thumbnail\.png\?v=7&r=[0-9a-f-]+$/, + ); + }); +}); diff --git a/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.ts b/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.ts index 5005a9dfa..c44815f75 100644 --- a/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.ts +++ b/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.ts @@ -97,9 +97,9 @@ export const collectLocalComponentSources = ( * paywall's stable object key, and versions the public URL with the document * `seq`. A row lock and monotonic guard keep a late render from clobbering a * newer one. - * Queue consumers call `handleDocumentIdle`; list-page backfills call - * `renderCurrent` so paywalls created before idle notifications were available - * (or whose best-effort queue message was exhausted) can recover. + * Queue consumers call `handleDocumentIdle` for standard renders. Administrative + * repair tools call `forceRenderCurrent` to explicitly replace the current + * document's thumbnail. * * The queue is generic — non-paywall documents may arrive — so a missing * paywall row is a silent no-op. `Db`, `MimicHost`, `PaywallArtifactStore`, @@ -196,14 +196,16 @@ export class PaywallThumbnailService extends Context.Service seq || (paywall.thumbnailSeq === seq && paywall.thumbnailUrl !== null)) @@ -211,7 +213,7 @@ export class PaywallThumbnailService extends Context.Service= ${seq}; skipping`, ); - return; + return false; } const contentHashes = collectDeployedComponentContentHashes(snapshot); @@ -243,7 +245,7 @@ export class PaywallThumbnailService extends Context.Service seq || - (current.thumbnailSeq === seq && current.thumbnailUrl !== null))) + (!force && current.thumbnailSeq === seq && current.thumbnailUrl !== null))) ) { return false; } @@ -262,7 +264,8 @@ export class PaywallThumbnailService extends Context.Service + Effect.fail(new PaywallThumbnailServiceError({ message: String(error.cause) })), SnapshotImageRenderError: (error) => Effect.fail(new PaywallThumbnailServiceError({ message: error.message })), }), ), ); - const renderCurrent = Effect.fn("renderCurrentPaywallThumbnail")( - function* (paywallId: string) { + const renderCurrentPaywall = (paywallId: string, force: boolean) => + Effect.gen(function* () { yield* Effect.annotateCurrentSpan("voidhash.paywall.id", paywallId); const paywall = yield* db.query.paywalls.findFirst({ where: { id: paywallId } }); if (!paywall) { - return; + return false; } yield* Effect.annotateCurrentSpan("voidhash.project.id", paywall.projectId); yield* mimicHost.ensurePaywallDocument(paywall.id); const document = yield* mimicHost.getPaywallDocument(paywall.id); yield* Effect.annotateCurrentSpan("voidhash.paywall_thumbnail.seq", document.version); - yield* renderPaywall({ paywall, seq: document.version, snapshot: document.root }); - }, - (effect) => - effect.pipe( - Effect.catchTags({ - EffectDrizzleQueryError: (error) => - Effect.fail(new PaywallThumbnailServiceError({ message: String(error.cause) })), - ComponentCompilerError: (error) => - Effect.fail(new PaywallThumbnailServiceError({ message: error.message })), - ComponentManifestCacheError: (error) => - Effect.fail(new PaywallThumbnailServiceError({ message: error.message })), - ComponentManifestInvalidError: (error) => - Effect.fail(new PaywallThumbnailServiceError({ message: error.message })), - MimicHostError: (error) => - Effect.fail(new PaywallThumbnailServiceError({ message: error.message })), - PaywallArtifactStoreError: (error) => - Effect.fail( - new PaywallThumbnailServiceError({ - message: `${error.message}: ${error.cause}`, - }), - ), - PublicFileStoreError: (error) => - Effect.fail( - new PaywallThumbnailServiceError({ - message: `${error.message}: ${error.cause}`, - }), - ), - SnapshotImageRenderError: (error) => - Effect.fail(new PaywallThumbnailServiceError({ message: error.message })), - }), - ), + return yield* renderPaywall({ + force, + paywall, + seq: document.version, + snapshot: document.root, + }); + }).pipe( + Effect.catchTags({ + EffectDrizzleQueryError: (error) => + Effect.fail(new PaywallThumbnailServiceError({ message: String(error.cause) })), + ComponentCompilerError: (error) => + Effect.fail(new PaywallThumbnailServiceError({ message: error.message })), + ComponentManifestCacheError: (error) => + Effect.fail(new PaywallThumbnailServiceError({ message: error.message })), + ComponentManifestInvalidError: (error) => + Effect.fail(new PaywallThumbnailServiceError({ message: error.message })), + MimicHostError: (error) => + Effect.fail(new PaywallThumbnailServiceError({ message: error.message })), + PaywallArtifactStoreError: (error) => + Effect.fail( + new PaywallThumbnailServiceError({ + message: `${error.message}: ${error.cause}`, + }), + ), + PublicFileStoreError: (error) => + Effect.fail( + new PaywallThumbnailServiceError({ + message: `${error.message}: ${error.cause}`, + }), + ), + SqlError: (error) => + Effect.fail(new PaywallThumbnailServiceError({ message: String(error.cause) })), + SnapshotImageRenderError: (error) => + Effect.fail(new PaywallThumbnailServiceError({ message: error.message })), + }), + ); + + const renderCurrent = Effect.fn("renderCurrentPaywallThumbnail")((paywallId: string) => + renderCurrentPaywall(paywallId, false), + ); + + const forceRenderCurrent = Effect.fn("forceRenderCurrentPaywallThumbnail")( + (paywallId: string) => renderCurrentPaywall(paywallId, true), ); - return { handleDocumentIdle, renderCurrent } as const; + return { forceRenderCurrent, handleDocumentIdle, renderCurrent } as const; }), }, ) { diff --git a/packages/rpc/src/groups/PaywallRpcsDef.ts b/packages/rpc/src/groups/PaywallRpcsDef.ts index b553393f6..b0044f304 100644 --- a/packages/rpc/src/groups/PaywallRpcsDef.ts +++ b/packages/rpc/src/groups/PaywallRpcsDef.ts @@ -51,16 +51,6 @@ export class PaywallRpcsDef extends RpcGroup.make( }), success: Schema.Array(Paywall), }), - Rpc.make("BackfillPaywallThumbnails", { - error: Schema.Union([RpcActionForbiddenError, RpcPaywallServiceError]), - payload: Schema.Struct({ - projectId: Schema.String, - }), - success: Schema.Struct({ - attempted: Schema.Number, - rendered: Schema.Number, - }), - }), Rpc.make("CreatePaywall", { error: Schema.Union([ RpcActionForbiddenError, diff --git a/selfhost/entry/tests/ThumbnailQueue.integration.test.ts b/selfhost/entry/tests/ThumbnailQueue.integration.test.ts index 199ba5e41..8b8f56589 100644 --- a/selfhost/entry/tests/ThumbnailQueue.integration.test.ts +++ b/selfhost/entry/tests/ThumbnailQueue.integration.test.ts @@ -29,7 +29,8 @@ describePg("self-host thumbnail queue", () => { Effect.sync(() => { handled.push(input); }), - renderCurrent: () => Effect.void, + forceRenderCurrent: () => Effect.succeed(false), + renderCurrent: () => Effect.succeed(false), }); yield* Effect.forkScoped( runSelfhostPaywallThumbnailConsumer.pipe( From ff9e98a833328a947d83be4f8b942d4363bab4db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Wed, 22 Jul 2026 13:28:17 +0200 Subject: [PATCH 03/17] feat: go to dashboard returns to paywall list --- .../src/features/studio/paywalls/designer/panels/top-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/www/src/features/studio/paywalls/designer/panels/top-panel.tsx b/apps/www/src/features/studio/paywalls/designer/panels/top-panel.tsx index dca18bd07..3a846dc03 100644 --- a/apps/www/src/features/studio/paywalls/designer/panels/top-panel.tsx +++ b/apps/www/src/features/studio/paywalls/designer/panels/top-panel.tsx @@ -128,7 +128,7 @@ export function TopPanel() { organizationSlug: organizationSlug ?? "", projectSlug: projectSlug ?? "", }, - to: "/studio/$organizationSlug/$projectSlug", + to: "/studio/$organizationSlug/$projectSlug/paywalls", }); }; From a0eb71f460dd049dbd0e078c1600faba14ac69dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Wed, 22 Jul 2026 15:53:44 +0200 Subject: [PATCH 04/17] fix: thumbnail rendering images --- .../design/content/docs/components/meta.yaml | 1 + .../content/docs/components/page-bar.mdx | 57 ++++++++ .../$projectSlug/paywalls.index.tsx | 131 ++++++++++++------ packages/core/package.json | 1 + .../PaywallThumbnailService.test.ts | 84 ++++++++++- .../PaywallThumbnailService.ts | 20 ++- .../inlinePublicFileImages.test.ts | 105 ++++++++++++++ .../inlinePublicFileImages.ts | 104 ++++++++++++++ .../src/services/paywalls/PaywallService.ts | 1 + packages/ui/components/page-bar.tsx | 70 ++++++++++ packages/ui/index.ts | 1 + packages/ui/package.json | 3 +- selfhost/entry/src/backend/Backend.ts | 25 +++- selfhost/entry/src/backend/Thumbnails.ts | 24 +++- selfhost/entry/tests/Thumbnails.test.ts | 10 ++ 15 files changed, 582 insertions(+), 55 deletions(-) create mode 100644 apps/www/src/features/design/content/docs/components/page-bar.mdx create mode 100644 packages/core/src/services/paywallThumbnails/inlinePublicFileImages.test.ts create mode 100644 packages/core/src/services/paywallThumbnails/inlinePublicFileImages.ts create mode 100644 packages/ui/components/page-bar.tsx diff --git a/apps/www/src/features/design/content/docs/components/meta.yaml b/apps/www/src/features/design/content/docs/components/meta.yaml index 59124c47c..a44026bb5 100644 --- a/apps/www/src/features/design/content/docs/components/meta.yaml +++ b/apps/www/src/features/design/content/docs/components/meta.yaml @@ -27,6 +27,7 @@ pages: - label - menubar - navigation-menu + - page-bar - pagination - popover - progress diff --git a/apps/www/src/features/design/content/docs/components/page-bar.mdx b/apps/www/src/features/design/content/docs/components/page-bar.mdx new file mode 100644 index 000000000..c90ca367e --- /dev/null +++ b/apps/www/src/features/design/content/docs/components/page-bar.mdx @@ -0,0 +1,57 @@ +--- +title: Page Bar +description: A full-width secondary bar below the page header with tabs and actions on either side. +--- + +## Usage + +`PageBar` shares the horizontal spacing and bottom border of `PageHeader`, so its underline runs edge to edge. Both sides accept tabs and buttons: the left side via `children`, the right side via `rightActions`. + + + + New paywall}> + Paywalls + + Filter}> + + All + Active + Inactive + Archived + + +
Page content
+
+
+ +```tsx +import { PageBar, PageBarTab, PageBarTabs, PageTabs } from "@voidhash/ui"; + + + Action}> + + All + Archived + + + {/* tab content rendered anywhere below the bar */} +; +``` + +## Components + +### PageTabs + +Unstyled tabs root. Wrap it around the `PageBar` and the tab content so `PageBarTab` triggers can control content rendered anywhere below the bar. Omit it when the bar holds no tabs. + +### PageBar + +The bar itself. `children` render on the left, `rightActions` on the right; both sides vertically center buttons while tabs stretch to the full bar height. + +### PageBarTabs + +Tab strip for use inside a `PageBar`. The active underline sits on the bar's bottom border. + +### PageBarTab + +A single tab trigger inside `PageBarTabs`. diff --git a/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.index.tsx b/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.index.tsx index 779fbef2d..7faed2efc 100644 --- a/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.index.tsx +++ b/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.index.tsx @@ -1,7 +1,14 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { Button, Page, PageHeader, PageHeaderTitle } from "@voidhash/ui"; -import { ArchiveIcon } from "lucide-react"; +import { + Page, + PageBar, + PageBarTab, + PageBarTabs, + PageHeader, + PageHeaderTitle, + PageTabs, +} from "@voidhash/ui"; import { useRef, useState } from "react"; import { useAuth } from "@/features/studio/components/auth-context"; @@ -15,6 +22,15 @@ import { CurrentUser } from "@/features/studio/lib/utils/current-user"; const THUMBNAIL_REFRESH_INTERVAL_MS = 2_000; const THUMBNAIL_REFRESH_WINDOW_MS = 45_000; +const PAYWALL_TABS = [ + { value: "all", label: "All" }, + { value: "active", label: "Active" }, + { value: "inactive", label: "Inactive" }, + { value: "archived", label: "Archived" }, +] as const; + +type PaywallTab = (typeof PAYWALL_TABS)[number]["value"]; + export const Route = createFileRoute( "/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/", )({ @@ -40,6 +56,13 @@ function PaywallsPageSkeleton() { Paywalls + +
+ {PAYWALL_TABS.map((tab) => ( +
+ ))} +
+
{Array.from({ length: 5 }).map((_, index) => ( // biome-ignore lint/suspicious/noArrayIndexKey: skeleton @@ -50,6 +73,23 @@ function PaywallsPageSkeleton() { ); } +function emptyStateMessage(tab: PaywallTab): string { + switch (tab) { + case "all": + return "No paywalls yet. Create one to get started."; + case "active": + return "No active paywalls. Create one to get started."; + case "inactive": + return "No inactive paywalls."; + case "archived": + return "No archived paywalls."; + default: { + const _exhaustive: never = tab; + return _exhaustive; + } + } +} + function PaywallsPage() { const { organizationSlug, projectSlug } = Route.useParams(); const { user } = useAuth(); @@ -63,15 +103,15 @@ function PaywallsPage() { throw new Error("Project not found"); } - const [showArchived, setShowArchived] = useState(false); + const [tab, setTab] = useState("active"); const thumbnailRefreshDeadline = useRef(Date.now() + THUMBNAIL_REFRESH_WINDOW_MS); const paywallsQueryOptions = listPaywallsOptions({ includeArchived: true, projectId: project.id, }); - // Fetch archived paywalls alongside active ones so toggling visibility is a - // client-side filter — no refetch (and no skeleton flash) when flipping it. + // Fetch archived paywalls alongside active ones so tab switches are a + // client-side filter — no refetch (and no skeleton flash) when changing tabs. const { data: allPaywalls } = useSuspenseQuery({ ...paywallsQueryOptions, // Thumbnail generation starts after the designer connection becomes idle, @@ -83,46 +123,59 @@ function PaywallsPage() { : false, }); - const archivedCount = allPaywalls.filter((paywall) => paywall.archivedAt != null).length; - const paywalls = showArchived - ? allPaywalls - : allPaywalls.filter((paywall) => paywall.archivedAt == null); + const paywalls = allPaywalls.filter((paywall) => { + const isArchived = paywall.archivedAt != null; + switch (tab) { + case "all": + return true; + case "active": + return !isArchived; + case "inactive": + // Paywalls only have archived vs not today; nothing maps to inactive yet. + return false; + case "archived": + return isArchived; + default: { + const _exhaustive: never = tab; + return _exhaustive; + } + } + }); return ( - - {archivedCount > 0 && ( - - )} - -
- } - > + }> Paywalls -
- {paywalls.length === 0 ? ( -
- No paywalls yet. Create one to get started. -
- ) : ( -
- {paywalls.map((paywall) => ( - + setTab(value as PaywallTab)} value={tab}> + + + {PAYWALL_TABS.map((item) => ( + + {item.label} + ))} -
- )} -
+ + +
+ {paywalls.length === 0 ? ( +
+ {emptyStateMessage(tab)} +
+ ) : ( +
+ {paywalls.map((paywall) => ( + + ))} +
+ )} +
+ ); } diff --git a/packages/core/package.json b/packages/core/package.json index 13bd006e3..6aa1ceed5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -75,6 +75,7 @@ "./services/paywallThumbnails/HtmlScreenshot": "./src/services/paywallThumbnails/HtmlScreenshot.ts", "./services/paywallThumbnails/PaywallThumbnailService": "./src/services/paywallThumbnails/PaywallThumbnailService.ts", "./services/paywallThumbnails/SnapshotImageRenderer": "./src/services/paywallThumbnails/SnapshotImageRenderer.ts", + "./services/paywallThumbnails/inlinePublicFileImages": "./src/services/paywallThumbnails/inlinePublicFileImages.ts", "./services/paymentProviders/AppStorePaymentProviderService": "./src/services/paymentProviders/AppStorePaymentProviderService.ts", "./services/paymentProviders/GooglePlayPaymentProviderService": "./src/services/paymentProviders/GooglePlayPaymentProviderService.ts", "./services/paymentProviders/StripePaymentProviderService": "./src/services/paymentProviders/StripePaymentProviderService.ts", diff --git a/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.test.ts b/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.test.ts index 693a84d22..5894e6d70 100644 --- a/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.test.ts +++ b/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.test.ts @@ -318,7 +318,9 @@ describe("PaywallThumbnailService forced renders", () => { Layer.succeed(Db, db as never), Layer.succeed(MimicHost, { ensurePaywallDocument: () => Effect.void, - getPaywallDocument: () => Effect.succeed({ root: snapshot, version: 7 }), + // Version-space is one ahead of the queue's seq-space: version 8 is the + // same document state a queue render would have stored as seq 7. + getPaywallDocument: () => Effect.succeed({ root: snapshot, version: 8 }), } as never), Layer.succeed(PaywallArtifactStore, { bucketName: "test", @@ -367,4 +369,84 @@ describe("PaywallThumbnailService forced renders", () => { /^https:\/\/files\.test\/files\/paywall-thumbnails\/proj_force\/pw_force\/thumbnail\.png\?v=7&r=[0-9a-f-]+$/, ); }); + + it("bypasses a stored thumbnailSeq ahead of the current document (repairs poisoned rows)", async () => { + // A row written with a version-space seq (the pre-fix force path) sits one + // ahead of anything the queue will ever publish; force must still replace it. + const paywall = { + id: "pw_repair", + projectId: "proj_repair", + thumbnailSeq: 8, + thumbnailUrl: + "https://files.test/files/paywall-thumbnails/proj_repair/pw_repair/thumbnail.png?v=8", + }; + const snapshot = { children: [], type: "root" }; + let thumbnailUpdate: { thumbnailSeq: number; thumbnailUrl: string } | undefined; + + const db = { + query: { + paywalls: { + findFirst: () => Effect.succeed(paywall), + }, + }, + transaction: (run: (tx: unknown) => unknown) => + run({ + select: () => ({ + from: () => ({ + where: () => ({ + for: () => Effect.succeed([paywall]), + }), + }), + }), + update: () => ({ + set: (value: typeof thumbnailUpdate) => { + thumbnailUpdate = value; + return { where: () => Effect.void }; + }, + }), + }), + }; + const dependencies = Layer.mergeAll( + Layer.succeed(Db, db as never), + Layer.succeed(MimicHost, { + ensurePaywallDocument: () => Effect.void, + getPaywallDocument: () => Effect.succeed({ root: snapshot, version: 8 }), + } as never), + Layer.succeed(PaywallArtifactStore, { + bucketName: "test", + getObject: () => Effect.succeed(null), + head: () => Effect.succeed(null), + putObject: () => Effect.void, + }), + Layer.succeed(ComponentCompiler, { + compileAndExtract: () => Effect.succeed({ status: "unavailable" as const }), + compileCheck: () => Effect.succeed({ status: "unavailable" as const }), + }), + Layer.succeed(ComponentManifestCacheService, { + getMany: () => Effect.succeed(new Map()), + record: () => Effect.succeed(undefined), + }), + Layer.succeed(PublicFileStore, { + deleteObject: () => Effect.void, + getObject: () => Effect.succeed(null), + publicBaseUrl: "https://files.test", + publicUrl: (key) => `https://files.test/files/${key}`, + putObject: () => Effect.void, + }), + Layer.succeed(SnapshotImageRenderer, { + render: () => Effect.succeed(new Uint8Array([1, 2, 3])), + }), + ); + const layer = PaywallThumbnailService.layer.pipe(Layer.provide(dependencies)); + + const forced = await Effect.runPromise( + Effect.gen(function* () { + const service = yield* PaywallThumbnailService; + return yield* service.forceRenderCurrent(paywall.id); + }).pipe(Effect.provide(layer)), + ); + + expect(forced).toBe(true); + expect(thumbnailUpdate).toMatchObject({ thumbnailSeq: 7 }); + }); }); diff --git a/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.ts b/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.ts index c44815f75..4f7c3ae1c 100644 --- a/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.ts +++ b/packages/core/src/services/paywallThumbnails/PaywallThumbnailService.ts @@ -241,11 +241,16 @@ export class PaywallThumbnailService extends Context.Service seq || - (!force && current.thumbnailSeq === seq && current.thumbnailUrl !== null))) + (current.thumbnailSeq === seq && current.thumbnailUrl !== null))) ) { return false; } @@ -348,11 +353,18 @@ export class PaywallThumbnailService extends Context.Service) => { + const reads: string[] = []; + return { + reads, + store: { + publicBaseUrl: BASE_URL, + getObject: (key: string) => + Effect.sync(() => { + reads.push(key); + return objects[key] ?? null; + }), + }, + }; +}; + +describe("inlinePublicFileImages", () => { + it("inlines img src and CSS background urls as data URIs", async () => { + const { store } = makeStore({ + "paywall-assets/p1/logo.png": { + body: new Uint8Array([1, 2, 3]), + contentType: "image/png", + }, + "paywall-assets/p1/bg.jpg": { + body: new Uint8Array([4, 5]), + contentType: "image/jpeg", + }, + }); + const html = + `` + + `
`; + + const result = await Effect.runPromise(inlinePublicFileImages(html, store)); + + expect(result).toBe( + `` + + `
`, + ); + }); + + it("fetches each distinct url once and leaves unknown keys untouched", async () => { + const { reads, store } = makeStore({ + "a.png": { body: new Uint8Array([9]), contentType: "image/png" }, + }); + const html = + `` + + ``; + + const result = await Effect.runPromise(inlinePublicFileImages(html, store)); + + expect(reads).toEqual(["a.png", "missing.png"]); + expect(result).toBe( + `` + + ``, + ); + }); + + it("strips query fragments when deriving the key and decodes percent-encoding", async () => { + const { reads, store } = makeStore({ + "sprüche/ä.png": { body: new Uint8Array([7]), contentType: null }, + }); + const html = ``; + + const result = await Effect.runPromise(inlinePublicFileImages(html, store)); + + expect(reads).toEqual(["sprüche/ä.png"]); + expect(result).toBe(``); + }); + + it("skips urls continued by an html-escaped query separator", async () => { + const { reads, store } = makeStore({ + "a.png": { body: new Uint8Array([1]), contentType: "image/png" }, + }); + const html = ``; + + const result = await Effect.runPromise(inlinePublicFileImages(html, store)); + + expect(reads).toEqual([]); + expect(result).toBe(html); + }); + + it("skips images that would push the document over the byte budget", async () => { + const { store } = makeStore({ + "big.png": { body: new Uint8Array(64).fill(1), contentType: "image/png" }, + "small.png": { body: new Uint8Array([1]), contentType: "image/png" }, + }); + const html = + `` + ``; + + const result = await Effect.runPromise( + inlinePublicFileImages(html, store, { maxHtmlBytes: html.length + 20 }), + ); + + expect(result).toContain(`${BASE_URL}/files/big.png`); + expect(result).toContain("data:image/png;base64,"); + }); +}); diff --git a/packages/core/src/services/paywallThumbnails/inlinePublicFileImages.ts b/packages/core/src/services/paywallThumbnails/inlinePublicFileImages.ts new file mode 100644 index 000000000..9ae8b90d9 --- /dev/null +++ b/packages/core/src/services/paywallThumbnails/inlinePublicFileImages.ts @@ -0,0 +1,104 @@ +import { Effect } from "effect"; + +import type { PublicFileStoreError, PublicFileStoreShape } from "../storage/PublicFileStore.ts"; + +/** + * Keep the inlined document safely under the 4 MiB HTML budget the Cloudflare + * Browser Rendering screenshot action enforces. + */ +const DEFAULT_MAX_HTML_BYTES = 3_500_000; + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const toBase64 = (bytes: Uint8Array): string => { + let binary = ""; + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return btoa(binary); +}; + +const keyFromUrl = (url: string, prefix: string): string | null => { + const raw = url.slice(prefix.length).split(/[?#]/, 1)[0] ?? ""; + if (raw === "") { + return null; + } + try { + // The renderer emits `encodeURI(url)`; recover the raw object key. + return decodeURIComponent(raw); + } catch { + return null; + } +}; + +/** + * Rewrites every public-file-store URL (`${publicBaseUrl}/files/`) inside + * a rendered HTML document into a base64 `data:` URI read straight from the + * store. + * + * Screenshot backends do not necessarily share the serving origin's network: + * Cloudflare Browser Rendering runs remotely, so a dev worker's + * `http://localhost:*` asset URLs are unreachable and images render blank. + * Inlining removes the network dependency entirely — the worker reads the same + * bytes the `GET /files/*` route would serve. + * + * Unknown keys are left untouched (the URL stays as-is). An image whose data + * URI would push the document past `maxHtmlBytes` is skipped with a warning + * rather than failing the render. A URL immediately followed by an HTML-escaped + * query continuation (`&`) is skipped too, since the match cannot span the + * entity boundary safely. + */ +export const inlinePublicFileImages = ( + html: string, + store: Pick, + options?: { readonly maxHtmlBytes?: number }, +): Effect.Effect => + Effect.gen(function* () { + const prefix = `${store.publicBaseUrl}/files/`; + const maxHtmlBytes = options?.maxHtmlBytes ?? DEFAULT_MAX_HTML_BYTES; + const pattern = new RegExp(`${escapeRegExp(prefix)}[^"'()<>\\s&\\\\]*`, "g"); + + const dataUris = new Map(); + let out = ""; + let cursor = 0; + let projectedBytes = html.length; + + for (const match of html.matchAll(pattern)) { + const url = match[0]; + const start = match.index; + const end = start + url.length; + out += html.slice(cursor, start); + cursor = start; + + if (html.startsWith("&", end)) { + continue; + } + + let dataUri = dataUris.get(url); + if (dataUri === undefined) { + const key = keyFromUrl(url, prefix); + const object = key === null ? null : yield* store.getObject(key); + dataUri = + object === null + ? null + : `data:${object.contentType ?? "image/png"};base64,${toBase64(object.body)}`; + dataUris.set(url, dataUri); + } + if (dataUri === null) { + continue; + } + if (projectedBytes + dataUri.length - url.length > maxHtmlBytes) { + yield* Effect.logWarning( + `Skipping thumbnail image inline for ${url}: document would exceed ${maxHtmlBytes} bytes`, + ); + continue; + } + + projectedBytes += dataUri.length - url.length; + out += dataUri; + cursor = end; + } + + return out + html.slice(cursor); + }); diff --git a/packages/core/src/services/paywalls/PaywallService.ts b/packages/core/src/services/paywalls/PaywallService.ts index 639db1055..f16c09e17 100644 --- a/packages/core/src/services/paywalls/PaywallService.ts +++ b/packages/core/src/services/paywalls/PaywallService.ts @@ -62,6 +62,7 @@ export class PaywallService extends Context.Service()("PaywallSe projectId, ...(includeArchived ? {} : { archivedAt: { isNull: true } }), }, + orderBy: { createdAt: "desc" }, }); }, (effect) => diff --git a/packages/ui/components/page-bar.tsx b/packages/ui/components/page-bar.tsx new file mode 100644 index 000000000..b02155147 --- /dev/null +++ b/packages/ui/components/page-bar.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Tabs as TabsPrimitive } from "radix-ui"; + +import { cn } from "../lib/utils"; + +export type PageBarProps = { + children?: React.ReactNode; + rightActions?: React.ReactNode; + className?: string; +}; + +/** + * Full-width secondary bar rendered below a `PageHeader`. Shares the header's + * horizontal spacing and bottom border, with a left side (`children`) and a + * right side (`rightActions`) that can both hold tabs and buttons. + */ +export function PageBar({ children, rightActions, className }: PageBarProps) { + return ( +
+
{children}
+ {rightActions &&
{rightActions}
} +
+ ); +} + +/** + * Unstyled tabs root connecting `PageBarTabs` triggers to content rendered + * anywhere below the bar. Wrap it around the `PageBar` and the tab content. + */ +export function PageTabs(props: React.ComponentProps) { + return ; +} + +/** + * Tab strip for use inside a `PageBar`. Triggers stretch to the bar's full + * height and the active underline sits on the bar's bottom border. + */ +export function PageBarTabs({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +/** A single tab trigger inside `PageBarTabs`. */ +export function PageBarTab({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 83459f79c..2e1cd5487 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -10,6 +10,7 @@ export * from "./components/theme-provider"; export * from "./components/theme-provider-tanstack"; export * from "./components/theme-toggle"; export * from "./components/page"; +export * from "./components/page-bar"; export * from "./components/ui/accordion"; export * from "./components/ui/alert"; export * from "./components/ui/alert-dialog"; diff --git a/packages/ui/package.json b/packages/ui/package.json index 36a8a2d6c..1142cc16b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -70,7 +70,8 @@ "./copy-text": "./components/copy-text.tsx", "./error-card": "./components/error-card.tsx", "./theme-toggle": "./components/theme-toggle.tsx", - "./page": "./components/page.tsx" + "./page": "./components/page.tsx", + "./page-bar": "./components/page-bar.tsx" }, "scripts": { "typecheck": "tsc --noEmit" diff --git a/selfhost/entry/src/backend/Backend.ts b/selfhost/entry/src/backend/Backend.ts index df7046c51..dedce604e 100644 --- a/selfhost/entry/src/backend/Backend.ts +++ b/selfhost/entry/src/backend/Backend.ts @@ -7,6 +7,7 @@ import { import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; import { Workos } from "@voidhash/core/services/auth/Workos"; import { SnapshotImageRenderer } from "@voidhash/core/services/paywallThumbnails/SnapshotImageRenderer"; +import type { PublicFileStore } from "@voidhash/core/services/storage/PublicFileStore"; import { PaywallAssetConfig } from "@voidhash/core/services/paywallLocations/PaywallAssetConfig"; import { Db } from "@voidhash/db"; import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; @@ -38,9 +39,18 @@ export const makeBackendInfrastructureLive = ( config: SelfhostRuntimeConfig, workos: Layer.Layer, clickhouse?: Layer.Layer, - snapshotImageRenderer: Layer.Layer = BackendSnapshotImageRendererStubLive, -): Layer.Layer => - Layer.mergeAll( + snapshotImageRenderer: Layer.Layer< + SnapshotImageRenderer, + never, + PublicFileStore + > = BackendSnapshotImageRendererStubLive, +): Layer.Layer => { + const publicFileStore = makePublicFileStoreLive( + config.publicObjectStore, + config.publicFilesBaseUrl, + ).pipe(Layer.provide(NodePlatformRuntimeLive)); + + return Layer.mergeAll( Db.layer(config.database), workos, WorkosOrgPortLive.pipe(Layer.provide(workos)), @@ -51,14 +61,15 @@ export const makeBackendInfrastructureLive = ( makePaywallArtifactStoreLive(config.artifactObjectStore).pipe( Layer.provide(NodePlatformRuntimeLive), ), - makePublicFileStoreLive(config.publicObjectStore, config.publicFilesBaseUrl).pipe( - Layer.provide(NodePlatformRuntimeLive), - ), + publicFileStore, BackendPaymentProviderStubsLive, BackendNoopIdentityProjectionPublisherLive, makeBackendMimicHostLive(config.publicBaseUrl), makeHttpComponentCompilerLive(config.componentCompilerUrl), - snapshotImageRenderer, + // `mergeAll` does not cross-wire siblings; the renderer's asset inlining + // reads the same store instance merged above (memoized by reference). + snapshotImageRenderer.pipe(Layer.provide(publicFileStore)), MemoryProjectSchemaCacheLive, clickhouse ?? Layer.empty, ); +}; diff --git a/selfhost/entry/src/backend/Thumbnails.ts b/selfhost/entry/src/backend/Thumbnails.ts index 6cad029b5..4e9b1ee90 100644 --- a/selfhost/entry/src/backend/Thumbnails.ts +++ b/selfhost/entry/src/backend/Thumbnails.ts @@ -3,12 +3,14 @@ import { HtmlScreenshot, HtmlScreenshotError, } from "@voidhash/core/services/paywallThumbnails/HtmlScreenshot"; +import { inlinePublicFileImages } from "@voidhash/core/services/paywallThumbnails/inlinePublicFileImages"; import { PaywallThumbnailService } from "@voidhash/core/services/paywallThumbnails/PaywallThumbnailService"; import { SnapshotImageRenderer, SnapshotImageRenderError, type SnapshotImageRendererShape, } from "@voidhash/core/services/paywallThumbnails/SnapshotImageRenderer"; +import { PublicFileStore } from "@voidhash/core/services/storage/PublicFileStore"; import type { PreviewTree, SnapshotNode, @@ -47,11 +49,17 @@ export const SelfhostHtmlScreenshotLive = Layer.effect( }), ); -/** Renders a Mimic paywall snapshot to static HTML, then to PNG bytes. */ +/** + * Renders a Mimic paywall snapshot to static HTML, then to PNG bytes. + * Public-file-store asset URLs are inlined as `data:` URIs first so the + * screenshot browser never depends on the serving origin being reachable + * (e.g. a container that cannot hairpin back to its own public domain). + */ export const SelfhostSnapshotImageRendererLive = Layer.effect( SnapshotImageRenderer, Effect.gen(function* () { const htmlScreenshot = yield* HtmlScreenshot; + const publicFileStore = yield* PublicFileStore; const { renderPaywallToHtml } = yield* Effect.promise(async () => { const preact = await import("preact"); const runtimeGlobals = globalThis as unknown as { @@ -96,8 +104,18 @@ export const SelfhostSnapshotImageRendererLive = Layer.effect( }), }); + const inlined = yield* inlinePublicFileImages(html, publicFileStore).pipe( + Effect.mapError( + (error) => + new SnapshotImageRenderError({ + cause: error.cause, + message: error.message, + }), + ), + ); + return yield* htmlScreenshot - .screenshot({ deviceScaleFactor, height, html, width }) + .screenshot({ deviceScaleFactor, height, html: inlined, width }) .pipe( Effect.mapError( (error) => @@ -115,7 +133,7 @@ export const SelfhostSnapshotImageRendererLive = Layer.effect( /** Builds the Chromium-backed snapshot renderer shared by MCP previews and thumbnails. */ export const makeSelfhostSnapshotImageRendererLive = ( screenshotConfig: ChromiumScreenshotConfig, -): Layer.Layer => +): Layer.Layer => SelfhostSnapshotImageRendererLive.pipe( Layer.provide( SelfhostHtmlScreenshotLive.pipe( diff --git a/selfhost/entry/tests/Thumbnails.test.ts b/selfhost/entry/tests/Thumbnails.test.ts index 5dff354f7..ef42c3625 100644 --- a/selfhost/entry/tests/Thumbnails.test.ts +++ b/selfhost/entry/tests/Thumbnails.test.ts @@ -1,5 +1,6 @@ import { HtmlScreenshot } from "@voidhash/core/services/paywallThumbnails/HtmlScreenshot"; import { SnapshotImageRenderer } from "@voidhash/core/services/paywallThumbnails/SnapshotImageRenderer"; +import { PublicFileStore } from "@voidhash/core/services/storage/PublicFileStore"; import { Effect, Layer } from "effect"; import { describe, expect, it } from "vitest"; @@ -42,6 +43,15 @@ describe("self-host paywall thumbnail renderer", () => { Effect.provide( SelfhostSnapshotImageRendererLive.pipe( Layer.provide(screenshot), + Layer.provide( + Layer.succeed(PublicFileStore, { + publicBaseUrl: "https://files.test", + publicUrl: (key) => `https://files.test/files/${key}`, + putObject: () => Effect.void, + getObject: () => Effect.succeed(null), + deleteObject: () => Effect.void, + }), + ), ), ), ), From 7998bd39e78c8bc603fcae537acbe76142e23054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Wed, 22 Jul 2026 22:36:54 +0200 Subject: [PATCH 05/17] feat: remove assets and deployments from paywall --- .../paywalls/detail/paywall-detail-stats.tsx | 347 ++++++++++++++++++ .../paywalls/detail/paywall-preview-card.tsx | 62 ++++ .../features/studio/paywalls/paywall-card.tsx | 19 +- .../components/sidebar/project-sidebar.tsx | 26 -- .../skeleton/project-layout-skeleton.tsx | 10 +- apps/www/src/routeTree.gen.ts | 78 ++-- .../$projectSlug/assets.index.tsx | 55 --- .../$projectSlug/deploys.tsx | 86 ----- .../$projectSlug/paywalls.$id.tsx | 186 ++++++++++ 9 files changed, 637 insertions(+), 232 deletions(-) create mode 100644 apps/www/src/features/studio/paywalls/detail/paywall-detail-stats.tsx create mode 100644 apps/www/src/features/studio/paywalls/detail/paywall-preview-card.tsx delete mode 100644 apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/assets.index.tsx delete mode 100644 apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/deploys.tsx create mode 100644 apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.$id.tsx diff --git a/apps/www/src/features/studio/paywalls/detail/paywall-detail-stats.tsx b/apps/www/src/features/studio/paywalls/detail/paywall-detail-stats.tsx new file mode 100644 index 000000000..d30f63859 --- /dev/null +++ b/apps/www/src/features/studio/paywalls/detail/paywall-detail-stats.tsx @@ -0,0 +1,347 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import type { AnalyticsFilterType, CustomAnalyticsInsightQueryType } from "@voidhash/rpc"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, + Skeleton, + ToggleGroup, + ToggleGroupItem, +} from "@voidhash/ui"; +import { ChartSplineIcon, MapPinOffIcon } from "lucide-react"; +import { useState } from "react"; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { customAnalyticsInsightQueryOptions } from "@/features/studio/lib/tanstack-query/analytics"; + +// Conventional event names emitted by paywall compositions through the SDK +// bridge. Every bridge event is stamped with the `paywall_location` property +// of the location that served the paywall, which is what scopes these queries +// to a single paywall. +const PAYWALL_VIEWED_EVENT = "paywall_viewed"; +const PURCHASE_COMPLETED_EVENT = "purchase_completed"; + +const CONVERSION_WINDOW_SECONDS = 7 * 86_400; + +const RANGE_OPTIONS = [ + { label: "7D", value: "last_7d" }, + { label: "30D", value: "last_30d" }, + { label: "90D", value: "last_90d" }, +] as const; + +type StatsRange = (typeof RANGE_OPTIONS)[number]["value"]; + +const isStatsRange = (value: string): value is StatsRange => + RANGE_OPTIONS.some((option) => option.value === value); + +const locationFilter = (locationSlugs: string[]): AnalyticsFilterType => ({ + field: "event.properties.paywall_location", + op: "in", + type: "predicate", + value: locationSlugs, +}); + +const buildTotalsDefinition = ( + range: StatsRange, + locationSlugs: string[], +): CustomAnalyticsInsightQueryType => ({ + display: "number", + granularity: "day", + kind: "trends", + series: [ + { + aggregation: "total_events", + eventNames: [PAYWALL_VIEWED_EVENT], + filters: locationFilter(locationSlugs), + key: "views", + label: "Views", + }, + { + aggregation: "unique_users", + eventNames: [PAYWALL_VIEWED_EVENT], + filters: locationFilter(locationSlugs), + key: "viewers", + label: "Unique viewers", + }, + { + aggregation: "total_events", + eventNames: [PURCHASE_COMPLETED_EVENT], + filters: locationFilter(locationSlugs), + key: "purchases", + label: "Purchases", + }, + ], + timeRange: { preset: range }, +}); + +const buildTimeseriesDefinition = ( + range: StatsRange, + locationSlugs: string[], +): CustomAnalyticsInsightQueryType => ({ + display: "area", + granularity: "day", + kind: "trends", + series: [ + { + aggregation: "total_events", + eventNames: [PAYWALL_VIEWED_EVENT], + filters: locationFilter(locationSlugs), + key: "views", + label: "Views", + }, + { + aggregation: "total_events", + eventNames: [PURCHASE_COMPLETED_EVENT], + filters: locationFilter(locationSlugs), + key: "purchases", + label: "Purchases", + }, + ], + timeRange: { preset: range }, +}); + +// The purchase step is intentionally unfiltered: the funnel is sequential per +// user, so it measures "viewed this paywall, then purchased" even when the +// purchase event isn't stamped with the paywall location. +const buildFunnelDefinition = ( + range: StatsRange, + locationSlugs: string[], +): CustomAnalyticsInsightQueryType => ({ + conversionWindowSeconds: CONVERSION_WINDOW_SECONDS, + kind: "funnels", + order: "sequential", + steps: [ + { + eventNames: [PAYWALL_VIEWED_EVENT], + filters: locationFilter(locationSlugs), + key: "viewed", + label: "Viewed paywall", + }, + { + eventNames: [PURCHASE_COMPLETED_EVENT], + key: "purchased", + label: "Purchased", + }, + ], + timeRange: { preset: range }, +}); + +interface StatTileProps { + label: string; + loading: boolean; + value: string; +} + +function StatTile({ label, loading, value }: StatTileProps) { + return ( +
+

{label}

+ {loading ? ( + + ) : ( +

{value}

+ )} +
+ ); +} + +interface PaywallDetailStatsProps { + locationSlugs: string[]; + projectId: string; +} + +/** + * Performance section of the paywall detail page: KPI tiles (views, unique + * viewers, purchases, view→purchase conversion) plus a views/purchases area + * chart, scoped to the locations currently showing the paywall. + */ +export function PaywallDetailStats({ locationSlugs, projectId }: PaywallDetailStatsProps) { + const [range, setRange] = useState("last_30d"); + const isLive = locationSlugs.length > 0; + + const totalsQuery = useQuery({ + ...customAnalyticsInsightQueryOptions({ + definition: buildTotalsDefinition(range, locationSlugs), + projectId, + }), + enabled: isLive, + }); + const timeseriesQuery = useQuery({ + ...customAnalyticsInsightQueryOptions({ + definition: buildTimeseriesDefinition(range, locationSlugs), + projectId, + }), + enabled: isLive, + }); + const funnelQuery = useQuery({ + ...customAnalyticsInsightQueryOptions({ + definition: buildFunnelDefinition(range, locationSlugs), + projectId, + }), + enabled: isLive, + }); + + const totals = totalsQuery.data?.kind === "trends" ? totalsQuery.data : undefined; + const timeseries = timeseriesQuery.data?.kind === "trends" ? timeseriesQuery.data : undefined; + const funnel = funnelQuery.data?.kind === "funnels" ? funnelQuery.data : undefined; + + const totalFor = (key: string) => + totals?.series.find((series) => series.key === key)?.points[0]?.value ?? 0; + + const chartRows = (() => { + if (!timeseries) { + return []; + } + const rows = new Map(); + for (const series of timeseries.series) { + for (const point of series.points) { + const timestamp = point.timestamp.getTime(); + const row = rows.get(timestamp) ?? { + date: point.timestamp.toISOString(), + purchases: 0, + views: 0, + }; + if (series.key === "views") { + row.views = point.value; + } + if (series.key === "purchases") { + row.purchases = point.value; + } + rows.set(timestamp, row); + } + } + return [...rows.entries()].sort(([a], [b]) => a - b).map(([, row]) => row); + })(); + + const chartConfig = { + purchases: { color: "var(--chart-2)", label: "Purchases" }, + views: { color: "var(--chart-1)", label: "Views" }, + } satisfies ChartConfig; + + return ( +
+
+

Performance

+ { + if (value && isStatsRange(value)) { + setRange(value); + } + }} + type="single" + value={range} + variant="outline" + > + {RANGE_OPTIONS.map((option) => ( + + {option.label} + + ))} + +
+ + {isLive ? ( + <> +
+ + + + +
+ + + + Views & purchases + + Events captured at locations currently showing this paywall. + + + + {timeseriesQuery.isPending ? ( + + ) : chartRows.length > 0 ? ( + + + + + new Date(value).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + }) + } + tickLine={false} + tickMargin={10} + /> + + } cursor={false} /> + + + + + ) : ( +
+
+ +

No events in this period yet

+
+
+ )} +
+
+ + ) : ( + + +
+ +

Not live yet

+

+ Assign this paywall to a location to start collecting stats. +

+
+
+
+ )} +
+ ); +} diff --git a/apps/www/src/features/studio/paywalls/detail/paywall-preview-card.tsx b/apps/www/src/features/studio/paywalls/detail/paywall-preview-card.tsx new file mode 100644 index 000000000..7ad281e7b --- /dev/null +++ b/apps/www/src/features/studio/paywalls/detail/paywall-preview-card.tsx @@ -0,0 +1,62 @@ +"use client"; + +import type { Paywall } from "@voidhash/rpc"; +import { Badge, Button, Card, CardContent } from "@voidhash/ui"; +import { ExternalLinkIcon, Smartphone } from "lucide-react"; + +interface PaywallPreviewCardProps { + liveRelease: { htmlUrl: string; version: number } | null; + paywall: typeof Paywall.Type; +} + +/** + * Phone-frame preview of the paywall's latest rendered thumbnail, with a link + * to the live HTML artifact when a released version is currently being shown. + */ +export function PaywallPreviewCard({ liveRelease, paywall }: PaywallPreviewCardProps) { + return ( + +
+ {paywall.thumbnailUrl && ( + + )} + +
+
+ {paywall.thumbnailUrl ? ( + {paywall.name} + ) : ( + + )} +
+
+
+ + {liveRelease ? ( + <> + Live · v{liveRelease.version} + + + ) : ( + Not live at any location + )} + +
+ ); +} diff --git a/apps/www/src/features/studio/paywalls/paywall-card.tsx b/apps/www/src/features/studio/paywalls/paywall-card.tsx index 2c412ab76..d943bb948 100644 --- a/apps/www/src/features/studio/paywalls/paywall-card.tsx +++ b/apps/www/src/features/studio/paywalls/paywall-card.tsx @@ -26,6 +26,7 @@ import { MoreHorizontalIcon, PencilIcon, Smartphone, + SquarePenIcon, Trash2Icon, } from "lucide-react"; import { useState } from "react"; @@ -46,9 +47,10 @@ interface PaywallCardProps { } /** - * A single paywall tile in the dashboard grid: a link into the designer plus a - * kebab menu with Rename / Archive (or Restore) / Delete. Archived paywalls are - * dimmed and badged, and expose Restore in place of Archive. + * A single paywall tile in the dashboard grid: a link to the paywall detail + * page plus a kebab menu with Edit (straight into the designer) / Rename / + * Archive (or Restore) / Delete. Archived paywalls are dimmed and badged, and + * expose Restore in place of Archive. */ export function PaywallCard({ paywall, organizationSlug, projectSlug }: PaywallCardProps) { const queryClient = useQueryClient(); @@ -99,7 +101,7 @@ export function PaywallCard({ paywall, organizationSlug, projectSlug }: PaywallC isArchived ? "opacity-60" : "" }`} params={{ id: paywall.id, organizationSlug, projectSlug }} - to="/studio/$organizationSlug/$projectSlug/design/$id" + to="/studio/$organizationSlug/$projectSlug/paywalls/$id" > {/* Preview Area */}
@@ -158,6 +160,15 @@ export function PaywallCard({ paywall, organizationSlug, projectSlug }: PaywallC + + + + Edit + + setRenameOpen(true)}> Rename diff --git a/apps/www/src/features/studio/shell/components/sidebar/project-sidebar.tsx b/apps/www/src/features/studio/shell/components/sidebar/project-sidebar.tsx index 48bc93530..4b456eee0 100644 --- a/apps/www/src/features/studio/shell/components/sidebar/project-sidebar.tsx +++ b/apps/www/src/features/studio/shell/components/sidebar/project-sidebar.tsx @@ -8,11 +8,9 @@ import { FlaskConical, GaugeIcon, Gift, - ImagesIcon, Logs, MapPin, Package2, - Rocket, Settings, Smartphone, ToggleLeft, @@ -185,12 +183,6 @@ export function ProjectSidebar({ ) || pathname.startsWith( `/studio/${organizationSlug}/${projectSlug}/settings/paywall-locations`, - ) || - pathname.startsWith( - `/studio/${organizationSlug}/${projectSlug}/assets`, - ) || - pathname.startsWith( - `/studio/${organizationSlug}/${projectSlug}/deploys`, ), title: "Paywalls", url: `/studio/${organizationSlug}/${projectSlug}/paywalls`, @@ -204,15 +196,6 @@ export function ProjectSidebar({ title: "Paywalls", url: `/studio/${organizationSlug}/${projectSlug}/paywalls`, }, - { - icon: ImagesIcon, - isActive: () => - pathname.startsWith( - `/studio/${organizationSlug}/${projectSlug}/assets`, - ), - title: "Assets", - url: `/studio/${organizationSlug}/${projectSlug}/assets`, - }, { icon: MapPin, isActive: () => @@ -222,15 +205,6 @@ export function ProjectSidebar({ title: "Paywall Locations", url: `/studio/${organizationSlug}/${projectSlug}/settings/paywall-locations`, }, - { - icon: Rocket, - isActive: () => - pathname.startsWith( - `/studio/${organizationSlug}/${projectSlug}/deploys`, - ), - title: "Deploys", - url: `/studio/${organizationSlug}/${projectSlug}/deploys`, - }, ], }, // The Experimentation suite (Feature Flags + A/B Tests) is gated behind diff --git a/apps/www/src/features/studio/shell/components/skeleton/project-layout-skeleton.tsx b/apps/www/src/features/studio/shell/components/skeleton/project-layout-skeleton.tsx index d1730ff15..122082db2 100644 --- a/apps/www/src/features/studio/shell/components/skeleton/project-layout-skeleton.tsx +++ b/apps/www/src/features/studio/shell/components/skeleton/project-layout-skeleton.tsx @@ -22,7 +22,6 @@ import { type LucideIcon, MapPin, Package2, - Rocket, Settings, Smartphone, Users, @@ -121,8 +120,7 @@ export function ProjectLayoutSkeleton({ icon: Smartphone, isActive: () => pathname.startsWith(`${base}/paywalls`) || - pathname.startsWith(`${base}/settings/paywall-locations`) || - pathname.startsWith(`${base}/deploys`), + pathname.startsWith(`${base}/settings/paywall-locations`), title: "Paywalls", url: `${base}/paywalls`, items: [ @@ -139,12 +137,6 @@ export function ProjectLayoutSkeleton({ title: "Paywall Locations", url: `${base}/settings/paywall-locations`, }, - { - icon: Rocket, - isActive: () => pathname.startsWith(`${base}/deploys`), - title: "Deploys", - url: `${base}/deploys`, - }, ], }, { diff --git a/apps/www/src/routeTree.gen.ts b/apps/www/src/routeTree.gen.ts index 3615fd862..bbcab5676 100644 --- a/apps/www/src/routeTree.gen.ts +++ b/apps/www/src/routeTree.gen.ts @@ -49,14 +49,12 @@ import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlug import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/index' import { Route as StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRouteImport } from './routes/studio/_authenticated/_designer/$organizationSlug.$projectSlug.design.$id' import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/overview' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugDeploysRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/deploys' import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/index' import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/products.index' import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons.index' import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.index' import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags.index' import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments.index' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAssetsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/assets.index' import { Route as StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_organization/$organizationSlug/~/settings.index' import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/perks' import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations' @@ -64,6 +62,7 @@ import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlug import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/products.$id' import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons.$id' import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags.$id' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.$id' import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments.$id' import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.trials' import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.subscribers' @@ -306,15 +305,6 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRout StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugDeploysRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugDeploysRouteImport.update( - { - id: '/deploys', - path: '/deploys', - getParentRoute: () => - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, - } as any, - ) const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsIndexRoute = StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsIndexRouteImport.update( { @@ -369,15 +359,6 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsI StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAssetsIndexRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAssetsIndexRouteImport.update( - { - id: '/assets/', - path: '/assets/', - getParentRoute: () => - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, - } as any, - ) const StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRoute = StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRouteImport.update( { @@ -450,6 +431,15 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsI StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRouteImport.update( + { + id: '/paywalls/$id', + path: '/paywalls/$id', + getParentRoute: () => + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, + } as any, + ) const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute = StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRouteImport.update( { @@ -630,7 +620,6 @@ export interface FileRoutesByFullPath { '/studio/$organizationSlug': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugRouteRouteWithChildren '/studio/$organizationSlug/$projectSlug': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteWithChildren '/studio/$organizationSlug/': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugIndexRoute - '/studio/$organizationSlug/$projectSlug/deploys': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugDeploysRoute '/studio/$organizationSlug/$projectSlug/overview': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRoute '/studio/$organizationSlug/$projectSlug/design/$id': typeof StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRoute '/studio/$organizationSlug/$projectSlug/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRoute @@ -644,6 +633,7 @@ export interface FileRoutesByFullPath { '/studio/$organizationSlug/$projectSlug/analytics/subscribers': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRoute '/studio/$organizationSlug/$projectSlug/analytics/trials': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute '/studio/$organizationSlug/$projectSlug/experiments/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute + '/studio/$organizationSlug/$projectSlug/paywalls/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute '/studio/$organizationSlug/$projectSlug/flags/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute '/studio/$organizationSlug/$projectSlug/persons/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRoute '/studio/$organizationSlug/$projectSlug/products/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRoute @@ -651,7 +641,6 @@ export interface FileRoutesByFullPath { '/studio/$organizationSlug/$projectSlug/settings/paywall-locations': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsRoute '/studio/$organizationSlug/$projectSlug/settings/perks': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRoute '/studio/$organizationSlug/~/settings/': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRoute - '/studio/$organizationSlug/$projectSlug/assets/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAssetsIndexRoute '/studio/$organizationSlug/$projectSlug/experiments/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute '/studio/$organizationSlug/$projectSlug/flags/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRoute '/studio/$organizationSlug/$projectSlug/paywalls/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRoute @@ -695,7 +684,6 @@ export interface FileRoutesByTo { '/api/auth/password/sign-up': typeof ApiAuthPasswordSignUpRoute '/studio/create-organization': typeof StudioAuthenticatedCreateOrganizationIndexRoute '/studio/$organizationSlug': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugIndexRoute - '/studio/$organizationSlug/$projectSlug/deploys': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugDeploysRoute '/studio/$organizationSlug/$projectSlug/overview': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRoute '/studio/$organizationSlug/$projectSlug/design/$id': typeof StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRoute '/studio/$organizationSlug/$projectSlug': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRoute @@ -709,6 +697,7 @@ export interface FileRoutesByTo { '/studio/$organizationSlug/$projectSlug/analytics/subscribers': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRoute '/studio/$organizationSlug/$projectSlug/analytics/trials': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute '/studio/$organizationSlug/$projectSlug/experiments/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute + '/studio/$organizationSlug/$projectSlug/paywalls/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute '/studio/$organizationSlug/$projectSlug/flags/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute '/studio/$organizationSlug/$projectSlug/persons/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRoute '/studio/$organizationSlug/$projectSlug/products/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRoute @@ -716,7 +705,6 @@ export interface FileRoutesByTo { '/studio/$organizationSlug/$projectSlug/settings/paywall-locations': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsRoute '/studio/$organizationSlug/$projectSlug/settings/perks': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRoute '/studio/$organizationSlug/~/settings': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRoute - '/studio/$organizationSlug/$projectSlug/assets': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAssetsIndexRoute '/studio/$organizationSlug/$projectSlug/experiments': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute '/studio/$organizationSlug/$projectSlug/flags': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRoute '/studio/$organizationSlug/$projectSlug/paywalls': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRoute @@ -770,7 +758,6 @@ export interface FileRoutesById { '/studio/_authenticated/_dashboard/_organization/$organizationSlug': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugRouteRouteWithChildren '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteWithChildren '/studio/_authenticated/_dashboard/_organization/$organizationSlug/': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugIndexRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/deploys': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugDeploysRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/overview': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRoute '/studio/_authenticated/_designer/$organizationSlug/$projectSlug/design/$id': typeof StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRoute @@ -784,6 +771,7 @@ export interface FileRoutesById { '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/subscribers': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/trials': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/products/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRoute @@ -791,7 +779,6 @@ export interface FileRoutesById { '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/perks': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRoute '/studio/_authenticated/_dashboard/_organization/$organizationSlug/~/settings/': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/assets/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAssetsIndexRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRoute @@ -843,7 +830,6 @@ export interface FileRouteTypes { | '/studio/$organizationSlug' | '/studio/$organizationSlug/$projectSlug' | '/studio/$organizationSlug/' - | '/studio/$organizationSlug/$projectSlug/deploys' | '/studio/$organizationSlug/$projectSlug/overview' | '/studio/$organizationSlug/$projectSlug/design/$id' | '/studio/$organizationSlug/$projectSlug/' @@ -857,6 +843,7 @@ export interface FileRouteTypes { | '/studio/$organizationSlug/$projectSlug/analytics/subscribers' | '/studio/$organizationSlug/$projectSlug/analytics/trials' | '/studio/$organizationSlug/$projectSlug/experiments/$id' + | '/studio/$organizationSlug/$projectSlug/paywalls/$id' | '/studio/$organizationSlug/$projectSlug/flags/$id' | '/studio/$organizationSlug/$projectSlug/persons/$id' | '/studio/$organizationSlug/$projectSlug/products/$id' @@ -864,7 +851,6 @@ export interface FileRouteTypes { | '/studio/$organizationSlug/$projectSlug/settings/paywall-locations' | '/studio/$organizationSlug/$projectSlug/settings/perks' | '/studio/$organizationSlug/~/settings/' - | '/studio/$organizationSlug/$projectSlug/assets/' | '/studio/$organizationSlug/$projectSlug/experiments/' | '/studio/$organizationSlug/$projectSlug/flags/' | '/studio/$organizationSlug/$projectSlug/paywalls/' @@ -908,7 +894,6 @@ export interface FileRouteTypes { | '/api/auth/password/sign-up' | '/studio/create-organization' | '/studio/$organizationSlug' - | '/studio/$organizationSlug/$projectSlug/deploys' | '/studio/$organizationSlug/$projectSlug/overview' | '/studio/$organizationSlug/$projectSlug/design/$id' | '/studio/$organizationSlug/$projectSlug' @@ -922,6 +907,7 @@ export interface FileRouteTypes { | '/studio/$organizationSlug/$projectSlug/analytics/subscribers' | '/studio/$organizationSlug/$projectSlug/analytics/trials' | '/studio/$organizationSlug/$projectSlug/experiments/$id' + | '/studio/$organizationSlug/$projectSlug/paywalls/$id' | '/studio/$organizationSlug/$projectSlug/flags/$id' | '/studio/$organizationSlug/$projectSlug/persons/$id' | '/studio/$organizationSlug/$projectSlug/products/$id' @@ -929,7 +915,6 @@ export interface FileRouteTypes { | '/studio/$organizationSlug/$projectSlug/settings/paywall-locations' | '/studio/$organizationSlug/$projectSlug/settings/perks' | '/studio/$organizationSlug/~/settings' - | '/studio/$organizationSlug/$projectSlug/assets' | '/studio/$organizationSlug/$projectSlug/experiments' | '/studio/$organizationSlug/$projectSlug/flags' | '/studio/$organizationSlug/$projectSlug/paywalls' @@ -982,7 +967,6 @@ export interface FileRouteTypes { | '/studio/_authenticated/_dashboard/_organization/$organizationSlug' | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug' | '/studio/_authenticated/_dashboard/_organization/$organizationSlug/' - | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/deploys' | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/overview' | '/studio/_authenticated/_designer/$organizationSlug/$projectSlug/design/$id' | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/' @@ -996,6 +980,7 @@ export interface FileRouteTypes { | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/subscribers' | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/trials' | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments/$id' + | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/$id' | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags/$id' | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons/$id' | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/products/$id' @@ -1003,7 +988,6 @@ export interface FileRouteTypes { | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations' | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/perks' | '/studio/_authenticated/_dashboard/_organization/$organizationSlug/~/settings/' - | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/assets/' | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments/' | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags/' | '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/' @@ -1318,13 +1302,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/deploys': { - id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/deploys' - path: '/deploys' - fullPath: '/studio/$organizationSlug/$projectSlug/deploys' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugDeploysRouteImport - parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute - } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/' path: '/settings' @@ -1367,13 +1344,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/assets/': { - id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/assets/' - path: '/assets' - fullPath: '/studio/$organizationSlug/$projectSlug/assets/' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAssetsIndexRouteImport - parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute - } '/studio/_authenticated/_dashboard/_organization/$organizationSlug/~/settings/': { id: '/studio/_authenticated/_dashboard/_organization/$organizationSlug/~/settings/' path: '/~/settings' @@ -1430,6 +1400,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/$id': { + id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/$id' + path: '/paywalls/$id' + fullPath: '/studio/$organizationSlug/$projectSlug/paywalls/$id' + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRouteImport + parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute + } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/trials': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/trials' path: '/analytics/trials' @@ -1636,7 +1613,6 @@ const StudioAuthenticatedDashboardOrganizationOrganizationSlugRouteRouteWithChil ) interface StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteChildren { - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugDeploysRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugDeploysRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityEventsRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityEventsRoute @@ -1649,13 +1625,13 @@ interface StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRou StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsApiKeysRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsApiKeysRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAssetsIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAssetsIndexRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRoute @@ -1673,8 +1649,6 @@ interface StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRou const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteChildren: StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteChildren = { - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugDeploysRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugDeploysRoute, StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRoute: StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRoute, StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRoute: @@ -1699,6 +1673,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteCh StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute, StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute: StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute, StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute: StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute, StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRoute: @@ -1711,8 +1687,6 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteCh StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsRoute, StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRoute: StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAssetsIndexRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAssetsIndexRoute, StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute: StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute, StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRoute: diff --git a/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/assets.index.tsx b/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/assets.index.tsx deleted file mode 100644 index 2c81ee5e9..000000000 --- a/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/assets.index.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { Page, PageHeader, PageHeaderTitle } from "@voidhash/ui"; -import { useAuth } from "@/features/studio/components/auth-context"; -import { CurrentUser } from "@/features/studio/lib/utils/current-user"; -import { AssetLibrary } from "@/features/studio/paywall-assets/asset-library"; -import { VoidhashErrorCard } from "@/features/studio/shell/components/voidhash-error-card"; - -export const Route = createFileRoute( - "/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/assets/", -)({ - component: AssetsIndexPage, - errorComponent: AssetsIndexPageError, -}); - -function AssetsIndexPageError() { - return ( - - ); -} - -/** - * Organization-scoped image asset library, rendered full-page. Assets live at - * the organization level but are surfaced under the project's Paywalls group, - * since they are consumed as paywall backgrounds; the org id is resolved from - * the current project. - */ -function AssetsIndexPage() { - const { organizationSlug, projectSlug } = Route.useParams(); - const { user } = useAuth(); - const project = CurrentUser.getProjectBySlugs( - user, - organizationSlug as string, - projectSlug as string, - ); - - if (!project) { - throw new Error("Project not found"); - } - - return ( - - - Assets - -
- -
-
- ); -} diff --git a/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/deploys.tsx b/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/deploys.tsx deleted file mode 100644 index a1de5b096..000000000 --- a/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/deploys.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; -import { Page, PageHeader, PageHeaderTitle } from "@voidhash/ui"; -import { useAuth } from "@/features/studio/components/auth-context"; - -import { listPaywallDeploysOptions } from "@/features/studio/lib/tanstack-query"; -import { CurrentUser } from "@/features/studio/lib/utils/current-user"; -import { PaywallDeployRecord } from "@/features/studio/paywall-deploys/paywall-deploy-record"; -import { SettingsCard } from "@/features/studio/settings"; -import { VoidhashErrorCard } from "@/features/studio/shell/components/voidhash-error-card"; - -export const Route = createFileRoute( - "/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/deploys", -)({ - component: ProjectDeploysPage, - errorComponent: ProjectDeploysPageError, - pendingComponent: ProjectDeploysPageSkeleton, -}); - -function ProjectDeploysPageError() { - return ( - - ); -} - -function ProjectDeploysPageSkeleton() { - return ( - - - Deploys - -
- -
- Loading deploys... -
-
-
-
- ); -} - -function ProjectDeploysPage() { - const { organizationSlug, projectSlug } = Route.useParams(); - const { user } = useAuth(); - const project = CurrentUser.getProjectBySlugs( - user, - organizationSlug as string, - projectSlug as string, - ); - - if (!project) { - throw new Error("Project not found"); - } - - const { data: deploys } = useSuspenseQuery(listPaywallDeploysOptions({ projectId: project.id })); - - return ( - - - Deploys - -
- {deploys.length === 0 ? ( - -
- No code deploys yet. Run voidhash deploy from your - project to push paywalls built in code. -
-
- ) : ( - - {deploys.map((deploy) => ( - - ))} - - )} -
-
- ); -} diff --git a/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.$id.tsx b/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.$id.tsx new file mode 100644 index 000000000..1c2d0c455 --- /dev/null +++ b/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.$id.tsx @@ -0,0 +1,186 @@ +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { + Badge, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Page, + PageHeader, +} from "@voidhash/ui"; +import { useAuth } from "@/features/studio/components/auth-context"; + +import { PaywallDetailStats } from "@/features/studio/paywalls/detail/paywall-detail-stats"; +import { PaywallPreviewCard } from "@/features/studio/paywalls/detail/paywall-preview-card"; +import { VoidhashErrorCard } from "@/features/studio/shell/components/voidhash-error-card"; +import { listPaywallLocationsOptions } from "@/features/studio/lib/tanstack-query/paywall-locations"; +import { + getPaywallDraftReleaseOptions, + listPaywallsOptions, +} from "@/features/studio/lib/tanstack-query/paywalls"; +import { CurrentUser } from "@/features/studio/lib/utils/current-user"; + +export const Route = createFileRoute( + "/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/$id", +)({ + component: PaywallDetailPage, + errorComponent: PaywallDetailPageError, +}); + +function PaywallDetailPageError() { + return ( + + ); +} + +function PaywallDetailPage() { + const { id, organizationSlug, projectSlug } = Route.useParams(); + const { user } = useAuth(); + const project = CurrentUser.getProjectBySlugs( + user, + organizationSlug as string, + projectSlug as string, + ); + + if (!project) { + throw new Error("Project not found"); + } + + const { data: paywalls } = useSuspenseQuery( + listPaywallsOptions({ includeArchived: true, projectId: project.id }), + ); + const { data: locations } = useSuspenseQuery( + listPaywallLocationsOptions({ projectId: project.id }), + ); + const { data: draftRelease } = useQuery( + getPaywallDraftReleaseOptions({ paywallId: id as string }), + ); + + const paywall = paywalls.find((item) => item.id === (id as string)); + + if (!paywall) { + return ( + + ); + } + + const isArchived = paywall.archivedAt != null; + const liveLocations = locations.filter( + (location) => location.activeShowing?.paywallId === paywall.id, + ); + const liveRelease = liveLocations.reduce<{ htmlUrl: string; version: number } | null>( + (latest, location) => { + const release = location.activeShowing?.paywallRelease; + if (!release) { + return latest; + } + return latest && latest.version >= release.version + ? latest + : { htmlUrl: release.htmlUrl, version: release.version }; + }, + null, + ); + + return ( + + + + Edit paywall + + + } + > +
+ + + + + + Paywalls + + + + + + {paywall.name} + + + + {isArchived && Archived} +
+
+ +
+
+
+ location.slug)} + projectId={project.id} + /> +
+
+ + + + Details + + +
+

Slug

+

{paywall.slug}

+
+
+

Live version

+

{liveRelease ? `v${liveRelease.version}` : "Not live"}

+
+
+

Latest draft

+

{draftRelease ? `v${draftRelease.version}` : "—"}

+
+
+

Locations

+ {liveLocations.length > 0 ? ( +
    + {liveLocations.map((location) => ( +
  • + {location.name}{" "} + + {location.slug} + +
  • + ))} +
+ ) : ( +

None

+ )} +
+
+
+
+
+
+
+ ); +} From 8f399e2accd85fd1ae5d2abe8ff7ce628142c5b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Thu, 23 Jul 2026 14:09:46 +0200 Subject: [PATCH 06/17] feat: styling improvements + component canvas --- apps/www/THIRD_PARTY_NOTICES.md | 26 ++ apps/www/package.json | 1 + .../components/docs/component-overview.tsx | 14 + .../cards/account-access.tsx | 86 ++++++ .../cards/card-overview.tsx | 77 +++++ .../cards/claimable-balance.tsx | 51 ++++ .../cards/contribution-history.tsx | 92 ++++++ .../component-overview/cards/cover-art.tsx | 48 +++ .../cards/dividend-income.tsx | 123 ++++++++ .../cards/empty-connect-bank.tsx | 40 +++ .../cards/empty-distribute-track.tsx | 41 +++ .../cards/empty-explore-catalog.tsx | 40 +++ .../docs/component-overview/cards/faq.tsx | 103 +++++++ .../component-overview/cards/front-door.tsx | 41 +++ .../cards/index-investing.tsx | 22 ++ .../cards/kitchen-island.tsx | 162 ++++++++++ .../component-overview/cards/loading-card.tsx | 25 ++ .../cards/new-milestone.tsx | 50 +++ .../cards/notification-settings.tsx | 98 ++++++ .../component-overview/cards/payments.tsx | 169 +++++++++++ .../cards/payout-threshold.tsx | 101 +++++++ .../component-overview/cards/power-usage.tsx | 81 +++++ .../component-overview/cards/preferences.tsx | 92 ++++++ .../cards/project-actions.tsx | 96 ++++++ .../component-overview/cards/qr-connect.tsx | 36 +++ .../cards/receiving-method.tsx | 90 ++++++ .../cards/recent-transactions.tsx | 276 +++++++++++++++++ .../cards/release-catalog.tsx | 99 ++++++ .../cards/roller-shades.tsx | 70 +++++ .../cards/savings-progress.tsx | 93 ++++++ .../cards/savings-targets.tsx | 116 +++++++ .../component-overview/cards/sidebar-nav.tsx | 285 ++++++++++++++++++ .../component-overview/cards/social-links.tsx | 85 ++++++ .../cards/stock-performance.tsx | 119 ++++++++ .../cards/syncing-state.tsx | 34 +++ .../cards/transfer-funds.tsx | 107 +++++++ .../cards/upcoming-payments.tsx | 54 ++++ .../component-overview/icon-placeholder.tsx | 31 ++ .../docs/component-overview/index.tsx | 100 ++++++ .../docs/component-overview/preview-config.ts | 10 + .../components/docs/component-registry.tsx | 1 + .../design/components/layout/page.tsx | 10 +- .../design/content/docs/components/meta.yaml | 1 + .../content/docs/components/overview.mdx | 7 + .../designer/components/ui/mode-toggle.tsx | 2 +- .../paywalls/designer/panels/action-panel.tsx | 4 +- .../studio/paywalls/paywall-actions-menu.tsx | 185 ++++++++++++ .../features/studio/paywalls/paywall-card.tsx | 163 +--------- .../studio/paywalls/paywall-table.tsx | 109 +++++++ .../studio/paywalls/paywall-view-settings.tsx | 79 +++++ apps/www/src/routeTree.gen.ts | 52 ++-- apps/www/src/routes/design/$.tsx | 2 +- apps/www/src/routes/design/index.tsx | 2 +- .../$projectSlug/paywalls.index.tsx | 20 +- packages/ui/components/page.tsx | 4 +- packages/ui/components/theme-toggle.tsx | 19 +- packages/ui/components/ui/breadcrumb.tsx | 2 +- packages/ui/components/ui/button.tsx | 9 +- packages/ui/components/ui/card.tsx | 4 +- packages/ui/components/ui/chart.tsx | 10 +- packages/ui/components/ui/empty.tsx | 100 ++++++ packages/ui/components/ui/item.tsx | 182 +++++++++++ packages/ui/components/ui/native-select.tsx | 59 ++++ packages/ui/components/ui/select.tsx | 21 +- packages/ui/components/ui/slider.tsx | 2 +- packages/ui/components/ui/spinner.tsx | 17 ++ packages/ui/components/ui/switch.tsx | 2 +- packages/ui/components/ui/tabs.tsx | 93 +++++- packages/ui/components/ui/toggle-group.tsx | 81 +---- packages/ui/index.ts | 5 + packages/ui/package.json | 5 + pnpm-lock.yaml | 37 ++- 72 files changed, 4266 insertions(+), 307 deletions(-) create mode 100644 apps/www/THIRD_PARTY_NOTICES.md create mode 100644 apps/www/src/features/design/components/docs/component-overview.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/account-access.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/card-overview.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/claimable-balance.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/contribution-history.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/cover-art.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/dividend-income.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/empty-connect-bank.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/empty-distribute-track.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/empty-explore-catalog.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/front-door.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/index-investing.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/kitchen-island.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/loading-card.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/new-milestone.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/notification-settings.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/payments.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/payout-threshold.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/power-usage.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/preferences.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/project-actions.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/qr-connect.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/receiving-method.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/recent-transactions.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/release-catalog.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/roller-shades.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/savings-progress.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/savings-targets.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/sidebar-nav.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/social-links.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/stock-performance.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/syncing-state.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/transfer-funds.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/upcoming-payments.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/icon-placeholder.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/index.tsx create mode 100644 apps/www/src/features/design/components/docs/component-overview/preview-config.ts create mode 100644 apps/www/src/features/design/content/docs/components/overview.mdx create mode 100644 apps/www/src/features/studio/paywalls/paywall-actions-menu.tsx create mode 100644 apps/www/src/features/studio/paywalls/paywall-table.tsx create mode 100644 apps/www/src/features/studio/paywalls/paywall-view-settings.tsx create mode 100644 packages/ui/components/ui/empty.tsx create mode 100644 packages/ui/components/ui/item.tsx create mode 100644 packages/ui/components/ui/native-select.tsx create mode 100644 packages/ui/components/ui/spinner.tsx diff --git a/apps/www/THIRD_PARTY_NOTICES.md b/apps/www/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..900a2e588 --- /dev/null +++ b/apps/www/THIRD_PARTY_NOTICES.md @@ -0,0 +1,26 @@ +# Third-party notices + +`src/features/design/components/docs/component-overview/` adapts the +`preview-02` component composition from shadcn/ui. + +MIT License + +Copyright (c) 2023 shadcn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/www/package.json b/apps/www/package.json index 0fe1b9f24..d768653f0 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -90,6 +90,7 @@ "react-dom": "catalog:", "react-easy-crop": "^6.0.2", "react-hook-form": "catalog:", + "react-qr-code": "^2.2.0", "recharts": "^2.15.0", "resend": "^4.5.1", "server-only": "^0.0.1", diff --git a/apps/www/src/features/design/components/docs/component-overview.tsx b/apps/www/src/features/design/components/docs/component-overview.tsx new file mode 100644 index 000000000..f2488d0cc --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview.tsx @@ -0,0 +1,14 @@ +"use client"; + +import Preview02Example from "./component-overview/index"; + +/** + * Renders shadcn's preview-02 composition using the Voidhash UI primitives. + */ +export function ComponentOverview() { + return ( +
+ +
+ ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/account-access.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/account-access.tsx new file mode 100644 index 000000000..24981f391 --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/account-access.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Button } from "@voidhash/ui"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@voidhash/ui"; +import { Field, FieldGroup, FieldLabel } from "@voidhash/ui"; +import { Input } from "@voidhash/ui"; +import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@voidhash/ui"; +import { IconPlaceholder } from "../icon-placeholder"; + +export function AccountAccess() { + return ( + + + Account Access + Update your credentials or re-authenticate. + + + + + Email Address + + + +
+ Current Password + + Forgot? + +
+ +
+
+
+ + + + + + + + + Danger Zone + + Archive account and remove catalog + + + + + + +
+ ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/card-overview.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/card-overview.tsx new file mode 100644 index 000000000..74c81fe07 --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/card-overview.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { Bar, BarChart, XAxis } from "recharts"; + +import { Badge } from "@voidhash/ui"; +import { Button } from "@voidhash/ui"; +import { Card, CardContent, CardDescription, CardTitle } from "@voidhash/ui"; +import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@voidhash/ui"; + +const activityData = [ + { month: "Jan", amount: 40 }, + { month: "Feb", amount: 55 }, + { month: "Mar", amount: 35 }, + { month: "Apr", amount: 60 }, + { month: "May", amount: 45 }, + { month: "Jun", amount: 50 }, + { month: "Jul", amount: 65 }, + { month: "Aug", amount: 40 }, + { month: "Sep", amount: 55 }, + { month: "Oct", amount: 70 }, + { month: "Nov", amount: 45 }, + { month: "Dec", amount: 80 }, +]; + +const chartConfig = { + amount: { + label: "Activity", + color: "var(--chart-2)", + }, +} satisfies ChartConfig; + +export function CardOverview() { + return ( +
+ + + Card Balance + US$12.94 + US$11,337.06 Available + + + + +
+ Payment Due + 1 Apr +
+ +
+
+ + +
+ Yearly Activity + +US$0.25 Daily Cash +
+ + + String(v).slice(0, 1)} + className="text-[10px]" + /> + } /> + + + +
+
+
+ ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/claimable-balance.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/claimable-balance.tsx new file mode 100644 index 000000000..1c7e3f98f --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/claimable-balance.tsx @@ -0,0 +1,51 @@ +import { Badge } from "@voidhash/ui"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@voidhash/ui"; +import { Item, ItemContent } from "@voidhash/ui"; +import { Separator } from "@voidhash/ui"; + +export function ClaimableBalance() { + return ( + + + Claimable Balance + $0.00 + + + Pending Setup + + + + + +
+ Net Royalties + $0.00 +
+
+ Processing Fee + -$0.00 +
+ +
+ Total Ready to Claim + $0.00 USD +
+
+
+
+ + + Once your bank is connected, balances over $10.00 are automatically eligible for monthly + distribution on the 15th of each month. + + +
+ ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/contribution-history.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/contribution-history.tsx new file mode 100644 index 000000000..120fb2ecf --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/contribution-history.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { Bar, BarChart, XAxis } from "recharts"; + +import { Button } from "@voidhash/ui"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@voidhash/ui"; +import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@voidhash/ui"; +import { Item, ItemContent, ItemDescription } from "@voidhash/ui"; +import { useDesignSystemSearchParams } from "../preview-config"; + +const chartData = [ + { month: "Dec", amount: 800 }, + { month: "Jan", amount: 1100 }, + { month: "Feb", amount: 900 }, + { month: "Mar", amount: 1300 }, + { month: "Apr", amount: 750 }, + { month: "May", amount: 1400 }, +]; + +const chartConfig = { + amount: { + label: "Contribution", + color: "var(--chart-2)", + }, +} satisfies ChartConfig; + +export function ContributionHistory() { + const [params] = useDesignSystemSearchParams(); + const isRounded = !["lyra", "sera"].includes(params.style); + + return ( + + + Contribution History + Last 6 months of activity + + + + + + } + /> + + + + + +
+ + + + Upcoming + + May 25, 2024 + $1,000 scheduled + + + + + + Auto-Save Plan + + Accelerated + Recurring weekly + + +
+
+ + + +
+ ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/cover-art.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/cover-art.tsx new file mode 100644 index 000000000..7701ac31b --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/cover-art.tsx @@ -0,0 +1,48 @@ +import { Button } from "@voidhash/ui"; +import { Card, CardContent, CardDescription, CardFooter } from "@voidhash/ui"; +import { Item } from "@voidhash/ui"; +import { Label } from "@voidhash/ui"; +import { IconPlaceholder } from "../icon-placeholder"; + +export function CoverArt() { + return ( + + + + + + + + + + + + Minimum 3000 × 3000px +
+ JPEG or PNG only +
+
+
+ ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/dividend-income.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/dividend-income.tsx new file mode 100644 index 000000000..d1c67686c --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/dividend-income.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { Bar, BarChart } from "recharts"; + +import { Button } from "@voidhash/ui"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@voidhash/ui"; +import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@voidhash/ui"; +import { Item, ItemContent, ItemDescription, ItemGroup, ItemTitle } from "@voidhash/ui"; +import { useDesignSystemSearchParams } from "../preview-config"; +import { IconPlaceholder } from "../icon-placeholder"; + +const HOLDINGS = [ + { + name: "Vanguard VIG", + shares: "450 Shares", + amount: "$1,842.10", + data: [ + { q: "Q1", value: 380 }, + { q: "Q2", value: 420 }, + { q: "Q3", value: 390 }, + { q: "Q4", value: 652 }, + ], + }, + { + name: "S&P 500 VOO", + shares: "112 Shares", + amount: "$928.40", + data: [ + { q: "Q1", value: 180 }, + { q: "Q2", value: 210 }, + { q: "Q3", value: 320 }, + { q: "Q4", value: 218 }, + ], + }, + { + name: "Apple AAPL", + shares: "85 Shares", + amount: "$340.00", + data: [ + { q: "Q1", value: 60 }, + { q: "Q2", value: 70 }, + { q: "Q3", value: 120 }, + { q: "Q4", value: 90 }, + ], + }, + { + name: "Realty Income", + shares: "320 Shares", + amount: "$1,139.50", + data: [ + { q: "Q1", value: 240 }, + { q: "Q2", value: 260 }, + { q: "Q3", value: 280 }, + { q: "Q4", value: 360 }, + ], + }, +]; + +const miniChartConfig = { + value: { + label: "Dividend", + color: "var(--chart-2)", + }, +} satisfies ChartConfig; + +export function DividendIncome() { + const [params] = useDesignSystemSearchParams(); + const isRounded = !["lyra", "sera"].includes(params.style); + + return ( + + + Q2 Dividend Income + + Quarterly dividend payouts across your portfolio holdings. + + + + + + + + {HOLDINGS.map((holding) => ( + + + {holding.name} + {holding.shares} + + + + } /> + + + + + {holding.amount} + + + ))} + + + + ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/empty-connect-bank.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/empty-connect-bank.tsx new file mode 100644 index 000000000..9f37a47ed --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/empty-connect-bank.tsx @@ -0,0 +1,40 @@ +import { Button } from "@voidhash/ui"; +import { Card, CardContent } from "@voidhash/ui"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@voidhash/ui"; +import { IconPlaceholder } from "../icon-placeholder"; + +export function EmptyConnectBank() { + return ( + + + + + + + + Connect Bank + + Link your payout method to receive monthly royalty distributions automatically. + + + + + + + + + ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/empty-distribute-track.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/empty-distribute-track.tsx new file mode 100644 index 000000000..7f79c0ed0 --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/empty-distribute-track.tsx @@ -0,0 +1,41 @@ +import { Button } from "@voidhash/ui"; +import { Card, CardContent } from "@voidhash/ui"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@voidhash/ui"; +import { IconPlaceholder } from "../icon-placeholder"; + +export function EmptyDistributeTrack() { + return ( + + + + + + + + Distribute Track + + Upload your first master to start reaching listeners on Spotify, Apple Music, and + more. + + + + + + + + + ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/empty-explore-catalog.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/empty-explore-catalog.tsx new file mode 100644 index 000000000..7411baae8 --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/empty-explore-catalog.tsx @@ -0,0 +1,40 @@ +import { Button } from "@voidhash/ui"; +import { Card, CardContent } from "@voidhash/ui"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@voidhash/ui"; +import { IconPlaceholder } from "../icon-placeholder"; + +export function EmptyExploreCatalog() { + return ( + + + + + + + + Explore Catalog + + Check your ISRC codes, metadata, and visual assets before going live. + + + + + + + + + ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx new file mode 100644 index 000000000..6819b81a6 --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@voidhash/ui"; +import { Button } from "@voidhash/ui"; +import { Card, CardContent, CardFooter } from "@voidhash/ui"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@voidhash/ui"; + +const GENERAL_QUESTIONS = [ + { + q: "How secure is my financial data with Ledger?", + a: "We use bank-level AES-256 encryption, SOC 2 Type II certified infrastructure, and never store your credentials. All connections use read-only access tokens. We are a SEC registered investment advisor.", + }, + { + q: "How do I connect my bank or investment accounts?", + a: "Go to Settings > Linked Accounts and search for your institution. We support over 12,000 banks and brokerages via Plaid and MX.", + }, + { + q: "Can I export my data for tax purposes?", + a: "Yes. Navigate to Reports > Tax Export to download a CSV or PDF summary of your transactions, dividends, and capital gains for any tax year.", + }, +]; + +const BILLING_QUESTIONS = [ + { + q: "What is the difference between Basic and Pro pricing tiers?", + a: "Basic includes budgeting, goal tracking, and up to 3 linked accounts. Pro adds unlimited accounts, dividend tracking, portfolio analysis, and priority support.", + }, + { + q: "How do I cancel my subscription?", + a: "Go to Settings > Billing > Manage Plan and click Cancel. Your access continues until the end of your current billing period.", + }, + { + q: "Do you offer a free trial?", + a: "Yes. All new accounts start with a 14-day Pro trial. No credit card required.", + }, +]; + +const GOALS_QUESTIONS = [ + { + q: "How do I set up a custom financial goal?", + a: "Click New Goal from the Savings Targets card. Choose a category, set a target amount and date, and we'll calculate the monthly contribution needed.", + }, + { + q: "Can I track multiple goals at once?", + a: "Yes. Pro accounts can track unlimited goals. Basic accounts support up to 3 active goals.", + }, + { + q: "How are monthly contributions calculated?", + a: "We divide the remaining amount by the number of months until your target date, adjusted for your current savings rate and any auto-transfer schedules.", + }, +]; + +function QuestionList({ questions }: { questions: { q: string; a: string }[] }) { + return ( + + {questions.map((item, index) => ( + + {item.q} + {item.a} + + ))} + + ); +} + +export function Faq() { + return ( + + + + + + General + + + Billing + + + Goals + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/front-door.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/front-door.tsx new file mode 100644 index 000000000..240d3a142 --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/front-door.tsx @@ -0,0 +1,41 @@ +import { Badge } from "@voidhash/ui"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@voidhash/ui"; +import { IconPlaceholder } from "../icon-placeholder"; + +export function FrontDoor() { + return ( + + + Front Door + Smart Lock Pro + +
+ Locked + +
+
+
+ +
+ + Live + +
+
+
+ ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/index-investing.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/index-investing.tsx new file mode 100644 index 000000000..d5d5b2cc3 --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/index-investing.tsx @@ -0,0 +1,22 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@voidhash/ui"; + +export function IndexInvesting() { + return ( + + + Dollar-Cost Averaging + A strategy for building wealth over time. + + + + + Over time + + , this smooths out the average cost of your investments. When prices drop, your fixed + amount buys more shares. When prices rise, you buy fewer. The result is a lower average + cost per share compared to lump-sum investing during volatile periods. + + + + ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/kitchen-island.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/kitchen-island.tsx new file mode 100644 index 000000000..97845d06a --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/kitchen-island.tsx @@ -0,0 +1,162 @@ +"use client"; + +import * as React from "react"; + +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@voidhash/ui"; +import { Item, ItemActions, ItemContent, ItemGroup, ItemMedia, ItemTitle } from "@voidhash/ui"; +import { Slider } from "@voidhash/ui"; +import { Switch } from "@voidhash/ui"; +import { ToggleGroup, ToggleGroupItem } from "@voidhash/ui"; +import { IconPlaceholder } from "../icon-placeholder"; + +const SCENES = { + cooking: { brightness: [90], colorTemp: [70], volume: [30], fade: [0] }, + dining: { brightness: [50], colorTemp: [40], volume: [20], fade: [60] }, + nightlight: { brightness: [15], colorTemp: [20], volume: [0], fade: [80] }, + focus: { brightness: [100], colorTemp: [85], volume: [0], fade: [0] }, +} as const; + +export function KitchenIsland() { + const [enabled, setEnabled] = React.useState(true); + const [scene, setScene] = React.useState("cooking"); + const [brightness, setBrightness] = React.useState([90]); + const [colorTemp, setColorTemp] = React.useState([70]); + const [volume, setVolume] = React.useState([30]); + const [fade, setFade] = React.useState([0]); + + const handleSceneChange = (value: string) => { + if (!value) return; + setScene(value); + const preset = SCENES[value as keyof typeof SCENES]; + setBrightness([...preset.brightness]); + setColorTemp([...preset.colorTemp]); + setVolume([...preset.volume]); + setFade([...preset.fade]); + }; + + return ( + + + Kitchen Island + Hue Color Ambient + + + + + +
+ Scenes + + + Cooking + + + Dining + + + Nightlight + + + Focus + + +
+ + + + + + + Brightness + + + + + + + + + + + Color Temp + + + + + + + + + + + Volume + + + + + + + + + + + Fade + + + + + + +
+
+ ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/loading-card.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/loading-card.tsx new file mode 100644 index 000000000..38dc57158 --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/loading-card.tsx @@ -0,0 +1,25 @@ +import { Card, CardContent, CardHeader } from "@voidhash/ui"; +import { Skeleton } from "@voidhash/ui"; + +export function LoadingCard() { + return ( + + + + + + + +
+ + + +
+
+ + +
+
+
+ ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/new-milestone.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/new-milestone.tsx new file mode 100644 index 000000000..0bc6c3d2f --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/new-milestone.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Button } from "@voidhash/ui"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@voidhash/ui"; +import { Field, FieldGroup, FieldLabel } from "@voidhash/ui"; +import { Input } from "@voidhash/ui"; + +export function NewMilestone() { + return ( + + + Set a new milestone + + Define your financial target and we'll help you pace your savings. + + + + + + Goal Name + + +
+ + Target Amount + + + + Target Date + + +
+
+
+ + + + +
+ ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/notification-settings.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/notification-settings.tsx new file mode 100644 index 000000000..3074bebb6 --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/notification-settings.tsx @@ -0,0 +1,98 @@ +"use client"; + +import * as React from "react"; + +import { Button } from "@voidhash/ui"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@voidhash/ui"; +import { Checkbox } from "@voidhash/ui"; +import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel } from "@voidhash/ui"; + +const NOTIFICATIONS = [ + { + id: "transactions", + label: "Transaction alerts", + description: "Deposits, withdrawals, and transfers.", + defaultChecked: true, + }, + { + id: "security", + label: "Security alerts", + description: "Login attempts and account changes.", + defaultChecked: true, + }, + { + id: "goals", + label: "Goal milestones", + description: "Updates at 25%, 50%, 75%, and 100%.", + defaultChecked: false, + }, + { + id: "market", + label: "Market updates", + description: "Daily portfolio summary and price alerts.", + defaultChecked: false, + }, +]; + +export function NotificationSettings() { + const [checked, setChecked] = React.useState>( + Object.fromEntries(NOTIFICATIONS.map((n) => [n.id, n.defaultChecked])), + ); + + const allChecked = NOTIFICATIONS.every((n) => checked[n.id]); + const someChecked = NOTIFICATIONS.some((n) => checked[n.id]) && !allChecked; + + const handleSelectAll = (value: boolean) => { + setChecked(Object.fromEntries(NOTIFICATIONS.map((n) => [n.id, value]))); + }; + + const handleToggle = (id: string, value: boolean) => { + setChecked((prev) => ({ ...prev, [id]: value })); + }; + + return ( + + + Notifications + Choose what you want to be notified about. + + + + + handleSelectAll(!!v)} + /> + + Select all + + + {NOTIFICATIONS.map((n) => ( + + handleToggle(n.id, !!v)} + /> + + {n.label} + {n.description} + + + ))} + + + + + + + ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/payments.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/payments.tsx new file mode 100644 index 000000000..0e7be017d --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/payments.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@voidhash/ui"; +import { Button } from "@voidhash/ui"; +import { Card, CardContent, CardHeader } from "@voidhash/ui"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@voidhash/ui"; +import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "@voidhash/ui"; +import { IconPlaceholder } from "../icon-placeholder"; + +export function Payments() { + return ( + + + + + + Home + + + + + + + + + + Profile + Statements + Documents + + + + + + + Payments + + + + + + + + + + + + + Change transfer limit + Adjust how much you can send from your balance. + + + + + + + + + + + Scheduled transfers + Set up a transfer to send at a later date. + + + + + + + + + + + Direct Debits + Set up and manage regular payments. + + + + + + + + + + + Recurring card payments + Manage your repeated card transactions. + + + + + + + + ); +} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/payout-threshold.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/payout-threshold.tsx new file mode 100644 index 000000000..a8a402735 --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/payout-threshold.tsx @@ -0,0 +1,101 @@ +"use client"; + +import * as React from "react"; + +import { Button } from "@voidhash/ui"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@voidhash/ui"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@voidhash/ui"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@voidhash/ui"; +import { Slider } from "@voidhash/ui"; +import { Textarea } from "@voidhash/ui"; +import { IconPlaceholder } from "../icon-placeholder"; + +export function PayoutThreshold() { + const [amount, setAmount] = React.useState([2500]); + + return ( + + + Payout Threshold + + Set the minimum balance required before a payout is triggered. + + + + + + + + + Preferred Currency + + + +
+ Minimum Payout Amount + ${amount[0].toFixed(2)} +
+ +
+ $50 (MIN) + $10,000 (MAX) +
+
+ + Notes +