diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml
new file mode 100644
index 0000000..0261375
--- /dev/null
+++ b/.github/workflows/chromatic.yml
@@ -0,0 +1,27 @@
+name: "Chromatic Publish"
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - "packages/shared-ui/**"
+ - ".storybook/**"
+
+jobs:
+ publish-chromatic:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: "pnpm"
+ - run: pnpm install --frozen-lockfile
+ - uses: chromaui/action@latest
+ with:
+ projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
+ token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.storybook/css.d.ts b/.storybook/css.d.ts
new file mode 100644
index 0000000..cbe652d
--- /dev/null
+++ b/.storybook/css.d.ts
@@ -0,0 +1 @@
+declare module "*.css";
diff --git a/.storybook/main.ts b/.storybook/main.ts
index 1312f45..b663a71 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -5,12 +5,20 @@ import path from "path";
const projectRoot = path.resolve(process.cwd());
const config: StorybookConfig = {
- stories: ["../packages/shared-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
+ stories: [
+ {
+ directory: "../packages/shared-ui/src",
+ files: "**/*.stories.@(js|jsx|mjs|ts|tsx)",
+ },
+ ],
addons: ["@storybook/addon-essentials"],
framework: {
name: "@storybook/react-vite",
options: {},
},
+ docs: {
+ autodocs: true,
+ },
staticDirs: ["../apps/landing/public"],
async viteFinal(config) {
const webRoot = path.resolve(projectRoot, "apps/landing");
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index e200451..705218e 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -1,12 +1,19 @@
-import React from "react";
-
import type { Preview } from "@storybook/react";
+import { AppModalProvider } from "../packages/shared-ui/src/components/app-modal";
import "../packages/shared-ui/src/global.css";
import "./preview.css";
const preview: Preview = {
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
parameters: {
+ layout: "centered",
controls: {
matchers: {
color: /(background|color)$/i,
@@ -18,7 +25,7 @@ const preview: Preview = {
values: [
{
name: "light",
- value: "oklch(0.9843 0.0017 247.8393)",
+ value: "#ffffff",
},
{
name: "dark",
@@ -27,22 +34,6 @@ const preview: Preview = {
],
},
},
- decorators: [
- (Story) => (
-
-
-
- ),
- ],
};
export default preview;
diff --git a/.storybook/tsconfig.json b/.storybook/tsconfig.json
new file mode 100644
index 0000000..ca4676d
--- /dev/null
+++ b/.storybook/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["./**/*.ts", "./**/*.tsx", "./**/*.d.ts"]
+}
diff --git a/package.json b/package.json
index 8c87b34..c4a375d 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,8 @@
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
"build:packages": "pnpm --recursive --filter './packages/*' run build",
"storybook": "storybook dev -p 6006",
- "build-storybook": "storybook build"
+ "build:storybook": "storybook build",
+ "chromatic": "chromatic --build-script-name build:storybook"
},
"devDependencies": {
"@changesets/cli": "^2.31.0",
@@ -21,9 +22,14 @@
"@tailwindcss/postcss": "^4",
"@tailwindcss/vite": "^4",
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
+ "@types/react": "19.2.15",
+ "@types/react-dom": "19.2.3",
+ "chromatic": "^18.5.0",
"eslint-plugin-storybook": "^10.1.10",
"prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
"storybook": "^8.6.15",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json
index 3745a11..b7f922f 100644
--- a/packages/shared-ui/package.json
+++ b/packages/shared-ui/package.json
@@ -8,9 +8,7 @@
"build": "tsup && tsc --project tsconfig.build.json && tailwindcss -i ./src/global.css -o ./dist/output.css --minify && for f in dist/index.mjs dist/index.js; do { printf '\"use client\";\\n'; cat \"$f\"; } > \"${f}.tmp\" && mv \"${f}.tmp\" \"$f\"; done",
"build:css": "tailwindcss -i ./src/global.css -o ./dist/output.css --minify",
"dev": "tsup --watch",
- "type-check": "tsc --noEmit",
- "storybook": "storybook dev -p 6006",
- "build-storybook": "storybook build"
+ "type-check": "tsc --noEmit"
},
"exports": {
".": {
@@ -33,8 +31,17 @@
},
"dependencies": {
"@base-ui/react": "^1.5.0",
+ "canvg": "^4.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "countries-list": "^3.4.1",
+ "country-flag-icons": "^1.6.20",
+ "framer-motion": "^12.43.0",
+ "heic-to": "^1.5.2",
+ "libphonenumber-js": "^1.13.11",
+ "react-dropzone": "^20.1.1",
+ "react-syntax-highlighter": "^16.1.1",
+ "react-useanimations": "^2.10.0",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
@@ -42,6 +49,7 @@
"@tailwindcss/postcss": "^4",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/react-syntax-highlighter": "^15.5.13",
"tailwindcss": "^4",
"tsup": "^8",
"typescript": "^5"
diff --git a/packages/shared-ui/src/button.tsx b/packages/shared-ui/src/button.tsx
deleted file mode 100644
index ca58f87..0000000
--- a/packages/shared-ui/src/button.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { Button as ButtonPrimitive } from "@base-ui/react/button";
-import { type VariantProps, cva } from "class-variance-authority";
-
-import { cn } from "./cn";
-
-const buttonVariants = cva(
- "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
- {
- variants: {
- variant: {
- default: "bg-primary text-primary-foreground hover:bg-primary/80",
- outline:
- "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
- secondary:
- "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
- ghost:
- "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
- destructive:
- "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
- link: "text-primary underline-offset-4 hover:underline",
- },
- size: {
- default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
- xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
- sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
- lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
- icon: "size-8",
- "icon-xs":
- "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
- "icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
- "icon-lg": "size-9",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
-);
-
-function Button({
- className,
- variant = "default",
- size = "default",
- ...props
-}: ButtonPrimitive.Props & VariantProps) {
- return ;
-}
-
-export { Button, buttonVariants };
diff --git a/packages/shared-ui/src/components/app-modal/app-modal.stories.tsx b/packages/shared-ui/src/components/app-modal/app-modal.stories.tsx
new file mode 100644
index 0000000..12eac23
--- /dev/null
+++ b/packages/shared-ui/src/components/app-modal/app-modal.stories.tsx
@@ -0,0 +1,190 @@
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { Button } from "../../ui/button"
+import { Input } from "../../ui/input"
+import { AppModal } from "./index"
+
+const meta: Meta = {
+ title: "Components/AppModal",
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Small: Story = {
+ render: () => (
+
+ ),
+}
+
+export const Medium: Story = {
+ render: () => (
+
+ ),
+}
+
+export const Full: Story = {
+ render: () => (
+
+ ),
+}
+
+export const CustomFooter: Story = {
+ render: () => (
+
+ ),
+}
+
+export const Destructive: Story = {
+ render: () => (
+
+ AppModal.open({
+ title: "Delete this rule?",
+ description:
+ "This can’t be undone. Live traffic using this rule will fall back to the default route.",
+ primaryButton: {
+ children: "Delete rule",
+ variant: "destructive",
+ onClick: () => AppModal.close(),
+ },
+ secondaryButton: { children: "Keep rule" },
+ })
+ }
+ >
+ Open delete confirmation
+
+ ),
+}
+
+export const NoCloseButton: Story = {
+ render: () => (
+
+ AppModal.open({
+ showCloseButton: false,
+ title: "Processing payout",
+ description:
+ "Don’t close this window until the export finishes. You’ll get a download when it’s ready.",
+ primaryButton: {
+ children: "Stay on this page",
+ onClick: () => AppModal.close(),
+ },
+ })
+ }
+ >
+ Open (no X button)
+
+ ),
+}
+
+export const Steps: Story = {
+ render: () => (
+
+ AppModal.open({
+ step: "Step 1 of 2",
+ title: "Modal title",
+ description:
+ "Body copy sits here. Footer keeps one primary and a quiet secondary.",
+ primaryButton: {
+ children: "Continue",
+ onClick: () =>
+ AppModal.updateConfig({
+ step: "Step 2 of 2",
+ title: "Almost done",
+ description: "Confirm to finish this flow.",
+ primaryButton: {
+ children: "Done",
+ onClick: () => AppModal.close(),
+ },
+ }),
+ },
+ secondaryButton: { children: "Cancel" },
+ })
+ }
+ >
+ Open step flow
+
+ ),
+}
diff --git a/packages/shared-ui/src/components/app-modal/index.tsx b/packages/shared-ui/src/components/app-modal/index.tsx
new file mode 100644
index 0000000..3ce1555
--- /dev/null
+++ b/packages/shared-ui/src/components/app-modal/index.tsx
@@ -0,0 +1,235 @@
+import {
+ useCallback,
+ useEffect,
+ useState,
+ type ReactNode,
+} from "react"
+import { AnimatePresence, motion } from "framer-motion"
+
+import { CloseX } from "../../icons"
+import { cn } from "../../cn"
+import { Button, type ButtonProps } from "../../ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "../../ui/dialog"
+
+type ModalButtonProps = ButtonProps & {
+ children: ReactNode
+}
+
+export interface AppModalOptions {
+ title: string
+ step?: ReactNode
+ description?: string
+ content?: ReactNode
+ footer?: ReactNode
+ primaryButton?: ModalButtonProps
+ secondaryButton?: ModalButtonProps
+ size?: "small" | "medium" | "full"
+ showCloseButton?: boolean
+ onClose?: () => void
+}
+
+type ModalState = { open: boolean; config: AppModalOptions | null }
+type SetModalState = (value: ModalState | ((prev: ModalState) => ModalState)) => void
+
+let setGlobalState: SetModalState | null = null
+
+export const AppModal = {
+ open: (options: AppModalOptions) =>
+ setGlobalState?.({ open: true, config: options }),
+ close: () => setGlobalState?.((prev) => ({ ...prev, open: false })),
+ updateConfig: (partial: Partial) =>
+ setGlobalState?.((prev) =>
+ prev.config ? { ...prev, config: { ...prev.config, ...partial } } : prev
+ ),
+}
+
+export interface AppModalUIProps extends AppModalOptions {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onExited?: () => void
+}
+
+const sizeStyles = {
+ small: "max-h-[90vh] sm:max-w-md",
+ medium: "max-h-[90vh] sm:max-w-2xl",
+ full: "h-full max-h-none w-full max-w-none rounded-none sm:max-w-none",
+}
+
+function AppModalUI({
+ open,
+ onOpenChange,
+ title,
+ step,
+ description,
+ content,
+ footer,
+ primaryButton,
+ secondaryButton,
+ size = "small",
+ showCloseButton = true,
+ onExited,
+}: AppModalUIProps) {
+ const [present, setPresent] = useState(open)
+
+ useEffect(() => {
+ if (open) setPresent(true)
+ }, [open])
+
+ const generatedFooter =
+ footer ??
+ ((primaryButton || secondaryButton) ? (
+ <>
+ {primaryButton ? : null}
+ {secondaryButton ? (
+
+ ) : null}
+ >
+ ) : null)
+
+ return (
+
+ )
+}
+
+export function AppModalProvider({ children }: { children: ReactNode }) {
+ const [state, setState] = useState({
+ open: false,
+ config: null,
+ })
+
+ useEffect(() => {
+ setGlobalState = setState
+ return () => {
+ setGlobalState = null
+ }
+ }, [])
+
+ const close = useCallback(() => {
+ setState((prev) => {
+ prev.config?.onClose?.()
+ return { ...prev, open: false }
+ })
+ }, [])
+
+ return (
+ <>
+ {children}
+ {state.config ? (
+ {
+ if (!nextOpen) close()
+ }}
+ onExited={() => setState({ open: false, config: null })}
+ secondaryButton={
+ state.config.secondaryButton
+ ? {
+ ...state.config.secondaryButton,
+ onClick: (event) => {
+ state.config?.secondaryButton?.onClick?.(event)
+ close()
+ },
+ }
+ : undefined
+ }
+ />
+ ) : null}
+ >
+ )
+}
+
+export { AppModalUI }
+export type { ModalButtonProps }
diff --git a/packages/shared-ui/src/components/code-block/code-block.stories.tsx b/packages/shared-ui/src/components/code-block/code-block.stories.tsx
new file mode 100644
index 0000000..c975b70
--- /dev/null
+++ b/packages/shared-ui/src/components/code-block/code-block.stories.tsx
@@ -0,0 +1,70 @@
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { CodeBlock } from "./index"
+
+const jsonSnippet = `{
+ "plan_tier":
+ "enterprise"
+}`
+
+const tsSnippet = `export const route = {
+ provider: "stripe",
+ weight: 60,
+}`
+
+const meta: Meta = {
+ title: "Components/CodeBlock",
+ component: CodeBlock,
+ parameters: {
+ layout: "padded",
+ },
+ args: {
+ language: "json",
+ children: jsonSnippet,
+ className: "w-full",
+ },
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const Hover: Story = {
+ args: { copyState: "hover" },
+}
+
+export const Copied: Story = {
+ args: { copyState: "copied" },
+}
+
+export const WithFilename: Story = {
+ args: {
+ filename: "route.json",
+ language: "json",
+ },
+}
+
+export const TypeScript: Story = {
+ args: {
+ language: "typescript",
+ filename: "route.ts",
+ children: tsSnippet,
+ },
+}
+
+export const Shell: Story = {
+ args: {
+ language: "bash",
+ children: "pnpm add @payroutes/shared-ui",
+ },
+}
+
+export const Scrollable: Story = {
+ args: {
+ maxHeight: "10rem",
+ language: "typescript",
+ children: `${tsSnippet}\n${tsSnippet}\n${tsSnippet}\n${tsSnippet}`,
+ },
+}
diff --git a/packages/shared-ui/src/components/code-block/index.tsx b/packages/shared-ui/src/components/code-block/index.tsx
new file mode 100644
index 0000000..3c157eb
--- /dev/null
+++ b/packages/shared-ui/src/components/code-block/index.tsx
@@ -0,0 +1,213 @@
+import { useMemo, type ComponentProps } from "react"
+import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter"
+import bash from "react-syntax-highlighter/dist/esm/languages/prism/bash"
+import json from "react-syntax-highlighter/dist/esm/languages/prism/json"
+import tsx from "react-syntax-highlighter/dist/esm/languages/prism/tsx"
+import typescript from "react-syntax-highlighter/dist/esm/languages/prism/typescript"
+import {
+ oneDark,
+ oneLight,
+} from "react-syntax-highlighter/dist/esm/styles/prism"
+
+import { Check, Copy } from "../../icons"
+import { useCopy } from "../../hooks/use-copy"
+import { type MixinProps, splitProps } from "../../lib/mixin"
+import { cn } from "../../cn"
+import { ScrollArea, ScrollBar } from "../../ui/scroll-area"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "../../ui/tooltip"
+
+SyntaxHighlighter.registerLanguage("tsx", tsx)
+SyntaxHighlighter.registerLanguage("typescript", typescript)
+SyntaxHighlighter.registerLanguage("bash", bash)
+SyntaxHighlighter.registerLanguage("json", json)
+
+type Language = "tsx" | "typescript" | "bash" | "json" | "shell" | "sh" | "zsh"
+
+export interface CodeBlockProps
+ extends MixinProps<"container", ComponentProps<"div">>,
+ MixinProps<"copy", Omit, "children" | "onClick">> {
+ language?: Language
+ children: string
+ filename?: string
+ logo?: string
+ showCopyButton?: boolean
+ maxHeight?: string | "none"
+ theme?: "light" | "dark"
+ className?: string
+ copyState?: "hover" | "copied"
+}
+
+function CopyAction({
+ copied,
+ onClick,
+ className,
+ "data-state": dataState,
+ ...props
+}: {
+ copied: boolean
+ onClick: () => void
+ className?: string
+ "data-state"?: "hover" | "copied"
+} & Omit, "children" | "onClick" | "className">) {
+ const isCopied = copied || dataState === "copied"
+
+ return (
+
+
+ }
+ >
+ {isCopied ? (
+
+ ) : (
+
+ )}
+
+ {isCopied ? "Copied!" : "Copy code"}
+
+ )
+}
+
+function CodeBlock({
+ language = "tsx",
+ children,
+ filename,
+ logo,
+ showCopyButton = true,
+ maxHeight = "none",
+ theme = "dark",
+ className,
+ copyState,
+ ...mixProps
+}: CodeBlockProps) {
+ const { copied, copy } = useCopy()
+ const { container, copy: copyProps } = splitProps(mixProps, "container", "copy")
+
+ const isDark = theme === "dark"
+ const showHeader = Boolean(filename || logo)
+ const syntaxLanguage = language === "shell" || language === "sh" || language === "zsh" ? "bash" : language
+
+ const syntaxTheme = useMemo(() => {
+ const base = isDark ? oneDark : oneLight
+ const bg = isDark ? "var(--code)" : "var(--muted)"
+
+ return {
+ ...base,
+ 'pre[class*="language-"]': {
+ ...base['pre[class*="language-"]'],
+ background: bg,
+ margin: 0,
+ padding: "1.25rem",
+ minWidth: "100%",
+ width: "max-content",
+ overflow: "visible",
+ },
+ 'code[class*="language-"]': {
+ ...base['code[class*="language-"]'],
+ background: "transparent",
+ fontSize: "0.875rem",
+ fontFamily: "var(--font-mono), monospace",
+ },
+ }
+ }, [isDark])
+
+ return (
+
+
+ {showHeader ? (
+
+
+ {logo ? (
+

+ ) : null}
+ {filename ? (
+
+ {filename}
+
+ ) : null}
+
+ {showCopyButton ? (
+
copy(children)}
+ data-state={copyState}
+ {...copyProps}
+ />
+ ) : null}
+
+ ) : null}
+
+
+
+ {children.trim()}
+
+
+
+ {!showHeader && showCopyButton ? (
+
+ copy(children)}
+ data-state={copyState}
+ {...copyProps}
+ />
+
+ ) : null}
+
+
+
+ )
+}
+
+export { CodeBlock, CopyAction }
+export type { Language }
diff --git a/packages/shared-ui/src/components/conditional-field-row/conditional-field-row.stories.tsx b/packages/shared-ui/src/components/conditional-field-row/conditional-field-row.stories.tsx
new file mode 100644
index 0000000..9e64fa1
--- /dev/null
+++ b/packages/shared-ui/src/components/conditional-field-row/conditional-field-row.stories.tsx
@@ -0,0 +1,52 @@
+import { useState } from "react"
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { Input } from "../../ui/input"
+import { EmbeddedFieldRow } from "../field-stack"
+import { ConditionalFieldRow } from "./index"
+
+const pricingOptions = [
+ { value: "one-time", label: "One-time" },
+ { value: "recurring", label: "Recurring" },
+]
+
+const meta: Meta = {
+ title: "Components/ConditionalFieldRow",
+ component: ConditionalFieldRow,
+ args: {
+ className: "w-80",
+ label: "Pricing type",
+ options: pricingOptions,
+ when: "recurring",
+ },
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {
+ render: function DefaultStory(args) {
+ const [value, setValue] = useState("one-time")
+ return (
+ setValue(next ?? "one-time")}
+ >
+ Free trial
+
+
+ )
+ },
+}
+
+export const Recurring: Story = {
+ args: { defaultValue: "recurring" },
+ render: (args) => (
+
+ Free trial
+
+
+ ),
+}
diff --git a/packages/shared-ui/src/components/conditional-field-row/index.tsx b/packages/shared-ui/src/components/conditional-field-row/index.tsx
new file mode 100644
index 0000000..b86b227
--- /dev/null
+++ b/packages/shared-ui/src/components/conditional-field-row/index.tsx
@@ -0,0 +1,76 @@
+import { useState, type ComponentProps, type ReactNode } from "react"
+
+import { cn } from "../../cn"
+import { Label } from "../../ui/label"
+import { Tabs, TabsList, TabsTrigger } from "../../ui/tabs"
+import { EmbeddedFieldRow } from "../field-stack"
+
+export interface ConditionalFieldRowOption {
+ value: string
+ label: ReactNode
+}
+
+export interface ConditionalFieldRowProps extends Omit, "children"> {
+ label?: ReactNode
+ options: ConditionalFieldRowOption[]
+ value?: string
+ defaultValue?: string
+ onValueChange?: (value: string | null) => void
+ when?: string
+ children?: ReactNode
+}
+
+function ConditionalFieldRow({
+ label,
+ options,
+ value,
+ defaultValue,
+ onValueChange,
+ when,
+ children,
+ className,
+ ...props
+}: ConditionalFieldRowProps) {
+ const isControlled = value !== undefined
+ const [internal, setInternal] = useState(defaultValue ?? options[0]?.value ?? "")
+ const selected = isControlled ? value : internal
+ const showNested = Boolean(children) && (when ? selected === when : false)
+
+ return (
+
+ {label ? (
+
+ ) : null}
+
+ {
+ if (!isControlled) setInternal(String(next ?? ""))
+ onValueChange?.(next == null ? null : String(next))
+ }}
+ >
+
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ {children}
+
+
+ )
+}
+
+export { ConditionalFieldRow }
diff --git a/packages/shared-ui/src/components/field-stack/field-stack.stories.tsx b/packages/shared-ui/src/components/field-stack/field-stack.stories.tsx
new file mode 100644
index 0000000..870a4c8
--- /dev/null
+++ b/packages/shared-ui/src/components/field-stack/field-stack.stories.tsx
@@ -0,0 +1,124 @@
+import { useState } from "react"
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { Input } from "../../ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../../ui/select"
+import { EmbeddedFieldRow, FieldStack } from "./index"
+
+const operators = [
+ { value: "gt", label: "Is greater than" },
+ { value: "lt", label: "Is less than" },
+ { value: "between", label: "Is between" },
+] as const
+
+function OperatorSelect({
+ defaultValue,
+ value,
+ onValueChange,
+}: {
+ defaultValue?: string
+ value?: string
+ onValueChange?: (value: string | null) => void
+}) {
+ return (
+
+ )
+}
+
+function BetweenFields() {
+ return (
+ <>
+
+ Minimum
+
+
+
+ Maximum
+
+
+
+ Both bounds are inclusive.
+
+ >
+ )
+}
+
+const meta: Meta = {
+ title: "Components/FieldStack",
+ component: FieldStack,
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {
+ render: () => (
+
+
+
+
+
+
+
+ ),
+}
+
+export const Stack: Story = {
+ render: () => (
+
+
+
+
+
+
+ ),
+}
+
+export const Inline: Story = {
+ render: () => (
+
+
+
+
+ or more
+
+
+ ),
+}
+
+export const Conditional: Story = {
+ render: function ConditionalStory() {
+ const [operator, setOperator] = useState("between")
+
+ return (
+
+ setOperator(value ?? "gt")} />
+
+
+
+
+ )
+ },
+}
diff --git a/packages/shared-ui/src/components/field-stack/index.tsx b/packages/shared-ui/src/components/field-stack/index.tsx
new file mode 100644
index 0000000..ec32c9e
--- /dev/null
+++ b/packages/shared-ui/src/components/field-stack/index.tsx
@@ -0,0 +1,93 @@
+import type { ComponentProps, ReactNode } from "react"
+
+import { cn } from "../../cn"
+
+export interface FieldStackProps extends ComponentProps<"div"> {
+ children: ReactNode
+}
+
+function FieldStack({ children, className, ...props }: FieldStackProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export interface EmbeddedFieldRowProps extends ComponentProps<"div"> {
+ when: boolean
+ children: ReactNode
+ layout?: "inline" | "stack"
+}
+
+function EmbeddedFieldRowRoot({
+ when,
+ children,
+ layout = "inline",
+ className,
+ ...props
+}: EmbeddedFieldRowProps) {
+ if (!when) return null
+
+ return (
+
+ )
+}
+
+function EmbeddedFieldRowLabel({
+ className,
+ ...props
+}: ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function EmbeddedFieldRowSuffix({
+ className,
+ ...props
+}: ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+const EmbeddedFieldRow = Object.assign(EmbeddedFieldRowRoot, {
+ Label: EmbeddedFieldRowLabel,
+ Suffix: EmbeddedFieldRowSuffix,
+})
+
+export { FieldStack, EmbeddedFieldRow }
diff --git a/packages/shared-ui/src/components/file-upload/file-upload.stories.tsx b/packages/shared-ui/src/components/file-upload/file-upload.stories.tsx
new file mode 100644
index 0000000..368b00e
--- /dev/null
+++ b/packages/shared-ui/src/components/file-upload/file-upload.stories.tsx
@@ -0,0 +1,70 @@
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { FileUpload, type FileWithPreview } from "./index"
+
+const sampleFile = {
+ name: "pro-plan-hero.png",
+ size: 1.2 * 1024 * 1024,
+ type: "image/png",
+ width: 1600,
+ height: 900,
+} as FileWithPreview
+
+const meta: Meta = {
+ title: "Components/FileUpload",
+ component: FileUpload,
+ args: {
+ className: "w-80",
+ },
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const Hover: Story = {
+ args: { "data-state": "hover" },
+}
+
+export const Drag: Story = {
+ args: { "data-state": "drag" },
+}
+
+export const Error: Story = {
+ args: { error: "That file is over 2MB. Try a smaller one." },
+}
+
+export const Disabled: Story = {
+ args: { disabled: true },
+}
+
+export const Uploading: Story = {
+ args: {
+ status: "uploading",
+ progress: 44,
+ value: sampleFile,
+ },
+}
+
+export const Success: Story = {
+ args: {
+ status: "success",
+ value: sampleFile,
+ },
+}
+
+export const States: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+ ),
+}
diff --git a/packages/shared-ui/src/components/file-upload/index.tsx b/packages/shared-ui/src/components/file-upload/index.tsx
new file mode 100644
index 0000000..2ac0c0b
--- /dev/null
+++ b/packages/shared-ui/src/components/file-upload/index.tsx
@@ -0,0 +1,365 @@
+import { useCallback, useEffect, useState, type ComponentProps, type ReactNode } from "react"
+import { type DropzoneOptions, type FileRejection, useDropzone } from "react-dropzone"
+
+import { ArrowUp, CloseX, Image, TriangleAlert } from "../../icons"
+import { ImageTransformer, type MimeType, type TransformOptions } from "../../integrations/image-transformer"
+import { type MixinProps, splitProps } from "../../lib/mixin"
+import { cn } from "../../cn"
+import { Spinner } from "../spinner"
+
+export interface FileWithPreview extends File {
+ preview?: string
+ width?: number
+ height?: number
+}
+
+type FileUploadState = "hover" | "drag"
+
+export interface FileUploadProps
+ extends MixinProps<"dropzone", Omit & { className?: string }>,
+ MixinProps<"label", Omit, "children">>,
+ MixinProps<"error", Omit, "children">> {
+ id?: string
+ value?: FileWithPreview | null
+ onFileChange?: (file: FileWithPreview | null) => void
+ onFileRejected?: (rejections: FileRejection[]) => void
+ onCancel?: () => void
+ onRemove?: () => void
+ progress?: number
+ status?: "idle" | "uploading" | "success"
+ hint?: string
+ error?: ReactNode
+ label?: ReactNode
+ disabled?: boolean
+ className?: string
+ enableTransformation?: boolean
+ targetFormat?: MimeType
+ maxDimension?: number
+ "data-state"?: FileUploadState
+}
+
+function formatBytes(bytes: number) {
+ if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))}KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
+}
+
+async function toFileWithPreview(file: File): Promise {
+ const preview = URL.createObjectURL(file)
+ const next = Object.assign(file, { preview }) as FileWithPreview
+
+ await new Promise((resolve) => {
+ const image = new window.Image()
+ image.onload = () => {
+ next.width = image.naturalWidth
+ next.height = image.naturalHeight
+ resolve()
+ }
+ image.onerror = () => resolve()
+ image.src = preview
+ })
+
+ return next
+}
+
+function BrowseLabel({ className }: { className?: string }) {
+ return (
+
+ browse
+
+ )
+}
+
+function DropHint({
+ title,
+ hint,
+ icon,
+ titleClassName,
+ hintClassName,
+}: {
+ title: ReactNode
+ hint?: ReactNode
+ icon: ReactNode
+ titleClassName?: string
+ hintClassName?: string
+}) {
+ return (
+
+ {icon}
+
{title}
+ {hint ?
{hint}
: null}
+
+ )
+}
+
+function FileUpload({
+ id,
+ value,
+ onFileChange,
+ onFileRejected,
+ onCancel,
+ onRemove,
+ progress = 0,
+ status,
+ hint = "PNG or JPG, up to 2MB",
+ error,
+ label,
+ disabled = false,
+ className,
+ enableTransformation = false,
+ targetFormat = "image/png",
+ maxDimension,
+ "data-state": dataState,
+ ...mixProps
+}: FileUploadProps) {
+ const { dropzone, label: labelProps, error: errorProps } = splitProps(
+ mixProps,
+ "dropzone",
+ "label",
+ "error"
+ )
+ const isControlled = value !== undefined
+ const [internalFile, setInternalFile] = useState(null)
+ const [rejectionError, setRejectionError] = useState(null)
+ const [isTransforming, setIsTransforming] = useState(false)
+
+ const file = isControlled ? value : internalFile
+ const displayError = error ?? rejectionError
+ const resolvedStatus = status ?? (file ? "success" : "idle")
+
+ const commitFile = useCallback(
+ (next: FileWithPreview | null) => {
+ if (!isControlled) setInternalFile(next)
+ onFileChange?.(next)
+ },
+ [isControlled, onFileChange]
+ )
+
+ const onDrop = useCallback(
+ async (acceptedFiles: File[], fileRejections: FileRejection[]) => {
+ if (fileRejections.length > 0) {
+ const tooLarge = fileRejections.some((rejection) =>
+ rejection.errors.some((item) => item.code === "file-too-large")
+ )
+ setRejectionError(
+ tooLarge
+ ? "That file is over 2MB. Try a smaller one."
+ : "That file isn’t supported. Try a PNG or JPG."
+ )
+ onFileRejected?.(fileRejections)
+ if (acceptedFiles.length === 0) return
+ } else {
+ setRejectionError(null)
+ }
+
+ const source = acceptedFiles[0]
+ if (!source) return
+
+ setIsTransforming(true)
+ try {
+ let nextFile = source
+ if (enableTransformation) {
+ try {
+ nextFile = await new ImageTransformer().transform(source, {
+ to: targetFormat,
+ maxDimension,
+ })
+ } catch (err) {
+ console.error("Transformation failed, falling back to original:", err)
+ }
+ }
+
+ commitFile(await toFileWithPreview(nextFile))
+ } finally {
+ setIsTransforming(false)
+ }
+ },
+ [commitFile, enableTransformation, maxDimension, onFileRejected, targetFormat]
+ )
+
+ const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
+ multiple: false,
+ accept: enableTransformation
+ ? {
+ "image/png": [".png"],
+ "image/jpeg": [".jpg", ".jpeg"],
+ "image/webp": [".webp"],
+ "image/heic": [".heic"],
+ "image/heif": [".heif"],
+ "image/svg+xml": [".svg"],
+ }
+ : {
+ "image/png": [".png"],
+ "image/jpeg": [".jpg", ".jpeg"],
+ },
+ maxSize: 2 * 1024 * 1024,
+ ...dropzone,
+ onDrop,
+ disabled: disabled || isTransforming,
+ noClick: resolvedStatus === "uploading" || resolvedStatus === "success",
+ })
+
+ useEffect(() => {
+ return () => {
+ if (file?.preview) URL.revokeObjectURL(file.preview)
+ }
+ }, [file?.preview])
+
+ const isHover = dataState === "hover"
+ const isDrag = isDragActive || dataState === "drag"
+ const isUploading = resolvedStatus === "uploading"
+ const isSuccess = resolvedStatus === "success" && !!file
+
+ return (
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+
+
+
+ {isTransforming ? (
+
+
+
+ ) : isUploading && file ? (
+
+
+
{file.name}
+
+ {Math.round(progress)}%
+ {
+ event.stopPropagation()
+ onCancel?.()
+ }}
+ >
+
+
+
+
+
+
+ ) : isSuccess && file ? (
+
+ {file.preview ? (
+

+ ) : (
+
+ )}
+
+
{file.name}
+
+ {formatBytes(file.size)}
+ {file.width && file.height ? ` - ${file.width}x${file.height}` : ""}
+
+
+
+ {
+ event.stopPropagation()
+ open()
+ }}
+ >
+ Replace
+
+ {
+ event.stopPropagation()
+ onRemove?.()
+ commitFile(null)
+ }}
+ >
+ Remove
+
+
+
+ ) : displayError ? (
+
}
+ title={displayError}
+ titleClassName="text-error"
+ hint={
+ <>
+ Drag an image here or
+ >
+ }
+ />
+ ) : (
+
+ ) : (
+
+ )
+ }
+ title={
+ isDrag ? (
+ "Drop to upload"
+ ) : (
+ <>
+ Drag an image here or
+ >
+ )
+ }
+ hint={hint}
+ titleClassName={disabled ? "text-disabled-foreground" : undefined}
+ hintClassName={disabled ? "text-disabled-foreground" : undefined}
+ />
+ )}
+
+
+ {displayError && !isUploading && !isSuccess ? (
+
+ {displayError}
+
+ ) : null}
+
+ )
+}
+
+export { FileUpload, ImageTransformer }
+export type { FileRejection, FileUploadState, MimeType, TransformOptions }
diff --git a/packages/shared-ui/src/components/phone-number-field/index.tsx b/packages/shared-ui/src/components/phone-number-field/index.tsx
new file mode 100644
index 0000000..3c8e6ee
--- /dev/null
+++ b/packages/shared-ui/src/components/phone-number-field/index.tsx
@@ -0,0 +1,369 @@
+import { Combobox } from "@base-ui/react/combobox"
+import {
+ memo,
+ useCallback,
+ useId,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ChangeEvent,
+ type ComponentProps,
+} from "react"
+import { getCountryData, type TCountryCode } from "countries-list"
+import { countries } from "country-flag-icons"
+import * as CountryFlags from "country-flag-icons/react/3x2"
+import {
+ AsYouType,
+ parsePhoneNumberWithError,
+ type CountryCode,
+} from "libphonenumber-js"
+
+import { Check, ChevronDown, Search } from "../../icons"
+import { type MixinProps, splitProps } from "../../lib/mixin"
+import { cn } from "../../cn"
+import { InputGroup, InputGroupInput } from "../../ui/input-group"
+import { Label } from "../../ui/label"
+
+export type PhoneNumber = {
+ number: string
+ countryCode: string
+}
+
+const DEFAULT_COUNTRY_CODE = "US"
+
+export const phoneNumberToString = (phoneNumber: PhoneNumber): string => {
+ try {
+ const parsed = parsePhoneNumberWithError(
+ phoneNumber.number,
+ phoneNumber.countryCode as CountryCode
+ )
+ if (parsed?.isValid()) return parsed.formatInternational()
+ } catch {}
+ const { phone } = getCountryData(phoneNumber.countryCode as TCountryCode)
+ const prefix = phone?.[0] ? `+${phone[0]}` : "+1"
+ const digits = phoneNumber.number.replace(/[^\d]/g, "")
+ return `${prefix} ${digits}`
+}
+
+export const phoneNumberFromString = (raw: string): PhoneNumber => {
+ if (!raw?.trim()) return { number: "", countryCode: DEFAULT_COUNTRY_CODE }
+
+ try {
+ const parsed = parsePhoneNumberWithError(raw)
+ if (parsed?.country) {
+ return { number: parsed.formatNational(), countryCode: parsed.country }
+ }
+ } catch {}
+
+ const countryCodeMatch = raw.match(/^(\+\d{1,4})/)
+ if (!countryCodeMatch) {
+ return {
+ number: raw.replace(/[^\d]/g, ""),
+ countryCode: DEFAULT_COUNTRY_CODE,
+ }
+ }
+
+ const prefix = countryCodeMatch[1]
+ const number = raw.slice(prefix.length).replace(/[^\d]/g, "")
+
+ if (prefix === "+1") return { number, countryCode: DEFAULT_COUNTRY_CODE }
+
+ const countryCode = countries.find((iso) => {
+ const { phone } = getCountryData(iso as TCountryCode)
+ return phone && phone.length > 0 && `+${phone[0]}` === prefix
+ })
+
+ return { number, countryCode: countryCode ?? DEFAULT_COUNTRY_CODE }
+}
+
+type CountryOption = {
+ name: string
+ prefix: string
+ countryCode: string
+ searchKey: string
+}
+
+const COUNTRIES_DATA: CountryOption[] = countries.flatMap((countryCode) => {
+ const { name, phone } = getCountryData(countryCode as TCountryCode)
+ if (!name || !phone.length) return []
+
+ return phone.map((prefix) => ({
+ name,
+ prefix: `+${prefix}`,
+ countryCode,
+ searchKey: `${name} ${countryCode} +${prefix}`.toLowerCase(),
+ }))
+})
+
+type LabelProps = ComponentProps
+type ErrorProps = ComponentProps<"p">
+type FlagProps = ComponentProps<(typeof CountryFlags)["US"]>
+
+export interface PhoneNumberFieldProps
+ extends MixinProps<"flag", Omit>,
+ MixinProps<"label", Omit>,
+ MixinProps<
+ "input",
+ Omit, "onChange" | "value">
+ >,
+ MixinProps<"error", Omit>,
+ MixinProps<"group", Omit, "children">> {
+ id?: string
+ value: PhoneNumber
+ onChange: (value: PhoneNumber) => void
+ disabled?: boolean
+ label?: LabelProps["children"] | null
+ error?: ErrorProps["children"] | null
+ defaultOpen?: boolean
+ className?: string
+ "data-state"?: "focus"
+}
+
+const flagClassName =
+ "inline-flex h-3.5 w-[21px] shrink-0 overflow-hidden rounded-[2px] ring-1 ring-border [&_svg]:block [&_svg]:size-full"
+
+const CountryFlag = memo(function CountryFlag({
+ countryCode,
+ className,
+ ...props
+}: { countryCode: string } & FlagProps) {
+ const FlagComponent =
+ CountryFlags[countryCode as TCountryCode] ?? CountryFlags.US
+
+ return (
+
+
+
+ )
+})
+
+const formatAsYouType = (raw: string, countryCode: string): string => {
+ const formatted = new AsYouType(countryCode as CountryCode).input(raw)
+ const digits = raw.replace(/\D/g, "")
+
+ if (formatted === digits && digits.length >= 6) {
+ if (digits.length <= 6) return `${digits.slice(0, 3)} ${digits.slice(3)}`
+ if (digits.length <= 9)
+ return `${digits.slice(0, 3)} ${digits.slice(3, 6)} ${digits.slice(6)}`
+ return `${digits.slice(0, 3)} ${digits.slice(3, 6)} ${digits.slice(6, 10)}`
+ }
+
+ return formatted
+}
+
+function PhoneNumberField({
+ id: idProp,
+ value,
+ onChange,
+ disabled,
+ label,
+ error,
+ defaultOpen,
+ className,
+ "data-state": dataState,
+ ...mixProps
+}: PhoneNumberFieldProps) {
+ const generatedId = useId()
+ const id = idProp ?? generatedId
+ const labelId = useId()
+ const [groupEl, setGroupEl] = useState(null)
+ const [countryOpen, setCountryOpen] = useState(false)
+ const inputRef = useRef(null)
+ const invalid = Boolean(error)
+
+ useLayoutEffect(() => {
+ if (!defaultOpen) return
+ const frame = requestAnimationFrame(() => setCountryOpen(true))
+ return () => cancelAnimationFrame(frame)
+ }, [defaultOpen])
+
+ const {
+ group,
+ label: labelProps,
+ flag,
+ input,
+ error: errorProps,
+ } = splitProps(mixProps, "label", "flag", "input", "error", "group")
+
+ const selectedCountry = useMemo(
+ () =>
+ COUNTRIES_DATA.find((country) => country.countryCode === value.countryCode) ??
+ COUNTRIES_DATA.find((country) => country.countryCode === DEFAULT_COUNTRY_CODE),
+ [value.countryCode]
+ )
+
+ const displayNumber = useMemo(
+ () => (value.number ? formatAsYouType(value.number, value.countryCode) : ""),
+ [value.number, value.countryCode]
+ )
+
+ const handleCountrySelect = useCallback(
+ (country: CountryOption | null) => {
+ if (!country) return
+ const digits = value.number.replace(/\D/g, "")
+ const number = digits ? formatAsYouType(digits, country.countryCode) : ""
+ onChange({ countryCode: country.countryCode, number })
+ requestAnimationFrame(() => inputRef.current?.focus())
+ },
+ [onChange, value.number]
+ )
+
+ const handleNumberChange = useCallback(
+ (event: ChangeEvent) => {
+ const raw = event.target.value
+ const newDigits = raw.replace(/\D/g, "")
+ const prevDigits = displayNumber.replace(/\D/g, "")
+ const digits =
+ newDigits.length === prevDigits.length && raw.length < displayNumber.length
+ ? newDigits.slice(0, -1)
+ : newDigits
+
+ onChange({
+ ...value,
+ number: digits ? formatAsYouType(digits, value.countryCode) : "",
+ })
+ },
+ [onChange, value, displayNumber]
+ )
+
+ return (
+
+ {label ? (
+
+ ) : null}
+
item.searchKey}
+ isItemEqualToValue={(a, b) =>
+ a.countryCode === b.countryCode && a.prefix === b.prefix
+ }
+ >
+
+
+
+
+ {selectedCountry?.prefix || "+1"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No countries found
+
+
+ {(item: CountryOption) => (
+
+
+ {item.prefix}
+ {item.name}
+
+
+
+
+ )}
+
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ )
+}
+
+export { PhoneNumberField, COUNTRIES_DATA }
+export type { CountryOption }
diff --git a/packages/shared-ui/src/components/phone-number-field/phone-number-field.stories.tsx b/packages/shared-ui/src/components/phone-number-field/phone-number-field.stories.tsx
new file mode 100644
index 0000000..4b588cf
--- /dev/null
+++ b/packages/shared-ui/src/components/phone-number-field/phone-number-field.stories.tsx
@@ -0,0 +1,73 @@
+import { useId, useState, type ComponentProps } from "react"
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { PhoneNumberField, type PhoneNumber } from "./index"
+
+const emptyValue: PhoneNumber = { number: "", countryCode: "US" }
+
+function PhoneFieldDemo(
+ props: Omit, "value" | "onChange">
+) {
+ const id = useId()
+ const [value, setValue] = useState(emptyValue)
+ return (
+
+ )
+}
+
+const meta: Meta = {
+ title: "Components/PhoneNumberField",
+ component: PhoneNumberField,
+ args: {
+ className: "w-72",
+ label: "Phone Number",
+ },
+ render: (args) => ,
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const Focused: Story = {
+ args: { "data-state": "focus" },
+}
+
+export const Invalid: Story = {
+ args: {
+ error: "Error message",
+ },
+}
+
+export const Disabled: Story = {
+ args: { disabled: true },
+}
+
+export const CountryOpen: Story = {
+ parameters: {
+ docs: { disable: true },
+ },
+ render: (args) => (
+
+ ),
+}
+
+export const States: Story = {
+ render: () => (
+
+ ),
+}
diff --git a/packages/shared-ui/src/components/resource-field/index.tsx b/packages/shared-ui/src/components/resource-field/index.tsx
new file mode 100644
index 0000000..0ec6624
--- /dev/null
+++ b/packages/shared-ui/src/components/resource-field/index.tsx
@@ -0,0 +1,302 @@
+import {
+ useMemo,
+ useRef,
+ useState,
+ type ComponentProps,
+ type ReactNode,
+} from "react"
+
+import { ChevronDown, ChevronsUpDown, CornerDownLeft, Search } from "../../icons"
+import { type MixinProps, splitProps } from "../../lib/mixin"
+import { cn } from "../../cn"
+import { Input } from "../../ui/input"
+import { InputGroup, InputGroupAddon, InputGroupInput } from "../../ui/input-group"
+import { Label } from "../../ui/label"
+import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover"
+import { Spinner } from "../spinner"
+
+export interface ResourceFieldProps
+ extends MixinProps<"container", ComponentProps<"div">>,
+ MixinProps<"popover", ComponentProps>,
+ MixinProps<"label", Omit, "children">>,
+ MixinProps<"error", Omit, "children">> {
+ value: T | null
+ onChange: (val: T | null) => void
+ items: T[]
+ getItemTitle: (item: T) => string
+ getItemGroup?: (item: T) => string
+ renderSearchItem: (item: T, isSelected: boolean) => ReactNode
+ renderSummary?: (item: T) => ReactNode
+ searchFilter: (item: T, query: string) => boolean
+ renderActions?: ReactNode
+ recentLabel?: string
+ label?: ReactNode
+ error?: ReactNode
+ placeholder?: string
+ isLoading?: boolean
+ defaultOpen?: boolean
+}
+
+function ResourceFieldItem({
+ selected,
+ className,
+ children,
+ ...props
+}: ComponentProps<"div"> & { selected?: boolean }) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function ResourceFieldKey({
+ resource,
+ className,
+ ...inputProps
+}: Omit, "className"> & {
+ resource: ReactNode
+ className?: string
+}) {
+ return (
+
+
+ {resource}
+
+
+
.
+
+
+ )
+}
+
+function ResourceField({
+ value,
+ onChange,
+ items,
+ getItemTitle,
+ getItemGroup,
+ renderSearchItem,
+ renderSummary,
+ searchFilter,
+ renderActions,
+ recentLabel = "Recent",
+ label,
+ error,
+ placeholder = "Search resources",
+ isLoading = false,
+ defaultOpen = false,
+ ...mixProps
+}: ResourceFieldProps) {
+ const {
+ container,
+ label: labelProps,
+ error: errorProps,
+ popover: popProps,
+ } = splitProps(mixProps, "container", "label", "error", "popover")
+
+ const [open, setOpen] = useState(defaultOpen)
+ const [query, setQuery] = useState("")
+ const [anchor, setAnchor] = useState(null)
+ const inputRef = useRef(null)
+
+ const filtered = useMemo(
+ () => items.filter((item) => searchFilter(item, query)),
+ [items, query, searchFilter]
+ )
+
+ const grouped = useMemo(() => {
+ if (!getItemGroup) return null
+ const groups = new Map()
+ for (const item of filtered) {
+ const group = getItemGroup(item)
+ const list = groups.get(group) ?? []
+ list.push(item)
+ groups.set(group, list)
+ }
+ return [...groups.entries()]
+ }, [filtered, getItemGroup])
+
+ const isItemSelected = (item: T) =>
+ value != null && getItemTitle(value) === getItemTitle(item)
+
+ const selectItem = (item: T) => {
+ onChange(item)
+ setOpen(false)
+ setQuery("")
+ }
+
+ const renderItemButton = (item: T) => (
+ selectItem(item)}
+ >
+ {renderSearchItem(item, isItemSelected(item))}
+
+ )
+
+ const picker = (
+
+
+
+
+
+
+ setQuery(event.target.value)}
+ />
+
+
+
+
+ {renderActions && !query ? (
+
+
+ Create
+
+ {renderActions}
+
+ ) : null}
+
+ {grouped
+ ? grouped.map(([group, groupItems]) => (
+
+
+ {group}
+
+
+ {groupItems.map(renderItemButton)}
+
+
+ ))
+ : (
+ <>
+ {filtered.length > 0 && !query ? (
+
+ {recentLabel}
+
+ ) : null}
+
+ {filtered.map(renderItemButton)}
+
+ >
+ )}
+
+ {filtered.length === 0 ? (
+
+ No results found
+
+ ) : null}
+
+
+ )
+
+ const trigger = (text: string) => (
+
+ {text}
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+ )
+
+ const selectedTriggerClassName = cn(
+ "inline-flex h-9 min-w-0 items-center gap-1.5 rounded-lg border border-input bg-popover px-2.5 font-mono text-sm text-foreground outline-none select-none",
+ "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
+ error && "border-error shadow-ring-error ring-0"
+ )
+
+ const summary = value ? renderSummary?.(value) : null
+
+ const field = (
+
+ {value ? (
+
+ {summary ?? (
+
+ {getItemTitle(value)}
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ ) : (
+ trigger(placeholder)
+ )}
+ {picker}
+
+ )
+
+ return (
+
+ {label ? (
+
+ ) : null}
+ {field}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ )
+}
+
+export { ResourceField, ResourceFieldItem, ResourceFieldKey }
diff --git a/packages/shared-ui/src/components/resource-field/resource-field.stories.tsx b/packages/shared-ui/src/components/resource-field/resource-field.stories.tsx
new file mode 100644
index 0000000..9fdaccd
--- /dev/null
+++ b/packages/shared-ui/src/components/resource-field/resource-field.stories.tsx
@@ -0,0 +1,161 @@
+import { useState } from "react"
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { Popover } from "../../ui/popover"
+import {
+ ResourceField,
+ ResourceFieldItem,
+ ResourceFieldKey,
+} from "./index"
+
+type Resource = {
+ group: string
+ title: string
+ keyHint?: boolean
+}
+
+const resources: Resource[] = [
+ { group: "Payment", title: "amount" },
+ { group: "Payment", title: "currency" },
+ { group: "Payment", title: "status" },
+ { group: "Customer", title: "customer.email" },
+ { group: "Customer", title: "customer.name" },
+ { group: "Customer", title: "customer.metadata.", keyHint: true },
+ { group: "Product", title: "product.id" },
+ { group: "Product", title: "product.metadata.", keyHint: true },
+]
+
+function itemLabel(item: Resource) {
+ if (!item.keyHint) return item.title
+ const [prefix] = item.title.split(".")
+ return (
+ <>
+ {prefix}.
+ <key>
+ >
+ )
+}
+
+function ResourceFieldDemo({
+ defaultOpen,
+ defaultValue = null,
+ error,
+}: {
+ defaultOpen?: boolean
+ defaultValue?: Resource | null
+ error?: string
+}) {
+ const [value, setValue] = useState(defaultValue)
+ const [key, setKey] = useState("region")
+
+ return (
+ item.title}
+ getItemGroup={(item) => item.group}
+ searchFilter={(item, query) =>
+ item.title.toLowerCase().includes(query.toLowerCase())
+ }
+ renderSearchItem={(item, selected) => (
+ {itemLabel(item)}
+ )}
+ renderSummary={(item) =>
+ item.keyHint ? (
+ ", "")}
+ value={key}
+ onChange={(event) => setKey(event.target.value)}
+ />
+ ) : undefined
+ }
+ />
+ )
+}
+
+const meta: Meta = {
+ title: "Components/ResourceField",
+ component: ResourceField,
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {
+ render: () => ,
+}
+
+export const Open: Story = {
+ parameters: {
+ docs: { disable: true },
+ },
+ render: () => (
+
+ item.title === "product.id") ?? null}
+ />
+
+ ),
+}
+
+export const Selected: Story = {
+ render: () => (
+ item.title === "product.id") ?? null}
+ />
+ ),
+}
+
+export const WithKey: Story = {
+ render: () => (
+ item.title === "product.metadata.") ?? null
+ }
+ />
+ ),
+}
+
+export const Item: Story = {
+ render: () => (
+
+
+ Payment
+
+
+ amount
+ currency
+ status
+
+
+ Product
+
+
+ product.id
+ product.name
+
+
+ ),
+}
+
+export const Key: Story = {
+ render: function KeyComposer() {
+ const [key, setKey] = useState("region")
+ return (
+
+ setKey(event.target.value)}
+ />
+
+ )
+ },
+}
diff --git a/packages/shared-ui/src/components/rule-builder-condition-row/index.tsx b/packages/shared-ui/src/components/rule-builder-condition-row/index.tsx
new file mode 100644
index 0000000..4709d47
--- /dev/null
+++ b/packages/shared-ui/src/components/rule-builder-condition-row/index.tsx
@@ -0,0 +1,184 @@
+import type { ComponentProps } from "react"
+
+import { CloseX } from "../../icons"
+import { type MixinProps, splitProps } from "../../lib/mixin"
+import { cn } from "../../cn"
+import { Button } from "../../ui/button"
+import { Input } from "../../ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../../ui/select"
+
+const DEFAULT_OPERATORS = [
+ { value: "is", label: "is" },
+ { value: "is_not", label: "is not" },
+ { value: "contains", label: "contains" },
+] as const
+
+export interface RuleBuilderConditionRowProps
+ extends MixinProps<"container", ComponentProps<"div">>,
+ MixinProps<"trigger", Omit, "children">>,
+ MixinProps<"content", Omit, "children">>,
+ MixinProps<"key", Omit, "value" | "onChange">>,
+ MixinProps<
+ "operatorTrigger",
+ Omit, "children">
+ >,
+ MixinProps<
+ "operatorContent",
+ Omit, "children">
+ >,
+ MixinProps<"input", Omit, "value" | "onChange">>,
+ MixinProps<"remove", Omit, "children">> {
+ resource: T | null
+ onResourceChange: (value: T | null) => void
+ resources: T[]
+ getItemValue: (item: T) => string
+ getItemTitle: (item: T) => string
+ fieldKey: string
+ onFieldKeyChange: (value: string) => void
+ operator: string | null
+ onOperatorChange: (value: string | null) => void
+ operators?: { value: string; label: string }[]
+ value: string
+ onValueChange: (value: string) => void
+ onRemove?: () => void
+ disabled?: boolean
+ placeholder?: string
+}
+
+function RuleBuilderConditionRow({
+ resource,
+ onResourceChange,
+ resources,
+ getItemValue,
+ getItemTitle,
+ fieldKey,
+ onFieldKeyChange,
+ operator,
+ onOperatorChange,
+ operators = [...DEFAULT_OPERATORS],
+ value,
+ onValueChange,
+ onRemove,
+ disabled = false,
+ placeholder = "Select field",
+ ...mixProps
+}: RuleBuilderConditionRowProps) {
+ const {
+ container,
+ trigger,
+ content,
+ key: keyProps,
+ operatorTrigger,
+ operatorContent,
+ input,
+ remove,
+ } = splitProps(
+ mixProps,
+ "container",
+ "trigger",
+ "content",
+ "key",
+ "operatorTrigger",
+ "operatorContent",
+ "input",
+ "remove"
+ )
+
+ return (
+
+
+
+ .
+ onFieldKeyChange(event.target.value)}
+ className={cn("h-9 w-28 font-mono md:text-sm", keyProps.className)}
+ />
+
+
+
+
+
onValueChange(event.target.value)}
+ className={cn("h-9 w-36 md:text-sm", input.className)}
+ />
+
+ {onRemove ? (
+
{
+ remove.onClick?.(event)
+ onRemove()
+ }}
+ >
+
+
+ ) : null}
+
+ )
+}
+
+export { RuleBuilderConditionRow }
diff --git a/packages/shared-ui/src/components/rule-builder-condition-row/rule-builder-condition-row.stories.tsx b/packages/shared-ui/src/components/rule-builder-condition-row/rule-builder-condition-row.stories.tsx
new file mode 100644
index 0000000..bdfc529
--- /dev/null
+++ b/packages/shared-ui/src/components/rule-builder-condition-row/rule-builder-condition-row.stories.tsx
@@ -0,0 +1,97 @@
+import { useState } from "react"
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { RuleBuilderConditionRow } from "./index"
+
+type Resource = {
+ id: string
+ title: string
+}
+
+const resources: Resource[] = [
+ { id: "customer.metadata", title: "customer.metadata" },
+ { id: "customer.email", title: "customer.email" },
+ { id: "product.metadata", title: "product.metadata" },
+ { id: "payment.status", title: "payment.status" },
+]
+
+type Row = {
+ resourceId: string
+ fieldKey: string
+ operator: string
+ value: string
+}
+
+const figRow: Row = {
+ resourceId: "customer.metadata",
+ fieldKey: "plan_tier",
+ operator: "is",
+ value: "enterprise",
+}
+
+function findResource(id: string) {
+ return resources.find((item) => item.id === id) ?? null
+}
+
+function ConditionRowDemo(props: {
+ defaultRow?: Row
+ showRemove?: boolean
+ disabled?: boolean
+}) {
+ const [row, setRow] = useState(props.defaultRow ?? figRow)
+
+ return (
+
+ setRow((current) => ({ ...current, resourceId: item?.id ?? "" }))
+ }
+ resources={resources}
+ getItemValue={(item) => item.id}
+ getItemTitle={(item) => item.title}
+ fieldKey={row.fieldKey}
+ onFieldKeyChange={(fieldKey) =>
+ setRow((current) => ({ ...current, fieldKey }))
+ }
+ operator={row.operator}
+ onOperatorChange={(operator) =>
+ setRow((current) => ({ ...current, operator: operator ?? "is" }))
+ }
+ value={row.value}
+ onValueChange={(value) => setRow((current) => ({ ...current, value }))}
+ onRemove={props.showRemove ? () => undefined : undefined}
+ disabled={props.disabled}
+ />
+ )
+}
+
+const meta: Meta = {
+ title: "Components/RuleBuilderConditionRow",
+ component: RuleBuilderConditionRow,
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {
+ render: () => ,
+}
+
+export const Disabled: Story = {
+ render: () => ,
+}
+
+export const Empty: Story = {
+ render: () => (
+
+ ),
+}
diff --git a/packages/shared-ui/src/components/rule-builder-destination-row/index.tsx b/packages/shared-ui/src/components/rule-builder-destination-row/index.tsx
new file mode 100644
index 0000000..65cfa98
--- /dev/null
+++ b/packages/shared-ui/src/components/rule-builder-destination-row/index.tsx
@@ -0,0 +1,156 @@
+import type { ComponentProps, ReactNode } from "react"
+
+import { CloseX } from "../../icons"
+import { type MixinProps, splitProps } from "../../lib/mixin"
+import { cn } from "../../cn"
+import { Badge } from "../../ui/badge"
+import { Button } from "../../ui/button"
+import { Input } from "../../ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../../ui/select"
+
+export interface RuleBuilderDestinationRowProps
+ extends MixinProps<"container", ComponentProps<"div">>,
+ MixinProps<"trigger", Omit, "children">>,
+ MixinProps<"content", Omit, "children">>,
+ MixinProps<"input", Omit, "value" | "onChange">>,
+ MixinProps<"suffix", Omit, "children">>,
+ MixinProps<"badge", Omit, "children">>,
+ MixinProps<"remove", Omit, "children">> {
+ destination: T | null
+ onDestinationChange: (value: T | null) => void
+ destinations: T[]
+ getItemValue: (item: T) => string
+ getItemTitle: (item: T) => string
+ share: string
+ onShareChange: (value: string) => void
+ suffix?: ReactNode
+ primary?: boolean
+ primaryLabel?: ReactNode
+ onRemove?: () => void
+ disabled?: boolean
+ placeholder?: string
+}
+
+function RuleBuilderDestinationRow({
+ destination,
+ onDestinationChange,
+ destinations,
+ getItemValue,
+ getItemTitle,
+ share,
+ onShareChange,
+ suffix = "%",
+ primary = false,
+ primaryLabel = "Primary",
+ onRemove,
+ disabled = false,
+ placeholder = "Select destination",
+ ...mixProps
+}: RuleBuilderDestinationRowProps) {
+ const {
+ container,
+ trigger,
+ content,
+ input,
+ suffix: suffixProps,
+ badge,
+ remove,
+ } = splitProps(
+ mixProps,
+ "container",
+ "trigger",
+ "content",
+ "input",
+ "suffix",
+ "badge",
+ "remove"
+ )
+
+ return (
+
+
+
+
+
+
+ onShareChange(event.target.value)}
+ className={cn("h-9 w-14 text-center md:text-sm", input.className)}
+ />
+
+ {suffix}
+
+
+
+
+ {primary ? (
+ {primaryLabel}
+ ) : onRemove ? (
+ {
+ remove.onClick?.(event)
+ onRemove()
+ }}
+ >
+
+
+ ) : null}
+
+
+ )
+}
+
+export { RuleBuilderDestinationRow }
diff --git a/packages/shared-ui/src/components/rule-builder-destination-row/rule-builder-destination-row.stories.tsx b/packages/shared-ui/src/components/rule-builder-destination-row/rule-builder-destination-row.stories.tsx
new file mode 100644
index 0000000..01b5337
--- /dev/null
+++ b/packages/shared-ui/src/components/rule-builder-destination-row/rule-builder-destination-row.stories.tsx
@@ -0,0 +1,164 @@
+import { useState } from "react"
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { RuleBuilderDestinationRow } from "./index"
+
+type Destination = {
+ id: string
+ name: string
+ description: string
+}
+
+const destinations: Destination[] = [
+ { id: "stripe-global", name: "Stripe", description: "global cards" },
+ { id: "adyen-eu", name: "Adyen", description: "EU acquiring" },
+ { id: "checkout-uk", name: "Checkout.com", description: "UK cards" },
+]
+
+const destinationTitle = (item: Destination) =>
+ `${item.name} · ${item.description}`
+
+type Row = {
+ id: string
+ destinationId: string
+ share: string
+ primary?: boolean
+}
+
+const figRows: Row[] = [
+ {
+ id: "1",
+ destinationId: "stripe-global",
+ share: "60",
+ primary: true,
+ },
+ {
+ id: "2",
+ destinationId: "adyen-eu",
+ share: "60",
+ },
+]
+
+function findDestination(id: string) {
+ return destinations.find((item) => item.id === id) ?? null
+}
+
+function DestinationRowDemo(props: {
+ defaultRow?: Row
+ showRemove?: boolean
+ disabled?: boolean
+}) {
+ const [row, setRow] = useState(
+ props.defaultRow ?? {
+ id: "1",
+ destinationId: "stripe-global",
+ share: "60",
+ primary: true,
+ }
+ )
+
+ return (
+
+ setRow((current) => ({
+ ...current,
+ destinationId: item?.id ?? "",
+ }))
+ }
+ destinations={destinations}
+ getItemValue={(item) => item.id}
+ getItemTitle={destinationTitle}
+ share={row.share}
+ onShareChange={(share) => setRow((current) => ({ ...current, share }))}
+ primary={row.primary}
+ onRemove={props.showRemove ? () => undefined : undefined}
+ disabled={props.disabled}
+ containerClassName="max-w-xl"
+ />
+ )
+}
+
+function DestinationRowsDemo({ defaultRows = figRows }: { defaultRows?: Row[] }) {
+ const [rows, setRows] = useState(defaultRows)
+
+ const updateRow = (id: string, next: Partial) => {
+ setRows((current) =>
+ current.map((row) => (row.id === id ? { ...row, ...next } : row))
+ )
+ }
+
+ return (
+
+ {rows.map((row) => (
+
+ updateRow(row.id, { destinationId: item?.id ?? "" })
+ }
+ destinations={destinations}
+ getItemValue={(item) => item.id}
+ getItemTitle={destinationTitle}
+ share={row.share}
+ onShareChange={(share) => updateRow(row.id, { share })}
+ primary={row.primary}
+ onRemove={
+ row.primary
+ ? undefined
+ : () =>
+ setRows((current) =>
+ current.filter((item) => item.id !== row.id)
+ )
+ }
+ />
+ ))}
+
+ )
+}
+
+const meta: Meta = {
+ title: "Components/RuleBuilderDestinationRow",
+ component: RuleBuilderDestinationRow,
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {
+ render: () => ,
+}
+
+export const Primary: Story = {
+ render: () => ,
+}
+
+export const Removable: Story = {
+ render: () => (
+
+ ),
+}
+
+export const Disabled: Story = {
+ render: () => ,
+}
+
+export const Empty: Story = {
+ render: () => (
+
+ ),
+}
diff --git a/packages/shared-ui/src/components/search-field/index.tsx b/packages/shared-ui/src/components/search-field/index.tsx
new file mode 100644
index 0000000..64dd58c
--- /dev/null
+++ b/packages/shared-ui/src/components/search-field/index.tsx
@@ -0,0 +1,84 @@
+import type { ComponentProps } from "react"
+
+import { CloseX, Search } from "../../icons"
+import { cn } from "../../cn"
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupInput,
+} from "../../ui/input-group"
+
+type SearchFieldProps = Omit<
+ ComponentProps,
+ "type" | "className" | "data-state"
+> & {
+ className?: string
+ "data-state"?: "hover" | "focus"
+}
+
+function SearchField({
+ className,
+ disabled,
+ placeholder = "Search providers",
+ "data-state": dataState,
+ ...props
+}: SearchFieldProps) {
+ return (
+
+
+
+
+
+
+
+
+ {
+ const input = event.currentTarget
+ .closest("[data-slot=input-group]")
+ ?.querySelector("input")
+ if (!(input instanceof HTMLInputElement)) return
+ // ponytail: native setter so React's value tracker still fires onChange
+ Object.getOwnPropertyDescriptor(
+ HTMLInputElement.prototype,
+ "value"
+ )?.set?.call(input, "")
+ input.dispatchEvent(new Event("input", { bubbles: true }))
+ input.focus()
+ }}
+ >
+
+
+
+
+ )
+}
+
+export { SearchField }
+export type { SearchFieldProps }
diff --git a/packages/shared-ui/src/components/search-field/search-field.stories.tsx b/packages/shared-ui/src/components/search-field/search-field.stories.tsx
new file mode 100644
index 0000000..2977734
--- /dev/null
+++ b/packages/shared-ui/src/components/search-field/search-field.stories.tsx
@@ -0,0 +1,45 @@
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { SearchField } from "./index"
+
+const meta: Meta = {
+ title: "Components/SearchField",
+ component: SearchField,
+ args: {
+ className: "w-80",
+ },
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const Hover: Story = {
+ args: { "data-state": "hover" },
+}
+
+export const Focused: Story = {
+ args: { "data-state": "focus" },
+}
+
+export const Filled: Story = {
+ args: { "data-state": "focus", defaultValue: "stripe2" },
+}
+
+export const Disabled: Story = {
+ args: { disabled: true },
+}
+
+export const States: Story = {
+ render: () => (
+
+
+
+
+
+
+
+ ),
+}
diff --git a/packages/shared-ui/src/components/spinner/index.tsx b/packages/shared-ui/src/components/spinner/index.tsx
new file mode 100644
index 0000000..2a9005d
--- /dev/null
+++ b/packages/shared-ui/src/components/spinner/index.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import * as React from "react";
+
+import UseAnimations from "react-useanimations";
+import loadingAnimation from "react-useanimations/lib/loading";
+
+interface Props extends Omit, "animation"> {}
+
+export const Spinner = ({ size = 30, strokeColor = "currentColor", ...props }: Props) => {
+ return ;
+};
diff --git a/packages/shared-ui/src/components/spinner/spinner.stories.tsx b/packages/shared-ui/src/components/spinner/spinner.stories.tsx
new file mode 100644
index 0000000..474c0ad
--- /dev/null
+++ b/packages/shared-ui/src/components/spinner/spinner.stories.tsx
@@ -0,0 +1,16 @@
+import { Meta, StoryObj } from "@storybook/react";
+
+import { Spinner } from "./index";
+
+const meta = {
+ title: "Components/Spinner",
+ component: Spinner,
+} satisfies Meta;
+
+export default meta;
+
+export const Default: StoryObj = {
+ args: {
+ size: 30,
+ },
+};
diff --git a/packages/shared-ui/src/components/tag-input/index.tsx b/packages/shared-ui/src/components/tag-input/index.tsx
new file mode 100644
index 0000000..a14566a
--- /dev/null
+++ b/packages/shared-ui/src/components/tag-input/index.tsx
@@ -0,0 +1,206 @@
+import { useId, useState, type ComponentProps, type KeyboardEvent, type ReactNode } from "react"
+
+import { CloseX, Plus } from "../../icons"
+import { type MixinProps, splitProps } from "../../lib/mixin"
+import { cn } from "../../cn"
+import { Badge } from "../../ui/badge"
+import { Input } from "../../ui/input"
+import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "../../ui/input-group"
+import { Label } from "../../ui/label"
+
+type InputMixin = Omit, "value" | "onChange"> & {
+ value?: string
+ onChange?: (value: string) => void
+}
+
+export interface TagProps extends Omit, "children"> {
+ disabled?: boolean
+ onRemove?: () => void
+ children: ReactNode
+}
+
+function Tag({ disabled, onRemove, className, children, ...props }: TagProps) {
+ return (
+
+ {children}
+ {
+ event.stopPropagation()
+ onRemove?.()
+ }}
+ >
+
+
+
+ )
+}
+
+export interface TagInputProps
+ extends MixinProps<"input", InputMixin>,
+ MixinProps<"tag", Omit, "children">>,
+ MixinProps<"label", Omit, "children">>,
+ MixinProps<"error", Omit, "children">>,
+ MixinProps<"helpText", Omit, "children">> {
+ id?: string
+ value?: string[]
+ onChange?: (value: string[]) => void
+ placeholder?: string
+ label?: ReactNode
+ error?: ReactNode
+ helpText?: ReactNode
+ disabled?: boolean
+ className?: string
+ "data-state"?: "focus"
+}
+
+function TagInput({
+ id,
+ className,
+ value,
+ onChange,
+ placeholder = "Add values...",
+ label,
+ error,
+ helpText,
+ disabled = false,
+ "data-state": dataState,
+ ...restProps
+}: TagInputProps) {
+ const generatedId = useId()
+ const inputId = id ?? generatedId
+ const { input, tag, label: labelProps, error: errorProps, helpText: helpTextProps } = splitProps(
+ restProps,
+ "input",
+ "tag",
+ "label",
+ "error",
+ "helpText"
+ )
+
+ const { value: pendingValue, onChange: onPendingChange, ...inputRest } = input
+ const isControlled = value !== undefined
+ const [internalTags, setInternalTags] = useState([])
+ const [internalPending, setInternalPending] = useState("")
+ const tags = isControlled ? value : internalTags
+ const pending = pendingValue !== undefined ? pendingValue : internalPending
+
+ const setTags = (next: string[]) => {
+ if (!isControlled) setInternalTags(next)
+ onChange?.(next)
+ }
+
+ const setPending = (next: string) => {
+ if (onPendingChange) onPendingChange(next)
+ else setInternalPending(next)
+ }
+
+ const addPending = () => {
+ const next = pending.trim()
+ if (!next) return
+ setTags(Array.from(new Set([...tags, next])))
+ setPending("")
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Enter" || event.key === ",") {
+ event.preventDefault()
+ addPending()
+ } else if (event.key === "Backspace" && !pending && tags.length > 0) {
+ setTags(tags.slice(0, -1))
+ }
+ }
+
+ return (
+
+ {label ? (
+
+ ) : null}
+ {helpText ? (
+
+ {helpText}
+
+ ) : null}
+
+
+ {tags.map((item) => (
+ setTags(tags.filter((tagItem) => tagItem !== item))}
+ {...tag}
+ className={cn(tag.className)}
+ >
+ {item}
+
+ ))}
+
+ setPending(event.target.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={addPending}
+ />
+
+ {disabled ? null : (
+
+ {
+ if (pending.trim()) addPending()
+ else
+ event.currentTarget
+ .closest("[data-slot=input-group]")
+ ?.querySelector("input")
+ ?.focus()
+ }}
+ >
+
+
+
+ )}
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ )
+}
+
+export { Tag, TagInput }
diff --git a/packages/shared-ui/src/components/tag-input/tag-input.stories.tsx b/packages/shared-ui/src/components/tag-input/tag-input.stories.tsx
new file mode 100644
index 0000000..0e3c36e
--- /dev/null
+++ b/packages/shared-ui/src/components/tag-input/tag-input.stories.tsx
@@ -0,0 +1,64 @@
+import { useState } from "react"
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { Tag, TagInput } from "./index"
+
+const meta: Meta = {
+ title: "Components/TagInput",
+ component: TagInput,
+ args: {
+ className: "w-80",
+ placeholder: "Add values...",
+ },
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {
+ render: function DefaultStory(args) {
+ const [value, setValue] = useState([])
+ return
+ },
+}
+
+export const Focused: Story = {
+ args: {
+ value: ["EU"],
+ "data-state": "focus",
+ },
+}
+
+export const Multiple: Story = {
+ args: {
+ value: ["EU", "UK"],
+ },
+}
+
+export const Disabled: Story = {
+ args: {
+ value: ["EU", "UK"],
+ disabled: true,
+ },
+}
+
+export const Tags: Story = {
+ render: () => (
+
+ EU
+ EU
+
+ ),
+}
+
+export const States: Story = {
+ render: () => (
+
+
+
+
+
+
+ ),
+}
diff --git a/packages/shared-ui/src/components/timeline/index.tsx b/packages/shared-ui/src/components/timeline/index.tsx
new file mode 100644
index 0000000..ed25b78
--- /dev/null
+++ b/packages/shared-ui/src/components/timeline/index.tsx
@@ -0,0 +1,364 @@
+import { useMemo, useState, type ComponentType, type ReactNode } from "react"
+
+import { Check, ChevronDown, ChevronUp, ClockPlay, CloseX } from "../../icons"
+import { type MixinProps, splitProps } from "../../lib/mixin"
+import { cn } from "../../cn"
+import { Button } from "../../ui/button"
+import { Skeleton } from "../../ui/skeleton"
+
+type LinkComponentType = ComponentType<{
+ href: string
+ className?: string
+ target?: string
+ rel?: string
+ children?: ReactNode
+}>
+
+const DefaultLink: LinkComponentType = ({ href, children, ...props }) => (
+
+ {children}
+
+)
+
+export type TimelineStatus = "success" | "pending" | "error" | "complete"
+
+export interface TimelineEntry {
+ title: ReactNode
+ date?: ReactNode
+ source?: ReactNode
+ description?: ReactNode
+ status?: TimelineStatus
+ data?: Record | null
+ contentOverride?: ReactNode
+ key?: string | number
+ titleClassName?: string
+}
+
+export interface TimelineProps
+ extends MixinProps<"container", React.ComponentProps<"div">> {
+ items: T[]
+ renderItem: (item: T, index: number) => TimelineEntry
+ emptyMessage?: string
+ isLoading?: boolean
+ limit?: number
+ skeletonRowCount?: number
+ routeMap?: Record string>
+ linkComponent?: LinkComponentType
+}
+
+const statusStyles: Record<
+ TimelineStatus,
+ { icon: typeof Check; className: string }
+> = {
+ success: {
+ icon: Check,
+ className: "bg-muted text-muted-foreground",
+ },
+ pending: {
+ icon: ClockPlay,
+ className: "bg-accent text-primary",
+ },
+ error: {
+ icon: CloseX,
+ className: "bg-ring-error text-error",
+ },
+ complete: {
+ icon: Check,
+ className: "bg-primary text-primary-foreground",
+ },
+}
+
+function formatLabel(key: string) {
+ return key.replace(/([A-Z])/g, " $1").replace(/^[a-z]/, (m) => m.toUpperCase())
+}
+
+function TimelineStatusIcon({ status }: { status: TimelineStatus }) {
+ const { icon: Icon, className } = statusStyles[status]
+
+ return (
+
+
+
+ )
+}
+
+function TimelineSummary({
+ data,
+ manualContent,
+ routeMap,
+ LinkComponent,
+}: {
+ data?: Record | null
+ manualContent?: ReactNode
+ routeMap?: Record string>
+ LinkComponent: LinkComponentType
+}) {
+ const summaryItems = useMemo(() => {
+ if (!data) return []
+
+ return Object.entries(data)
+ .filter(([key, val]) => key !== "$changes" && val !== null && typeof val !== "object")
+ .map(([key, val]) => {
+ const href = routeMap?.[key]?.(String(val))
+
+ return (
+
+ {formatLabel(key)}:
+ {href ? (
+
+ {String(val)}
+
+ ) : (
+ {String(val)}
+ )}
+
+ )
+ })
+ }, [data, routeMap, LinkComponent])
+
+ if (manualContent) return {manualContent}
+ if (!data || summaryItems.length === 0) return null
+
+ return (
+
+ {summaryItems.reduce(
+ (prev, curr, i) =>
+ i === 0
+ ? [curr]
+ : [
+ ...prev,
+
+ •
+ ,
+ curr,
+ ],
+ []
+ )}
+
+ )
+}
+
+function getDiffRows(changes: Record) {
+ const previousAttributes = changes.previous_attributes
+
+ if (previousAttributes && typeof previousAttributes === "object") {
+ const previous = previousAttributes as Record
+ const current = (changes.data as Record | undefined) ?? {}
+ return Object.keys(previous).map((key) => ({
+ key,
+ from: previous[key],
+ to: current[key],
+ }))
+ }
+
+ return Object.entries(changes)
+ .filter(([, val]) => val && typeof val === "object" && ("from" in val || "to" in val))
+ .map(([key, val]) => {
+ const entry = val as { from?: unknown; to?: unknown }
+ return { key, from: entry.from, to: entry.to }
+ })
+}
+
+function TimelineDiff({ changes }: { changes?: unknown }) {
+ if (!changes || typeof changes !== "object") return null
+
+ const rows = getDiffRows(changes as Record)
+ if (rows.length === 0) return null
+
+ return (
+
+ {rows.map((row) => (
+
+ {formatLabel(row.key)}:
+
+ {row.from === null || row.from === "" || row.from === undefined ? "none" : String(row.from)}
+
+ →
+
+ {row.to === null || row.to === "" || row.to === undefined ? "none" : String(row.to)}
+
+
+ ))}
+
+ )
+}
+
+function TimelineItem({
+ entry,
+ isLast,
+ routeMap,
+ LinkComponent,
+}: {
+ entry: TimelineEntry
+ isLast: boolean
+ routeMap?: Record string>
+ LinkComponent: LinkComponentType
+}) {
+ const status = entry.status ?? "success"
+ const isDetailed = Boolean(entry.description)
+
+ return (
+
+
+ {!isLast ? (
+
+ ) : null}
+
+
+
+
+
+
+ {entry.title}
+
+ {isDetailed ? (
+
+ {entry.date}
+ {entry.date && entry.source ? " - " : null}
+ {entry.source}
+
+ ) : entry.source ? (
+ {entry.source}
+ ) : null}
+
+
+ {!isDetailed && entry.date ? (
+
+ ) : null}
+
+ {entry.description ? (
+
{entry.description}
+ ) : null}
+
+
+
+
+
+ )
+}
+
+function Timeline({
+ items,
+ renderItem,
+ isLoading,
+ limit = 0,
+ emptyMessage = "No story found",
+ skeletonRowCount = 3,
+ routeMap,
+ linkComponent: LinkComponent = DefaultLink,
+ ...mixProps
+}: TimelineProps) {
+ const [isExpanded, setIsExpanded] = useState(false)
+ const { container } = splitProps(mixProps, "container")
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (!items?.length) {
+ return (
+
+ {emptyMessage}
+
+ )
+ }
+
+ const displayItems = limit > 0 && items.length > limit && !isExpanded ? items.slice(0, limit) : items
+
+ return (
+
+
+
+ {isExpanded ? (
+
setIsExpanded(false)}
+ >
+ Show less
+
+ ) : null}
+
+ )
+}
+
+export { Timeline, TimelineStatusIcon }
+export type { LinkComponentType }
diff --git a/packages/shared-ui/src/components/timeline/timeline.stories.tsx b/packages/shared-ui/src/components/timeline/timeline.stories.tsx
new file mode 100644
index 0000000..cbe3541
--- /dev/null
+++ b/packages/shared-ui/src/components/timeline/timeline.stories.tsx
@@ -0,0 +1,139 @@
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { Timeline, type TimelineStatus } from "./index"
+
+type SampleItem = {
+ id: string
+ title: string
+ date: string
+ source?: string
+ description?: string
+ status?: TimelineStatus
+ data?: Record
+}
+
+const figmaItems: SampleItem[] = [
+ {
+ id: "1",
+ status: "success",
+ title: "Activity label",
+ source: "viaStripe",
+ date: "Jul 14 - 09:12",
+ },
+ {
+ id: "2",
+ status: "pending",
+ title: "Activity label",
+ source: "viaStripe",
+ date: "Jul 14 - 09:12",
+ },
+ {
+ id: "3",
+ status: "error",
+ title: "Activity label",
+ source: "viaStripe",
+ date: "Jul 14 - 09:12",
+ },
+ {
+ id: "4",
+ status: "complete",
+ title: "Activity label",
+ source: "viaStripe",
+ date: "Jul 14",
+ description: "Description text goes here",
+ },
+]
+
+const dataItems: SampleItem[] = [
+ {
+ id: "1",
+ status: "complete",
+ title: "Created",
+ date: "Jan 15, 2025 at 2:30 PM",
+ data: { customerId: "cust_abc123", productId: "prod_xyz" },
+ },
+ {
+ id: "2",
+ status: "pending",
+ title: "Updated",
+ date: "Jan 14, 2025 at 10:00 AM",
+ data: {
+ $changes: {
+ email: { from: "old@example.com", to: "new@example.com" },
+ name: { from: "Old Name", to: "New Name" },
+ },
+ },
+ },
+ {
+ id: "3",
+ status: "success",
+ title: "Subscription renewed",
+ date: "Jan 10, 2025 at 9:15 AM",
+ data: { planId: "plan_pro", status: "active" },
+ },
+]
+
+const meta: Meta = {
+ title: "Components/Timeline",
+ component: Timeline,
+ args: {
+ emptyMessage: "No history found",
+ isLoading: false,
+ limit: 0,
+ skeletonRowCount: 3,
+ },
+}
+
+export default meta
+
+type Story = StoryObj
+
+const renderItem = (item: SampleItem) => ({
+ key: item.id,
+ title: item.title,
+ date: item.date,
+ source: item.source,
+ description: item.description,
+ status: item.status,
+ data: item.data,
+})
+
+export const Default: Story = {
+ render: (args) => (
+
+
+
+ ),
+}
+
+export const WithData: Story = {
+ render: (args) => (
+
+
+
+ ),
+}
+
+export const WithLimit: Story = {
+ render: (args) => (
+
+
+
+ ),
+}
+
+export const Loading: Story = {
+ render: (args) => (
+
+
+
+ ),
+}
+
+export const Empty: Story = {
+ render: (args) => (
+
+
+
+ ),
+}
diff --git a/packages/shared-ui/src/components/wizard-stepper/index.tsx b/packages/shared-ui/src/components/wizard-stepper/index.tsx
new file mode 100644
index 0000000..564e1b6
--- /dev/null
+++ b/packages/shared-ui/src/components/wizard-stepper/index.tsx
@@ -0,0 +1,99 @@
+import { Fragment, type ComponentProps, type ReactNode } from "react"
+
+import { Check } from "../../icons"
+import { cn } from "../../cn"
+
+export type WizardStepStatus = "complete" | "current" | "upcoming"
+
+export interface WizardStepProps extends ComponentProps<"div"> {
+ status?: WizardStepStatus
+ step: number
+ label?: ReactNode
+}
+
+function WizardStep({
+ status = "upcoming",
+ step,
+ label,
+ className,
+ ...props
+}: WizardStepProps) {
+ return (
+
+
+ {status === "complete" ? (
+
+ ) : status === "current" ? (
+ step
+ ) : null}
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+ )
+}
+
+export interface WizardStepperProps extends ComponentProps<"div"> {
+ steps: ReactNode[]
+ current?: number
+}
+
+function WizardStepper({ steps, current = 0, className, ...props }: WizardStepperProps) {
+ return (
+
+ {steps.map((label, index) => {
+ const status: WizardStepStatus =
+ index < current ? "complete" : index === current ? "current" : "upcoming"
+
+ return (
+
+
+ {index < steps.length - 1 ? (
+
+ ) : null}
+
+ )
+ })}
+
+ )
+}
+
+export { WizardStep, WizardStepper }
diff --git a/packages/shared-ui/src/components/wizard-stepper/wizard-stepper.stories.tsx b/packages/shared-ui/src/components/wizard-stepper/wizard-stepper.stories.tsx
new file mode 100644
index 0000000..491aed9
--- /dev/null
+++ b/packages/shared-ui/src/components/wizard-stepper/wizard-stepper.stories.tsx
@@ -0,0 +1,35 @@
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { WizardStep, WizardStepper } from "./index"
+
+const steps = ["Welcome", "Organization", "Provider", "Routing", "Checkout", "Finish"]
+
+const meta: Meta = {
+ title: "Components/WizardStepper",
+ component: WizardStepper,
+ args: {
+ steps,
+ current: 2,
+ className: "w-full",
+ },
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const First: Story = { args: { current: 0 } }
+
+export const Complete: Story = { args: { current: steps.length } }
+
+export const Steps: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+}
diff --git a/packages/shared-ui/src/global.css b/packages/shared-ui/src/global.css
index b0b2415..6ca288e 100644
--- a/packages/shared-ui/src/global.css
+++ b/packages/shared-ui/src/global.css
@@ -8,7 +8,7 @@
--signal-blue-60: #7dafd9;
--signal-blue-40: #a8cae5;
--signal-blue-20: #d4e4f2;
-
+
--core-indigo-100: #100022;
--core-indigo-80: #40334e;
--core-indigo-60: #70667a;
@@ -27,6 +27,27 @@
--summit-sky-40: #f3f9fd;
--summit-sky-20: #f9fcfe;
+ --forest-green-100: #15803d;
+ --forest-green-80: #449964;
+ --forest-green-60: #73b38b;
+ --forest-green-40: #a1ccb1;
+ --forest-green-20: #d0e6d8;
+
+ --amber-orange-100: #b45309;
+ --amber-orange-80: #c3753a;
+ --amber-orange-60: #d2986b;
+ --amber-orange-40: #e1ba9d;
+ --amber-orange-20: #f0ddce;
+
+ --crimson-red-100: #bf3126;
+ --crimson-red-80: #cc5a51;
+ --crimson-red-60: #d9837d;
+ --crimson-red-40: #e5ada8;
+ --crimson-red-20: #f2d6d4;
+
+ --gradient-start: var(--signal-blue-80);
+ --gradient-end: var(--signal-blue-100);
+
--background: var(--summit-sky-40);
--foreground: var(--midnight-black-100);
--card: var(--summit-sky-20);
@@ -41,11 +62,34 @@
--muted-foreground: var(--midnight-black-60);
--accent: var(--signal-blue-20);
--accent-foreground: var(--signal-blue-100);
- --destructive: var(--core-indigo-100);
- --destructive-foreground: var(--summit-sky-20);
- --border: var(--summit-sky-80);
- --input: var(--summit-sky-80);
+ --success: var(--forest-green-100);
+ --success-foreground: var(--summit-sky-20);
+ --warning: var(--amber-orange-100);
+ --warning-foreground: var(--summit-sky-20);
+ --error: var(--crimson-red-100);
+ --error-foreground: var(--summit-sky-20);
+ --ring-error: var(--crimson-red-20);
+ --destructive: var(--error);
+ --destructive-foreground: var(--error-foreground);
+ --border: var(--core-indigo-20);
+ --input: var(--core-indigo-20);
--ring: var(--signal-blue-100);
+ --disabled: var(--core-indigo-20);
+ --disabled-foreground: var(--midnight-black-40);
+ --code: var(--midnight-black-100);
+ --code-foreground: var(--summit-sky-20);
+ --toast-info: color-mix(in srgb, var(--signal-blue-20) 55%, var(--summit-sky-20));
+ --toast-info-foreground: var(--signal-blue-100);
+ --toast-success: color-mix(in srgb, var(--forest-green-20) 55%, var(--summit-sky-20));
+ --toast-success-foreground: var(--forest-green-100);
+ --toast-warning: color-mix(in srgb, var(--amber-orange-20) 55%, var(--summit-sky-20));
+ --toast-warning-foreground: var(--amber-orange-100);
+ --toast-error: color-mix(in srgb, var(--crimson-red-20) 55%, var(--summit-sky-20));
+ --toast-error-foreground: var(--crimson-red-100);
+ --shadow-button-hover: 0 8px 16px -2px color-mix(in srgb, var(--primary) 40%, transparent);
+ --shadow-thumb: 0 4px 10px -2px color-mix(in srgb, var(--primary) 60%, transparent);
+ --shadow-ring-error: 0 0 0 4px var(--ring-error);
+ --shadow-elevation-md: 0px 10px 20px -30px color-mix(in srgb, var(--signal-blue-100) 20%, transparent);
--chart-1: var(--signal-blue-100);
--chart-2: var(--core-indigo-100);
--chart-3: var(--summit-sky-100);
@@ -106,6 +150,27 @@
--summit-sky-40: #f3f9fd;
--summit-sky-20: #f9fcfe;
+ --forest-green-100: #15803d;
+ --forest-green-80: #449964;
+ --forest-green-60: #73b38b;
+ --forest-green-40: #a1ccb1;
+ --forest-green-20: #d0e6d8;
+
+ --amber-orange-100: #b45309;
+ --amber-orange-80: #c3753a;
+ --amber-orange-60: #d2986b;
+ --amber-orange-40: #e1ba9d;
+ --amber-orange-20: #f0ddce;
+
+ --crimson-red-100: #bf3126;
+ --crimson-red-80: #cc5a51;
+ --crimson-red-60: #d9837d;
+ --crimson-red-40: #e5ada8;
+ --crimson-red-20: #f2d6d4;
+
+ --gradient-start: var(--signal-blue-80);
+ --gradient-end: #19609a;
+
--background: var(--core-indigo-100);
--foreground: var(--summit-sky-20);
--card: var(--core-indigo-80);
@@ -120,11 +185,34 @@
--muted-foreground: var(--core-indigo-20);
--accent: var(--signal-blue-20);
--accent-foreground: var(--signal-blue-100);
- --destructive: var(--midnight-black-80);
- --destructive-foreground: var(--summit-sky-20);
+ --success: var(--forest-green-100);
+ --success-foreground: var(--summit-sky-20);
+ --warning: var(--amber-orange-100);
+ --warning-foreground: var(--summit-sky-20);
+ --error: var(--crimson-red-100);
+ --error-foreground: var(--summit-sky-20);
+ --ring-error: var(--crimson-red-20);
+ --destructive: var(--error);
+ --destructive-foreground: var(--error-foreground);
--border: var(--core-indigo-80);
--input: var(--core-indigo-80);
--ring: var(--signal-blue-100);
+ --disabled: var(--core-indigo-60);
+ --disabled-foreground: var(--core-indigo-40);
+ --code: var(--midnight-black-100);
+ --code-foreground: var(--summit-sky-20);
+ --toast-info: color-mix(in srgb, var(--signal-blue-20) 55%, var(--summit-sky-20));
+ --toast-info-foreground: var(--signal-blue-100);
+ --toast-success: color-mix(in srgb, var(--forest-green-20) 55%, var(--summit-sky-20));
+ --toast-success-foreground: var(--forest-green-100);
+ --toast-warning: color-mix(in srgb, var(--amber-orange-20) 55%, var(--summit-sky-20));
+ --toast-warning-foreground: var(--amber-orange-100);
+ --toast-error: color-mix(in srgb, var(--crimson-red-20) 55%, var(--summit-sky-20));
+ --toast-error-foreground: var(--crimson-red-100);
+ --shadow-button-hover: 0 8px 16px -2px color-mix(in srgb, var(--primary) 45%, transparent);
+ --shadow-thumb: 0 4px 10px -2px color-mix(in srgb, var(--primary) 60%, transparent);
+ --shadow-ring-error: 0 0 0 4px var(--ring-error);
+ --shadow-elevation-md: 0px 10px 20px -30px color-mix(in srgb, var(--signal-blue-100) 20%, transparent);
--chart-1: var(--signal-blue-100);
--chart-2: var(--summit-sky-100);
--chart-3: var(--signal-blue-60);
@@ -163,6 +251,8 @@
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
+ --color-gradient-start: var(--gradient-start);
+ --color-gradient-end: var(--gradient-end);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
@@ -171,9 +261,28 @@
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
+ --color-success: var(--success);
+ --color-success-foreground: var(--success-foreground);
+ --color-warning: var(--warning);
+ --color-warning-foreground: var(--warning-foreground);
+ --color-error: var(--error);
+ --color-error-foreground: var(--error-foreground);
+ --color-ring-error: var(--ring-error);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
+ --color-disabled: var(--disabled);
+ --color-disabled-foreground: var(--disabled-foreground);
+ --color-code: var(--code);
+ --color-code-foreground: var(--code-foreground);
+ --color-toast-info: var(--toast-info);
+ --color-toast-info-foreground: var(--toast-info-foreground);
+ --color-toast-success: var(--toast-success);
+ --color-toast-success-foreground: var(--toast-success-foreground);
+ --color-toast-warning: var(--toast-warning);
+ --color-toast-warning-foreground: var(--toast-warning-foreground);
+ --color-toast-error: var(--toast-error);
+ --color-toast-error-foreground: var(--toast-error-foreground);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
@@ -205,6 +314,10 @@
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
+ --shadow-button-hover: var(--shadow-button-hover);
+ --shadow-thumb: var(--shadow-thumb);
+ --shadow-ring-error: var(--shadow-ring-error);
+ --shadow-elevation-md: var(--shadow-elevation-md);
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
diff --git a/packages/shared-ui/src/hooks/use-copy.ts b/packages/shared-ui/src/hooks/use-copy.ts
new file mode 100644
index 0000000..02e2a3b
--- /dev/null
+++ b/packages/shared-ui/src/hooks/use-copy.ts
@@ -0,0 +1,13 @@
+import { useState } from "react"
+
+export function useCopy(resetDelay = 2000) {
+ const [copied, setCopied] = useState(false)
+
+ const copy = async (text: string) => {
+ await navigator.clipboard.writeText(text)
+ setCopied(true)
+ window.setTimeout(() => setCopied(false), resetDelay)
+ }
+
+ return { copied, copy }
+}
diff --git a/packages/shared-ui/src/icons.tsx b/packages/shared-ui/src/icons.tsx
index 376b15e..12e72a2 100644
--- a/packages/shared-ui/src/icons.tsx
+++ b/packages/shared-ui/src/icons.tsx
@@ -1,3 +1,5 @@
+import type { SVGProps } from "react";
+
export function PayroutesLogo({ className }: { className?: string }) {
return (