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/backend/src/routes/v1/sdk.ts b/apps/backend/src/routes/v1/sdk.ts index 1dabc7bb6..b1f2f15ee 100644 --- a/apps/backend/src/routes/v1/sdk.ts +++ b/apps/backend/src/routes/v1/sdk.ts @@ -321,7 +321,7 @@ export const SdkGroupLive = HttpApiBuilder.group(VoidhashV1Api, "sdk", (handlers return null; } - // `resolved.exposure` (when non-null) carries { experimentKey, + // `resolved.exposure` (when non-null) carries { experimentId, // variantKey, personId, distinctId } for the assigned subject. // Server-side `$experiment.exposed` emission is wired here once the // analytics dispatch producer (`AnalyticsDispatchService` over the diff --git a/apps/backend/src/rpcs/experiment-rpcs.ts b/apps/backend/src/rpcs/experiment-rpcs.ts index 661aaef0a..9b46c2253 100644 --- a/apps/backend/src/rpcs/experiment-rpcs.ts +++ b/apps/backend/src/rpcs/experiment-rpcs.ts @@ -7,10 +7,8 @@ import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-w import { ExperimentRpcsDef, RpcActionForbiddenError, - RpcExperimentKeyAlreadyExistsError, RpcExperimentNotFoundError, RpcExperimentServiceError, - RpcExperimentTreatmentNotFoundError, RpcExperimentValidationError, RpcExperimentVariantNotFoundError, } from "@voidhash/rpc"; @@ -26,9 +24,8 @@ const toRpcExperiment = (e: { readonly featureFlagId: string; readonly hypothesis: string | null; readonly id: string; - readonly key: string; readonly name: string; - readonly primaryMetricEventName: string; + readonly primaryMetricEventName: string | null; readonly projectId: string; readonly secondaryMetricEventNames: readonly string[] | null; readonly startedAt: Date | null; @@ -43,7 +40,6 @@ const toRpcExperiment = (e: { readonly experimentId: string; readonly id: string; readonly isControl: boolean; - readonly key: string; readonly name: string; readonly updatedAt: Date | null; readonly weightBps: number; @@ -81,7 +77,6 @@ const toRpcExperiment = (e: { featureFlagId: e.featureFlagId, hypothesis: e.hypothesis, id: e.id, - key: e.key, name: e.name, primaryMetricEventName: e.primaryMetricEventName, projectId: e.projectId, @@ -116,12 +111,6 @@ export const ExperimentRpcsLive = ExperimentRpcsDef.toLayer( Effect.fail( new RpcExperimentVariantNotFoundError({ message: `Variant not found: ${error.variantId}` }), ); - const treatmentNotFound = (error: { readonly treatmentId: string }) => - Effect.fail( - new RpcExperimentTreatmentNotFoundError({ - message: `Treatment not found: ${error.treatmentId}`, - }), - ); return { ArchiveExperiment: (input) => @@ -142,21 +131,12 @@ export const ExperimentRpcsLive = ExperimentRpcsDef.toLayer( }), ), CreateExperiment: (input) => - service - .createExperiment({ - ...input, - secondaryMetricEventNames: input.secondaryMetricEventNames - ? [...input.secondaryMetricEventNames] - : undefined, - }) - .pipe( - Effect.catchTags({ - ActionForbiddenError: forbidden, - ExperimentKeyAlreadyExistsError: (error) => - Effect.fail(new RpcExperimentKeyAlreadyExistsError({ key: error.key })), - ExperimentServiceError: serviceError, - }), - ), + service.createExperiment(input).pipe( + Effect.catchTags({ + ActionForbiddenError: forbidden, + ExperimentServiceError: serviceError, + }), + ), GetExperiment: (input) => service.getExperiment(input).pipe( Effect.map(toRpcExperiment), @@ -190,25 +170,6 @@ export const ExperimentRpcsLive = ExperimentRpcsDef.toLayer( ExperimentValidationError: validation, }), ), - RemoveExperimentTreatment: (input) => - service.removeTreatment(input).pipe( - Effect.catchTags({ - ActionForbiddenError: forbidden, - ExperimentNotFoundError: notFound, - ExperimentServiceError: serviceError, - ExperimentTreatmentNotFoundError: treatmentNotFound, - ExperimentValidationError: validation, - }), - ), - ReplaceExperimentVariants: (input) => - service.replaceVariants({ ...input, variants: [...input.variants] }).pipe( - Effect.catchTags({ - ActionForbiddenError: forbidden, - ExperimentNotFoundError: notFound, - ExperimentServiceError: serviceError, - ExperimentValidationError: validation, - }), - ), RestoreExperiment: (input) => service.restoreExperiment(input).pipe( Effect.catchTags({ @@ -226,28 +187,9 @@ export const ExperimentRpcsLive = ExperimentRpcsDef.toLayer( ExperimentValidationError: validation, }), ), - UpdateExperiment: (input) => - service - .updateExperiment({ - ...input, - secondaryMetricEventNames: - input.secondaryMetricEventNames === undefined - ? undefined - : input.secondaryMetricEventNames - ? [...input.secondaryMetricEventNames] - : null, - }) - .pipe( - Effect.map(toRpcExperiment), - Effect.catchTags({ - ActionForbiddenError: forbidden, - ExperimentNotFoundError: notFound, - ExperimentServiceError: serviceError, - ExperimentValidationError: validation, - }), - ), - UpsertExperimentTreatment: (input) => - service.upsertTreatment(input).pipe( + SaveExperimentSetup: (input) => + service.saveSetup(input).pipe( + Effect.map(toRpcExperiment), Effect.catchTags({ ActionForbiddenError: forbidden, ExperimentNotFoundError: notFound, diff --git a/apps/backend/src/rpcs/feature-flag-rpcs.ts b/apps/backend/src/rpcs/feature-flag-rpcs.ts index 559983100..37f66c4e4 100644 --- a/apps/backend/src/rpcs/feature-flag-rpcs.ts +++ b/apps/backend/src/rpcs/feature-flag-rpcs.ts @@ -1,4 +1,10 @@ import { FeatureFlagService } from "@voidhash/core/services"; +import type { + FeatureFlag, + FeatureFlagOverride, + FeatureFlagTarget, + FeatureFlagVariant, +} from "@voidhash/db"; import { FeatureFlagRpcsDef, RpcActionForbiddenError, @@ -11,6 +17,41 @@ import { } from "@voidhash/rpc"; import { Effect } from "effect"; +const toRpcFeatureFlagVariant = (variant: FeatureFlagVariant) => ({ + archivedAt: variant.archivedAt, + createdAt: variant.createdAt, + featureFlagId: variant.featureFlagId, + id: variant.id, + label: variant.name || null, + updatedAt: variant.updatedAt, + value: variant.payload, +}); + +const toRpcFeatureFlag = ( + flag: FeatureFlag & { + readonly overrides: ReadonlyArray; + readonly targets: ReadonlyArray; + readonly variants: ReadonlyArray; + }, +) => { + const { key, name: _name, variants, ...rest } = flag; + return { + ...rest, + slug: key, + variants: variants.map(toRpcFeatureFlagVariant), + }; +}; + +const toRpcFeatureFlagListItem = ( + flag: FeatureFlag & { readonly variantCount: number; readonly variants?: undefined }, +) => { + const { key, name: _name, variants: _variants, ...rest } = flag; + return { + ...rest, + slug: key, + }; +}; + export const FeatureFlagRpcsLive = FeatureFlagRpcsDef.toLayer( Effect.gen(function* FeatureFlagRpcsLive() { const service = yield* FeatureFlagService; @@ -54,8 +95,8 @@ export const FeatureFlagRpcsLive = FeatureFlagRpcsDef.toLayer( Effect.fail(new RpcFeatureFlagTargetNotFoundError({ message: error.message })), }), ), - CreateFeatureFlag: (input) => - service.createFlag(input).pipe( + CreateFeatureFlag: ({ slug, ...input }) => + service.createFlag({ ...input, key: slug }).pipe( Effect.catchTags({ ActionForbiddenError: (error) => Effect.fail(new RpcActionForbiddenError({ message: error.message })), @@ -69,6 +110,7 @@ export const FeatureFlagRpcsLive = FeatureFlagRpcsDef.toLayer( ), GetFeatureFlag: ({ id }) => service.getFlagById({ id }).pipe( + Effect.map(toRpcFeatureFlag), Effect.catchTags({ ActionForbiddenError: (error) => Effect.fail(new RpcActionForbiddenError({ message: error.message })), @@ -100,6 +142,7 @@ export const FeatureFlagRpcsLive = FeatureFlagRpcsDef.toLayer( ), ListFeatureFlags: (input) => service.listFlags(input).pipe( + Effect.map((flags) => flags.map(toRpcFeatureFlagListItem)), Effect.catchTags({ ActionForbiddenError: (error) => Effect.fail(new RpcActionForbiddenError({ message: error.message })), @@ -120,8 +163,9 @@ export const FeatureFlagRpcsLive = FeatureFlagRpcsDef.toLayer( Effect.fail(new RpcFeatureFlagServiceError({ cause: error.cause })), }), ), - UpdateFeatureFlag: (input) => - service.updateFlag(input).pipe( + UpdateFeatureFlag: ({ slug, ...input }) => + service.updateFlag({ ...input, key: slug }).pipe( + Effect.map(toRpcFeatureFlag), Effect.catchTags({ ActionForbiddenError: (error) => Effect.fail(new RpcActionForbiddenError({ message: error.message })), @@ -137,7 +181,7 @@ export const FeatureFlagRpcsLive = FeatureFlagRpcsDef.toLayer( ), UpdateFeatureFlagVariants: (input) => service - .updateFlagVariants({ + .updateCustomerFlagVariants({ ...input, variants: [...input.variants], }) 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..238b24146 100644 --- a/apps/backend/src/testing/rpc-smoke-cases.ts +++ b/apps/backend/src/testing/rpc-smoke-cases.ts @@ -259,9 +259,10 @@ export const rpcSmokeCases = [ expected: success, payload: ({ ids, runId }) => ({ description: "Smoke flag", - key: `smoke-flag-${runId}`, - name: "Smoke Flag", projectId: ids.projectId, + slug: `smoke-flag-${runId}`, + type: "json", + variants: [], }), role: "admin", tag: "CreateFeatureFlag", @@ -277,8 +278,8 @@ export const rpcSmokeCases = [ payload: ({ featureFlagId, runId }) => ({ enabled: true, id: featureFlagId, - name: `Smoke Flag ${runId}`, rolloutBps: 5000, + slug: `smoke-flag-updated-${runId}`, }), role: "admin", tag: "UpdateFeatureFlag", @@ -289,10 +290,7 @@ export const rpcSmokeCases = [ featureFlagId, variants: [ { - key: "control", - name: "Control", - payload: { kind: "control" }, - weightBps: 10000, + value: { kind: "control" }, }, ], }), @@ -555,12 +553,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 }), @@ -792,10 +784,7 @@ const knownMissingRpcSmokeTags = new Set([ "ListExperiments", "GetExperiment", "CreateExperiment", - "UpdateExperiment", - "ReplaceExperimentVariants", - "UpsertExperimentTreatment", - "RemoveExperimentTreatment", + "SaveExperimentSetup", "StartExperiment", "PauseExperiment", "ConcludeExperiment", diff --git a/apps/mimic-admin/src/components/ui/button.tsx b/apps/mimic-admin/src/components/ui/button.tsx index fe109aff0..8c9fd8eb7 100644 --- a/apps/mimic-admin/src/components/ui/button.tsx +++ b/apps/mimic-admin/src/components/ui/button.tsx @@ -15,8 +15,6 @@ const buttonVariants = cva( "bg-destructive text-white shadow-sm hover:bg-destructive/90", outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, diff --git a/apps/studio/src/components/ui/button.tsx b/apps/studio/src/components/ui/button.tsx index 9ba4de22f..be9b247d8 100644 --- a/apps/studio/src/components/ui/button.tsx +++ b/apps/studio/src/components/ui/button.tsx @@ -2,12 +2,11 @@ import type { ButtonHTMLAttributes, ReactNode } from "react"; import { cn } from "../../lib/cn"; -type Variant = "default" | "secondary" | "ghost" | "outline"; +type Variant = "default" | "ghost" | "outline"; type Size = "sm" | "md" | "icon"; const VARIANTS: Record = { default: "bg-emerald-600 text-white hover:bg-emerald-500", - secondary: "bg-neutral-800 text-neutral-100 hover:bg-neutral-700", ghost: "bg-transparent text-neutral-300 hover:bg-neutral-800", outline: "border border-neutral-700 bg-transparent text-neutral-200 hover:bg-neutral-800", }; 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/scripts/docs-preview/index.html b/apps/www/scripts/docs-preview/index.html new file mode 100644 index 000000000..4114aea95 --- /dev/null +++ b/apps/www/scripts/docs-preview/index.html @@ -0,0 +1,12 @@ + + + + + + Docs preview + + +
+ + + diff --git a/apps/www/scripts/docs-preview/main.tsx b/apps/www/scripts/docs-preview/main.tsx new file mode 100644 index 000000000..a813747e7 --- /dev/null +++ b/apps/www/scripts/docs-preview/main.tsx @@ -0,0 +1,207 @@ +import type * as PageTree from "fumadocs-core/page-tree"; +import { FrameworkProvider } from "fumadocs-core/framework"; +import { Callout } from "fumadocs-ui/components/callout"; +import { CodeBlock, Pre } from "fumadocs-ui/components/codeblock"; +import { Step, Steps } from "fumadocs-ui/components/steps"; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; +import { RootProvider } from "fumadocs-ui/provider/base"; +import { type ReactNode, useState } from "react"; +import { createRoot } from "react-dom/client"; + +import "../../src/styles/globals.css"; +import { DocsLayout } from "../../src/features/docs/components/layout/docs"; +import { + DocsBody, + DocsDescription, + DocsPage, + DocsTitle, +} from "../../src/features/docs/components/layout/page"; + +const page = (name: string, url: string): PageTree.Item => ({ name, type: "page", url }); +const separator = (name: string): PageTree.Separator => ({ name, type: "separator" }); + +/** Stand-in for the generated fumadocs tree, shaped like the real docs content. */ +const TREE: PageTree.Root = { + name: "Docs", + type: "root", + children: [ + page("Introduction", "/docs/introduction"), + page("Installation", "/docs/installation"), + page("Basic usage", "/docs/basic-usage"), + separator("Platform"), + { + name: "React Native", + type: "folder", + index: page("Overview", "/docs/react-native"), + children: [ + page("Quickstart", "/docs/react-native/quickstart"), + separator("Paywalls"), + page("Rendering a paywall", "/docs/react-native/paywalls/rendering"), + page("Placements", "/docs/react-native/paywalls/placements"), + ], + }, + page("CLI", "/docs/cli"), + page("MCP server", "/docs/mcp"), + separator("Reference"), + page("SDKs and APIs", "/docs/sdks-and-apis"), + { + name: "Guides", + type: "folder", + index: page("All guides", "/docs/guides"), + children: [page("App Store Connect", "/docs/guides/app-store-connect")], + }, + { + name: "API", + type: "folder", + index: page("Overview", "/docs/api/overview"), + children: [ + page("Authentication", "/docs/api/authentication"), + page("Persons", "/docs/api/persons"), + page("Webhooks", "/docs/api/webhooks"), + ], + }, + ], +}; + +const TOC = [ + { depth: 2, title: "Install the SDK", url: "#install-the-sdk" }, + { depth: 2, title: "Configure your project", url: "#configure-your-project" }, + { depth: 3, title: "Environment variables", url: "#environment-variables" }, + { depth: 3, title: "Native modules", url: "#native-modules" }, + { depth: 2, title: "Render your first paywall", url: "#render-your-first-paywall" }, + { depth: 2, title: "Next steps", url: "#next-steps" }, +]; + +function Sample() { + return ( + + Installation + + Add the Voidhash SDK to your React Native app and connect it to a project. + + +

+ Voidhash ships a single universal SDK for React Native. It bundles paywall rendering, + entitlement checks, analytics capture and remote configuration, so you only install one + package regardless of which parts of the platform you use. +

+

Install the SDK

+

+ Install the package with your package manager of choice. The SDK requires React Native + 0.76 or newer and Expo SDK 52 or newer. +

+
+          pnpm add @voidhash/react-native
+        
+

Configure your project

+

+ Wrap your application in the provider and pass the publishable key for the project you + want to target. Keys are scoped per project and per environment. +

+

Environment variables

+ + + + + + + + + + + + + + + + + + + + +
VariableRequiredDescription
+ VOIDHASH_PUBLISHABLE_KEY + YesIdentifies the project the client talks to.
+ VOIDHASH_HOST + NoOverride for self-hosted deployments.
+

Native modules

+
    +
  • Run a native rebuild after installing — the SDK links StoreKit and Billing.
  • +
  • Expo Go is not supported; use a development build.
  • +
  • + See the development build guide for the full setup. +
  • +
+

Render your first paywall

+

+ Paywalls are addressed by placement, not by id, so you can swap the paywall shown at a + placement from the dashboard without shipping an app update. +

+
+ Placements are resolved server-side and cached locally, so the first render after a cold + start is instant. +
+ + Expo Go cannot load the native module. Create a development build before continuing. + + +
{`import { VoidhashProvider } from "@voidhash/react-native";\n\nexport default function App() {\n  return {children};\n}`}
+
+ + npm install @voidhash/react-native + pnpm add @voidhash/react-native + bun add @voidhash/react-native + +

Next steps

+ + +

Create a paywall

+

Design it in the dashboard, then publish it to a placement.

+
+ +

Wire up the placement

+

Point your app at the placement key and ship.

+
+
+

Once the SDK is installed, continue with the quickstart to publish your first paywall.

+
+
+ ); +} + +function Preview() { + const [pathname, setPathname] = useState("/docs/installation"); + + return ( + ( + { + event.preventDefault(); + if (href) { + setPathname(href); + } + }} + > + {children} + + )} + usePathname={() => pathname} + useParams={() => ({})} + useRouter={() => ({ + push: setPathname, + refresh: () => {}, + })} + > + + + + + + + ); +} + +createRoot(document.getElementById("root")!).render(); diff --git a/apps/www/scripts/docs-preview/vite.config.ts b/apps/www/scripts/docs-preview/vite.config.ts new file mode 100644 index 000000000..653db6090 --- /dev/null +++ b/apps/www/scripts/docs-preview/vite.config.ts @@ -0,0 +1,16 @@ +import tailwindcss from "@tailwindcss/vite"; +import viteReact from "@vitejs/plugin-react"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), + plugins: [tailwindcss(), viteReact()], + resolve: { + alias: { + "@": fileURLToPath(new URL("../../src", import.meta.url)), + }, + dedupe: ["react", "react-dom"], + }, + server: { port: 5198 }, +}); diff --git a/apps/www/scripts/landing-preview/index.html b/apps/www/scripts/landing-preview/index.html new file mode 100644 index 000000000..5bc25a487 --- /dev/null +++ b/apps/www/scripts/landing-preview/index.html @@ -0,0 +1,12 @@ + + + + + + Landing preview + + +
+ + + diff --git a/apps/www/scripts/landing-preview/main.tsx b/apps/www/scripts/landing-preview/main.tsx new file mode 100644 index 000000000..64da5171e --- /dev/null +++ b/apps/www/scripts/landing-preview/main.tsx @@ -0,0 +1,6 @@ +import { createRoot } from "react-dom/client"; + +import "../../src/styles/globals.css"; +import { LandingPage } from "../../src/features/www/landing/landing-page"; + +createRoot(document.getElementById("root")!).render(); diff --git a/apps/www/scripts/landing-preview/vite.config.ts b/apps/www/scripts/landing-preview/vite.config.ts new file mode 100644 index 000000000..419d79a6d --- /dev/null +++ b/apps/www/scripts/landing-preview/vite.config.ts @@ -0,0 +1,16 @@ +import tailwindcss from "@tailwindcss/vite"; +import viteReact from "@vitejs/plugin-react"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), + plugins: [tailwindcss(), viteReact()], + resolve: { + alias: { + "@": fileURLToPath(new URL("../../src", import.meta.url)), + }, + dedupe: ["react", "react-dom"], + }, + server: { port: 5199 }, +}); diff --git a/apps/www/scripts/pricing-preview/index.html b/apps/www/scripts/pricing-preview/index.html new file mode 100644 index 000000000..612641f4f --- /dev/null +++ b/apps/www/scripts/pricing-preview/index.html @@ -0,0 +1,12 @@ + + + + + + Pricing preview + + +
+ + + diff --git a/apps/www/scripts/pricing-preview/main.tsx b/apps/www/scripts/pricing-preview/main.tsx new file mode 100644 index 000000000..2bdb30894 --- /dev/null +++ b/apps/www/scripts/pricing-preview/main.tsx @@ -0,0 +1,6 @@ +import { createRoot } from "react-dom/client"; + +import "../../src/styles/globals.css"; +import { PricingPage } from "../../src/features/www/pricing/pricing-page"; + +createRoot(document.getElementById("root")!).render(); diff --git a/apps/www/scripts/pricing-preview/vite.config.ts b/apps/www/scripts/pricing-preview/vite.config.ts new file mode 100644 index 000000000..3ff532e40 --- /dev/null +++ b/apps/www/scripts/pricing-preview/vite.config.ts @@ -0,0 +1,16 @@ +import tailwindcss from "@tailwindcss/vite"; +import viteReact from "@vitejs/plugin-react"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), + plugins: [tailwindcss(), viteReact()], + resolve: { + alias: { + "@": fileURLToPath(new URL("../../src", import.meta.url)), + }, + dedupe: ["react", "react-dom"], + }, + server: { port: 5200 }, +}); diff --git a/apps/www/src/components/default-catch-boundary.tsx b/apps/www/src/components/default-catch-boundary.tsx index c73379adc..1702d6503 100644 --- a/apps/www/src/components/default-catch-boundary.tsx +++ b/apps/www/src/components/default-catch-boundary.tsx @@ -17,7 +17,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) { onClick={() => { router.invalidate(); }} - variant="secondary" + variant="outline" > Try Again diff --git a/apps/www/src/components/lenticular-refraction-pass.tsx b/apps/www/src/components/lenticular-refraction-pass.tsx new file mode 100644 index 000000000..65ca92b66 --- /dev/null +++ b/apps/www/src/components/lenticular-refraction-pass.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useFrame, useThree } from "@react-three/fiber"; +import { useEffect, useMemo } from "react"; +import * as THREE from "three"; +import { EffectComposer as ThreeEffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; +import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; +import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js"; + +const DEFAULT_ANGLE_DEGREES = 45.08; +const DEFAULT_CENTER_X = 0.5; +const DEFAULT_CENTER_Y = 0.6386; +const DEFAULT_PERIOD_RATIO = 0.0394; +const DEFAULT_STRENGTH = -100; + +const fragmentShader = ` +uniform sampler2D tDiffuse; +uniform vec2 uResolution; +uniform float uAngle; +uniform vec2 uCenter; +uniform float uPeriodRatio; +uniform float uStrength; + +varying vec2 vUv; + +void main() { + vec2 resolution = max(uResolution, vec2(1.0)); + vec2 screenUv = vec2(vUv.x, 1.0 - vUv.y); + vec2 px = screenUv * resolution; + vec2 centerPx = uCenter * resolution; + vec2 normal = normalize(vec2(sin(uAngle), cos(uAngle))); + float period = max(resolution.x * uPeriodRatio, 1.0); + float signedStrength = clamp(uStrength, -100.0, 100.0) / 100.0; + float strength = abs(signedStrength); + float stripeAxis = dot(px - centerPx, normal); + float cell = fract(stripeAxis / period + 0.115); + float opticalPhase = mix(cell, 1.0 - cell, step(0.0, signedStrength)); + + float edgePosition = opticalPhase * 2.0 - 1.0; + float edgeProximity = pow(abs(edgePosition), 1.3); + float refractionAmount = strength * edgeProximity * 0.7; + float targetY = 1.0 - step(0.0, edgePosition); + vec2 leftUv = mix(screenUv, vec2(0.0, targetY), refractionAmount); + vec2 rightUv = mix(screenUv, vec2(1.0, targetY), refractionAmount); + vec2 leftTextureUv = vec2(leftUv.x, 1.0 - leftUv.y); + vec2 rightTextureUv = vec2(rightUv.x, 1.0 - rightUv.y); + float sideMix = smoothstep(uCenter.x - 0.1, uCenter.x + 0.1, screenUv.x); + + gl_FragColor = mix( + texture2D(tDiffuse, leftTextureUv), + texture2D(tDiffuse, rightTextureUv), + sideMix + ); +} +`; + +const vertexShader = ` +varying vec2 vUv; + +void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +`; + +export type LenticularRefractionSettings = { + angleDegrees?: number; + centerX?: number; + centerY?: number; + periodRatio?: number; + strength?: number; +}; + +/** Applies the lenticular refraction transform to the current Three.js scene. */ +export function LenticularRefractionPass({ + settings = {}, +}: { + settings?: LenticularRefractionSettings; +}) { + const { camera, gl, scene, size } = useThree(); + const angleDegrees = settings.angleDegrees ?? DEFAULT_ANGLE_DEGREES; + const centerX = settings.centerX ?? DEFAULT_CENTER_X; + const centerY = settings.centerY ?? DEFAULT_CENTER_Y; + const periodRatio = settings.periodRatio ?? DEFAULT_PERIOD_RATIO; + const strength = settings.strength ?? DEFAULT_STRENGTH; + const { composer, shaderPass } = useMemo(() => { + const nextComposer = new ThreeEffectComposer(gl); + const nextShaderPass = new ShaderPass({ + fragmentShader, + uniforms: { + tDiffuse: { value: null }, + uAngle: { value: THREE.MathUtils.degToRad(angleDegrees) }, + uCenter: { value: new THREE.Vector2(centerX, centerY) }, + uPeriodRatio: { value: periodRatio }, + uResolution: { value: new THREE.Vector2(1, 1) }, + uStrength: { value: strength }, + }, + vertexShader, + }); + + nextComposer.addPass(new RenderPass(scene, camera)); + nextComposer.addPass(nextShaderPass); + + return { + composer: nextComposer, + shaderPass: nextShaderPass, + }; + }, [angleDegrees, camera, centerX, centerY, gl, periodRatio, scene, strength]); + + useEffect(() => { + composer.setPixelRatio(gl.getPixelRatio()); + composer.setSize(size.width, size.height); + gl.getDrawingBufferSize(shaderPass.uniforms.uResolution.value); + }, [composer, gl, shaderPass, size.height, size.width]); + + useEffect(() => { + shaderPass.uniforms.uAngle.value = THREE.MathUtils.degToRad(angleDegrees); + shaderPass.uniforms.uCenter.value.set(centerX, centerY); + shaderPass.uniforms.uPeriodRatio.value = periodRatio; + shaderPass.uniforms.uStrength.value = strength; + }, [angleDegrees, centerX, centerY, periodRatio, shaderPass, strength]); + + useEffect( + () => () => { + shaderPass.dispose(); + composer.dispose(); + }, + [composer, shaderPass], + ); + + useFrame((_, delta) => { + composer.render(delta); + }, 1); + + return null; +} diff --git a/apps/www/src/features/auth/components/auth-layout.tsx b/apps/www/src/features/auth/components/auth-layout.tsx index cf97eee44..39d64a7d0 100644 --- a/apps/www/src/features/auth/components/auth-layout.tsx +++ b/apps/www/src/features/auth/components/auth-layout.tsx @@ -2,34 +2,22 @@ import { Link } from "@tanstack/react-router"; import { Logo } from "@voidhash/ui"; import type { ReactNode } from "react"; -import { VoidhashGradientBackground } from "@/components/voidhash-gradient-background"; - -const authGradientSettings = { - topEnabled: false, - effectHeight: 70, -} as const; +import { AuthLenticularBackground } from "./auth-lenticular-background"; export type AuthLayoutProps = { children: ReactNode; }; /** - * Shared shell for the auth surfaces. Renders the blurred Voidhash gradient + * Shared shell for the auth surfaces. Renders the lenticular Voidhash gradient * behind a backdrop-blurred form column with the logo (linking home), matching * the sign-in / sign-up layout. Page content is centred in a `max-w-sm` column. */ export function AuthLayout({ children }: AuthLayoutProps) { return (
- -
+ +
diff --git a/apps/www/src/features/auth/components/auth-lenticular-background.tsx b/apps/www/src/features/auth/components/auth-lenticular-background.tsx new file mode 100644 index 000000000..6ace73fe9 --- /dev/null +++ b/apps/www/src/features/auth/components/auth-lenticular-background.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { HeroShader } from "@/features/www/hero/hero-shader"; +import { cn } from "@/lib/utils"; + +/** Renders the landing lenticular composition with the animated Perlin surface as its source. */ +export function AuthLenticularBackground({ className }: { className?: string }) { + return ( +
+ +
+ ); +} diff --git a/apps/www/src/features/design/components/docs/colors/index.tsx b/apps/www/src/features/design/components/docs/colors/index.tsx new file mode 100644 index 000000000..0b50c638b --- /dev/null +++ b/apps/www/src/features/design/components/docs/colors/index.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useCallback, useState } from "react"; + +import { cn } from "@/features/design/lib/cn"; + +import { + BRAND_SCALES, + isLightColor, + resolveToken, + scaleSteps, + SEMANTIC_GROUPS, + type SemanticToken, + type ThemeName, +} from "./tokens"; + +const THEMES: ThemeName[] = ["light", "dark"]; + +const useCopyValue = () => { + const [copied, setCopied] = useState(undefined); + + const copy = useCallback((value: string) => { + void navigator.clipboard.writeText(value).then(() => { + setCopied(value); + setTimeout(() => setCopied((current) => (current === value ? undefined : current)), 1200); + }); + }, []); + + return { copied, copy }; +}; + +interface CopyButtonProps { + className?: string; + copied: boolean; + label: string; + onCopy: () => void; + value: string; +} + +function CopyButton({ className, copied, label, onCopy, value }: CopyButtonProps) { + return ( + + ); +} + +interface TokenSwatchProps { + theme: ThemeName; + token: SemanticToken; +} + +function TokenSwatch({ theme, token }: TokenSwatchProps) { + const resolved = resolveToken(token.name, theme); + if (!resolved) { + return null; + } + + const on = token.on ? resolveToken(token.on, theme) : undefined; + const fallbackText = isLightColor(resolved.value) ? "oklch(0% 0 0)" : "oklch(100% 0 0)"; + + return ( +
+ + {token.on ? "Aa" : theme === "light" ? "L" : "D"} + +
+ ); +} + +interface TokenValueProps { + copiedValue: string | undefined; + onCopy: (value: string) => void; + theme: ThemeName; + token: SemanticToken; +} + +function TokenValue({ copiedValue, onCopy, theme, token }: TokenValueProps) { + const resolved = resolveToken(token.name, theme); + if (!resolved) { + return null; + } + + return ( +
+ {theme} + onCopy(resolved.value)} + value={resolved.value} + /> + {resolved.alias ? via {resolved.alias} : null} +
+ ); +} + +interface SemanticTokenCardProps { + copiedValue: string | undefined; + onCopy: (value: string) => void; + token: SemanticToken; +} + +function SemanticTokenCard({ copiedValue, onCopy, token }: SemanticTokenCardProps) { + const variable = `var(--${token.name})`; + + return ( +
+
+ {THEMES.map((theme) => ( + + ))} +
+ +
+
+ onCopy(variable)} + value={`--${token.name}`} + /> + {token.on ? ( + + on --{token.on} + + ) : null} +
+ +

{token.meaning}

+ +
+ {token.utilities.map((utility) => ( + + {utility} + + ))} +
+ +
+ {THEMES.map((theme) => ( + + ))} +
+
+
+ ); +} + +/** + * Renders every semantic theme token grouped by intent, with its light and dark + * value, the alias it resolves through, and the Tailwind utilities that map to + * it. Values are read from `@voidhash/ui/styles/brand-theme.css`, so this page + * cannot drift from the theme. + */ +export function SemanticColorTokens() { + const { copied, copy } = useCopyValue(); + + return ( +
+ {SEMANTIC_GROUPS.map((group) => ( +
+
+

{group.title}

+

{group.description}

+
+ +
+ {group.tokens.map((token) => ( + + ))} +
+
+ ))} +
+ ); +} + +/** + * Renders the raw brand ramps every semantic token is built from. Scales are + * theme-independent — only the semantic tokens above remap between light and + * dark. + */ +export function BrandColorScales() { + const { copied, copy } = useCopyValue(); + + return ( +
+ {BRAND_SCALES.map((scale) => { + const steps = scaleSteps(scale.prefix); + + return ( +
+
+

{scale.title}

+

{scale.meaning}

+
+ +
+ {steps.map((step) => { + const name = `${scale.prefix}-${step}`; + const resolved = resolveToken(name, "light"); + if (!resolved) { + return null; + } + + const variable = `var(--${name})`; + + return ( + + ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/apps/www/src/features/design/components/docs/colors/tokens.ts b/apps/www/src/features/design/components/docs/colors/tokens.ts new file mode 100644 index 000000000..9f9124708 --- /dev/null +++ b/apps/www/src/features/design/components/docs/colors/tokens.ts @@ -0,0 +1,452 @@ +import brandThemeCss from "@voidhash/ui/styles/brand-theme.css?raw"; + +export type ThemeName = "light" | "dark"; + +export interface ResolvedToken { + /** Final color value with every `var()` indirection followed. */ + value: string; + /** The variable this token points at, when it is defined as an alias. */ + alias?: string; +} + +export interface SemanticToken { + /** Custom property name without the leading dashes, e.g. `primary`. */ + name: string; + /** What the token is for and when to reach for it. */ + meaning: string; + /** Tailwind utilities that map onto this token. */ + utilities: string[]; + /** Token used for text/icons placed on top of this one, if there is a pair. */ + on?: string; +} + +export interface SemanticGroup { + title: string; + description: string; + tokens: SemanticToken[]; +} + +export interface BrandScale { + /** Custom property prefix, e.g. `blue-ribbon` for `--blue-ribbon-500`. */ + prefix: string; + title: string; + meaning: string; +} + +const SELECTOR_LIGHT = ":root"; +const SELECTOR_DARK = ".dark"; +const ALIAS_PATTERN = /^var\((--[\w-]+)\)$/; + +/** + * Reads the custom properties declared in a single flat rule block. The brand + * theme keeps `:root` and `.dark` free of nested rules, so a brace scan is + * enough — no CSS parser needed. + */ +const readDeclarations = (css: string, selector: string): Record => { + const selectorStart = css.indexOf(`${selector} {`); + if (selectorStart === -1) { + return {}; + } + + const blockStart = css.indexOf("{", selectorStart); + const blockEnd = css.indexOf("}", blockStart); + const declarations: Record = {}; + + for (const declaration of css.slice(blockStart + 1, blockEnd).split(";")) { + const separator = declaration.indexOf(":"); + if (separator === -1) { + continue; + } + + const name = declaration.slice(0, separator).trim(); + if (name.startsWith("--")) { + declarations[name] = declaration.slice(separator + 1).trim(); + } + } + + return declarations; +}; + +const LIGHT_DECLARATIONS = readDeclarations(brandThemeCss, SELECTOR_LIGHT); +const DARK_DECLARATIONS = { + ...LIGHT_DECLARATIONS, + ...readDeclarations(brandThemeCss, SELECTOR_DARK), +}; + +const declarationsFor = (theme: ThemeName) => + theme === "dark" ? DARK_DECLARATIONS : LIGHT_DECLARATIONS; + +const follow = (declarations: Record, value: string, depth = 0): string => { + const alias = ALIAS_PATTERN.exec(value); + const target = alias ? declarations[alias[1]] : undefined; + if (!target || depth > 10) { + return value; + } + + return follow(declarations, target, depth + 1); +}; + +/** + * Resolves a theme token to its literal color, following alias chains such as + * `--primary` → `--blue-ribbon-600` → `oklch(…)`. Returns `undefined` when the + * token is not declared for the given theme. + */ +export const resolveToken = (name: string, theme: ThemeName): ResolvedToken | undefined => { + const declarations = declarationsFor(theme); + const raw = declarations[`--${name}`]; + if (!raw) { + return undefined; + } + + const alias = ALIAS_PATTERN.exec(raw); + return { + alias: alias?.[1], + value: follow(declarations, raw), + }; +}; + +/** Lists the steps declared for a scale prefix, ordered light to dark. */ +export const scaleSteps = (prefix: string): number[] => + Object.keys(LIGHT_DECLARATIONS) + .map((name) => { + const match = new RegExp(`^--${prefix}-(\\d+)$`).exec(name); + return match ? Number(match[1]) : undefined; + }) + .filter((step): step is number => step !== undefined) + .sort((a, b) => a - b); + +/** + * Estimates whether a color is light enough to need dark text on top. Handles + * the two literal formats used by the theme: `oklch(L% C H)` and hex. + */ +export const isLightColor = (value: string): boolean => { + const oklch = /^oklch\(\s*([\d.]+)%/.exec(value); + if (oklch) { + return Number(oklch[1]) >= 62; + } + + const hex = /^#([\da-f]{6})$/i.exec(value); + if (hex) { + const int = Number.parseInt(hex[1], 16); + const luminance = + (0.2126 * ((int >> 16) & 0xff) + 0.7152 * ((int >> 8) & 0xff) + 0.0722 * (int & 0xff)) / 255; + return luminance >= 0.55; + } + + return true; +}; + +export const SEMANTIC_GROUPS: SemanticGroup[] = [ + { + description: + "The stack of neutral surfaces, from the page canvas up to floating layers. Pick the one that matches how far the element is lifted off the page, not the color you want.", + title: "Surfaces", + tokens: [ + { + meaning: + "The app canvas. Everything else sits on top of it. Set once on `body` — components should not repaint it.", + name: "background", + on: "foreground", + utilities: ["bg-background"], + }, + { + meaning: + "A neutral surface raised off the canvas: toolbars, inspector rails, list rows that need separation without a card border.", + name: "surface", + on: "foreground", + utilities: ["bg-surface"], + }, + { + meaning: + "A recessed surface for wells and tracks — slider rails, progress backgrounds, inset code blocks.", + name: "surface-muted", + on: "foreground", + utilities: ["bg-surface-muted"], + }, + { + meaning: + "Content containers. Use with `--border` for the outline; in dark mode it reads lighter than the canvas so cards float.", + name: "card", + on: "card-foreground", + utilities: ["bg-card"], + }, + { + meaning: "Text and icons inside a card.", + name: "card-foreground", + utilities: ["text-card-foreground"], + }, + { + meaning: + "App chrome around the workspace — designer and editor panels. Slightly darker than `--card` in dark mode so tooling recedes behind content.", + name: "panel", + on: "foreground", + utilities: ["bg-panel"], + }, + { + meaning: + "Layers that float above the page: dropdowns, menus, tooltips, comboboxes, date pickers.", + name: "popover", + on: "popover-foreground", + utilities: ["bg-popover"], + }, + { + meaning: "Text and icons inside a popover layer.", + name: "popover-foreground", + utilities: ["text-popover-foreground"], + }, + ], + }, + { + description: + "Text and icon colors. Body copy is `--foreground`; anything quieter steps down to `--muted-foreground` rather than lowering opacity.", + title: "Content", + tokens: [ + { + meaning: "Default body text, headings, and icons on the canvas.", + name: "foreground", + utilities: ["text-foreground"], + }, + { + meaning: + "Secondary text: labels, helper copy, placeholders, timestamps, inactive icons. The lowest-emphasis text that still meets contrast.", + name: "muted-foreground", + utilities: ["text-muted-foreground"], + }, + { + meaning: + "Quiet neutral fill for badges, skeletons, disabled controls, and hovered table rows.", + name: "muted", + on: "muted-foreground", + utilities: ["bg-muted"], + }, + ], + }, + { + description: + "Interactive intent. One primary action per view; everything competing with it drops to secondary or ghost styling.", + title: "Actions", + tokens: [ + { + meaning: + "The primary action and brand accent — solid buttons, selected states, links, active nav items.", + name: "primary", + on: "primary-foreground", + utilities: ["bg-primary", "text-primary", "border-primary"], + }, + { + meaning: "Text and icons on a primary fill. Stays white in both themes.", + name: "primary-foreground", + utilities: ["text-primary-foreground"], + }, + { + meaning: "Neutral, lower-emphasis actions that sit next to a primary button.", + name: "secondary", + on: "secondary-foreground", + utilities: ["bg-secondary"], + }, + { + meaning: "Text and icons on a secondary fill.", + name: "secondary-foreground", + utilities: ["text-secondary-foreground"], + }, + { + meaning: + "Hover and highlight state for list-like surfaces: menu items, command results, sidebar rows, ghost buttons.", + name: "accent", + on: "accent-foreground", + utilities: ["bg-accent", "hover:bg-accent"], + }, + { + meaning: "Text and icons on an accent highlight.", + name: "accent-foreground", + utilities: ["text-accent-foreground"], + }, + ], + }, + { + description: + "Status colors. Reserved for outcomes and risk — never used decoratively, so their appearance always carries meaning.", + title: "Feedback", + tokens: [ + { + meaning: + "Destructive and irreversible actions, error states, invalid fields. Pair with a confirmation for anything unrecoverable.", + name: "destructive", + on: "destructive-foreground", + utilities: ["bg-destructive", "text-destructive", "border-destructive"], + }, + { + meaning: "Text and icons on a destructive fill.", + name: "destructive-foreground", + utilities: ["text-destructive-foreground"], + }, + { + meaning: "Successful outcomes and healthy status — completed steps, live deployments.", + name: "success", + on: "success-foreground", + utilities: ["bg-success", "text-success"], + }, + { + meaning: "Text and icons on a success fill.", + name: "success-foreground", + utilities: ["text-success-foreground"], + }, + ], + }, + { + description: + "Hairlines, control outlines, and focus. These are the only tokens allowed to draw structure — do not fake borders with a background color.", + title: "Borders and focus", + tokens: [ + { + meaning: + "Default hairline between surfaces. Applied globally by the base layer, so most elements inherit it without a border utility.", + name: "border", + utilities: ["border-border"], + }, + { + meaning: "Outline of form controls — inputs, textareas, selects, checkboxes.", + name: "input", + utilities: ["border-input"], + }, + { + meaning: + "Keyboard focus ring. The base layer renders it at 50% opacity (`outline-ring/50`), so focus reads clearly without shouting.", + name: "ring", + utilities: ["ring-ring", "outline-ring/50"], + }, + ], + }, + { + description: + "The sidebar runs its own surface stack so navigation can go darker than the app without dragging the rest of the UI with it.", + title: "Sidebar", + tokens: [ + { + meaning: "Sidebar background. Pure black in dark mode, pinning navigation to the far edge.", + name: "sidebar", + on: "sidebar-foreground", + utilities: ["bg-sidebar"], + }, + { + meaning: "Sidebar labels and icons.", + name: "sidebar-foreground", + utilities: ["text-sidebar-foreground"], + }, + { + meaning: + "Active navigation item. Deliberately neutral rather than brand blue so the sidebar does not compete with in-page primary actions.", + name: "sidebar-primary", + on: "sidebar-primary-foreground", + utilities: ["bg-sidebar-primary"], + }, + { + meaning: "Text on an active navigation item.", + name: "sidebar-primary-foreground", + utilities: ["text-sidebar-primary-foreground"], + }, + { + meaning: "Hovered navigation item.", + name: "sidebar-accent", + on: "sidebar-accent-foreground", + utilities: ["bg-sidebar-accent"], + }, + { + meaning: "Text on a hovered navigation item.", + name: "sidebar-accent-foreground", + utilities: ["text-sidebar-accent-foreground"], + }, + { + meaning: "Dividers inside the sidebar and the seam against the app canvas.", + name: "sidebar-border", + utilities: ["border-sidebar-border"], + }, + { + meaning: "Focus ring for sidebar controls.", + name: "sidebar-ring", + utilities: ["ring-sidebar-ring"], + }, + ], + }, + { + description: + "Categorical series colors, ordered by how they should be assigned. Use them in sequence so the same series index keeps the same color across charts.", + title: "Data visualization", + tokens: [ + { + meaning: "First series — the metric the chart is about.", + name: "chart-1", + utilities: ["fill-chart-1", "stroke-chart-1"], + }, + { + meaning: "Second series.", + name: "chart-2", + utilities: ["fill-chart-2", "stroke-chart-2"], + }, + { + meaning: "Third series.", + name: "chart-3", + utilities: ["fill-chart-3", "stroke-chart-3"], + }, + { + meaning: "Fourth series.", + name: "chart-4", + utilities: ["fill-chart-4", "stroke-chart-4"], + }, + { + meaning: "Fifth series. Beyond five categories, group the tail into an “Other” bucket.", + name: "chart-5", + utilities: ["fill-chart-5", "stroke-chart-5"], + }, + ], + }, +]; + +export const BRAND_SCALES: BrandScale[] = [ + { + meaning: + "The Voidhash brand hue. Step 600 is `--primary`, step 500 is `--ring`. Lighter steps back tinted surfaces; darker steps are for text on tinted backgrounds.", + prefix: "blue-ribbon", + title: "Blue Ribbon", + }, + { + meaning: + "Secondary brand hue. Used for the second chart series and for AI/agent surfaces that need to read as distinct from primary actions.", + prefix: "electric-violet", + title: "Electric Violet", + }, + { + meaning: "Third chart series and accent illustrations. Not used for interactive states.", + prefix: "fuchsia-pink", + title: "Fuchsia Pink", + }, + { + meaning: + "Danger. Step 600 is `--destructive` in light mode, step 500 in dark mode where the surface is darker.", + prefix: "radical-red", + title: "Radical Red", + }, + { + meaning: + "Reserved for high-urgency, non-destructive states. No semantic token maps to it yet, so reference the scale directly and document the usage.", + prefix: "blaze-orange", + title: "Blaze Orange", + }, + { + meaning: + "Warnings and pending states — there is no `--warning` token, so use `amber-500`/`amber-600` when you need caution without danger. Also the fourth chart series.", + prefix: "amber", + title: "Amber", + }, + { + meaning: "Success and healthy status. Step 600 is `--success`; step 500 is the fifth series.", + prefix: "pistachio", + title: "Pistachio", + }, + { + meaning: + "The neutral ramp every surface, border, and text token is built from. Light mode maps 50–200 to surfaces and 500–900 to text; dark mode inverts that.", + prefix: "zinc", + title: "Zinc", + }, +]; 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..383abf71e --- /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..db97fc696 --- /dev/null +++ b/apps/www/src/features/design/components/docs/component-overview/cards/kitchen-island.tsx @@ -0,0 +1,161 @@ +"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 +