diff --git a/.bruno/Payment/beneficiary/dashboard.bru b/.bruno/Payment/beneficiary/dashboard.bru
index 467af1f..84ff776 100644
--- a/.bruno/Payment/beneficiary/dashboard.bru
+++ b/.bruno/Payment/beneficiary/dashboard.bru
@@ -11,7 +11,7 @@ get {
}
params:path {
- id: 2f6dfb20-7834-484c-8472-096f72fc5f08
+ id: 16f41847-4bc4-4898-92d1-75fd314d15a8
}
settings {
diff --git a/app/components.json b/app/components.json
index 2b0833f..6a73fe0 100644
--- a/app/components.json
+++ b/app/components.json
@@ -18,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
- "registries": {}
+ "registries": {
+ "@wds": "https://wds-shadcn-registry.netlify.app/r/{name}.json"
+ }
}
diff --git a/app/package.json b/app/package.json
index ed77ae6..0d36bf0 100644
--- a/app/package.json
+++ b/app/package.json
@@ -18,12 +18,14 @@
"@module/payment": "workspace:*",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
- "@radix-ui/react-dialog": "1.1.15",
+ "@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-label": "2.1.8",
+ "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8",
- "@radix-ui/react-slot": "1.2.4",
+ "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
@@ -32,9 +34,11 @@
"@tailwindcss/vite": "4.1.17",
"@tanstack/react-router": "1.139.9",
"@tanstack/react-table": "8.21.3",
+ "@valkyr/db": "npm:@jsr/valkyr__db@2.0.0",
"@zitadel/react": "1.1.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
+ "cmdk": "^1.1.1",
"lucide-react": "0.555.0",
"next-themes": "0.4.6",
"react": "19.2.0",
diff --git a/app/src/app/dashboard/dashboard.controller.ts b/app/src/app/dashboard/dashboard.controller.ts
index 5b4de00..9175f8e 100644
--- a/app/src/app/dashboard/dashboard.controller.ts
+++ b/app/src/app/dashboard/dashboard.controller.ts
@@ -1,20 +1,3 @@
import { Controller } from "@/lib/controller.tsx";
-import { api } from "@/services/api.ts";
-export class DashboardController extends Controller<{
- beneficiaries: any[];
-}> {
- async onInit() {
- return {
- beneficiaries: await this.#getBenficiaries(),
- };
- }
-
- async #getBenficiaries() {
- const response = await api.ledger.benficiaries.list();
- if ("error" in response) {
- return [];
- }
- return response.data;
- }
-}
+export class DashboardController extends Controller {}
diff --git a/app/src/app/dashboard/dashboard.view.tsx b/app/src/app/dashboard/dashboard.view.tsx
index b1415c4..f5507a9 100644
--- a/app/src/app/dashboard/dashboard.view.tsx
+++ b/app/src/app/dashboard/dashboard.view.tsx
@@ -2,11 +2,6 @@ import { makeControllerComponent } from "@/lib/controller.tsx";
import { DashboardController } from "./dashboard.controller.ts";
-export const DashboardView = makeControllerComponent(DashboardController, ({ beneficiaries }) => {
- return (
-
- Dashboard
-
{JSON.stringify(beneficiaries, null, 2)}
-
- );
+export const DashboardView = makeControllerComponent(DashboardController, () => {
+ return Dashboard
;
});
diff --git a/app/src/app/payment/dashboard/dashboard.controller.ts b/app/src/app/payment/dashboard/dashboard.controller.ts
new file mode 100644
index 0000000..2aece52
--- /dev/null
+++ b/app/src/app/payment/dashboard/dashboard.controller.ts
@@ -0,0 +1,36 @@
+import type { Beneficiary } from "@module/payment/client";
+
+import { loadBeneficiaries, payment } from "@/database/payment.ts";
+import { Controller } from "@/lib/controller.tsx";
+import { User } from "@/services/user.ts";
+
+export class PaymentDashboardController extends Controller<{
+ beneficiaries: Beneficiary[];
+}> {
+ #subscriptions: any[] = [];
+
+ async onInit() {
+ await this.#subscribe();
+ return {
+ isCreating: false,
+ beneficiaries: [],
+ };
+ }
+
+ async onDestroy(): Promise {
+ for (const subscription of this.#subscriptions) {
+ subscription.unsubscribe();
+ }
+ }
+
+ async #subscribe() {
+ await loadBeneficiaries();
+ this.#subscriptions.push(
+ await payment
+ .collection("beneficiary")
+ .subscribe({ tenantId: await User.getTenantId() }, { sort: { label: 1 } }, (documents) => {
+ this.setState("beneficiaries", documents);
+ }),
+ );
+ }
+}
diff --git a/app/src/app/payment/dashboard/dashboard.view.tsx b/app/src/app/payment/dashboard/dashboard.view.tsx
new file mode 100644
index 0000000..2ad3c7f
--- /dev/null
+++ b/app/src/app/payment/dashboard/dashboard.view.tsx
@@ -0,0 +1,22 @@
+import { makeControllerComponent } from "@/lib/controller.tsx";
+
+import { PaymentDashboardController } from "./dashboard.controller.ts";
+
+export const PaymentDashboardView = makeControllerComponent(PaymentDashboardController, ({ beneficiaries }) => {
+ if (beneficiaries.length === 0) {
+ return (
+
+
No Beneficiaries Found
+
+ This tenant does not have a beneficiaries assigned. A beneficiary entity is required to continue.
+
+
+ );
+ }
+ return (
+
+ Payments
+
{JSON.stringify(beneficiaries, null, 2)}
+
+ );
+});
diff --git a/app/src/components/app-sidebar.controller.ts b/app/src/components/app-sidebar.controller.ts
new file mode 100644
index 0000000..ba82086
--- /dev/null
+++ b/app/src/components/app-sidebar.controller.ts
@@ -0,0 +1,34 @@
+import { GalleryVerticalEnd } from "lucide-react";
+
+import { Controller } from "@/lib/controller.tsx";
+import { User } from "@/services/user.ts";
+
+type Tenant = {
+ name: string;
+ logo: React.ElementType;
+ domain: string;
+};
+
+export class AppSiderbarController extends Controller<{
+ tenant: Tenant;
+}> {
+ async onInit() {
+ const user = await User.resolve();
+ if (user === undefined) {
+ return {
+ tenant: {
+ name: "Zitadel",
+ logo: GalleryVerticalEnd,
+ domain: "iam.valkyrjs.com",
+ },
+ };
+ }
+ return {
+ tenant: {
+ name: user.tenant.name,
+ logo: GalleryVerticalEnd,
+ domain: user.tenant.domain,
+ },
+ };
+ }
+}
diff --git a/app/src/components/app-sidebar.tsx b/app/src/components/app-sidebar.tsx
index 3a437dd..a0bf091 100644
--- a/app/src/components/app-sidebar.tsx
+++ b/app/src/components/app-sidebar.tsx
@@ -1,26 +1,7 @@
-import * as React from "react"
-import {
- IconCamera,
- IconChartBar,
- IconDashboard,
- IconDatabase,
- IconFileAi,
- IconFileDescription,
- IconFileWord,
- IconFolder,
- IconHelp,
- IconInnerShadowTop,
- IconListDetails,
- IconReport,
- IconSearch,
- IconSettings,
- IconUsers,
-} from "@tabler/icons-react"
+import { Link } from "@tanstack/react-router";
+import { GalleryVerticalEnd } from "lucide-react";
-import { NavDocuments } from "@/components/nav-documents"
-import { NavMain } from "@/components/nav-main"
-import { NavSecondary } from "@/components/nav-secondary"
-import { NavUser } from "@/components/nav-user"
+import { NavUser } from "@/components/nav-user";
import {
Sidebar,
SidebarContent,
@@ -29,151 +10,38 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
-} from "@/components/ui/sidebar"
+} from "@/components/ui/sidebar";
-const data = {
- user: {
- name: "shadcn",
- email: "m@example.com",
- avatar: "/avatars/shadcn.jpg",
- },
- navMain: [
- {
- title: "Dashboard",
- url: "#",
- icon: IconDashboard,
- },
- {
- title: "Lifecycle",
- url: "#",
- icon: IconListDetails,
- },
- {
- title: "Analytics",
- url: "#",
- icon: IconChartBar,
- },
- {
- title: "Projects",
- url: "#",
- icon: IconFolder,
- },
- {
- title: "Team",
- url: "#",
- icon: IconUsers,
- },
- ],
- navClouds: [
- {
- title: "Capture",
- icon: IconCamera,
- isActive: true,
- url: "#",
- items: [
- {
- title: "Active Proposals",
- url: "#",
- },
- {
- title: "Archived",
- url: "#",
- },
- ],
- },
- {
- title: "Proposal",
- icon: IconFileDescription,
- url: "#",
- items: [
- {
- title: "Active Proposals",
- url: "#",
- },
- {
- title: "Archived",
- url: "#",
- },
- ],
- },
- {
- title: "Prompts",
- icon: IconFileAi,
- url: "#",
- items: [
- {
- title: "Active Proposals",
- url: "#",
- },
- {
- title: "Archived",
- url: "#",
- },
- ],
- },
- ],
- navSecondary: [
- {
- title: "Settings",
- url: "#",
- icon: IconSettings,
- },
- {
- title: "Get Help",
- url: "#",
- icon: IconHelp,
- },
- {
- title: "Search",
- url: "#",
- icon: IconSearch,
- },
- ],
- documents: [
- {
- name: "Data Library",
- url: "#",
- icon: IconDatabase,
- },
- {
- name: "Reports",
- url: "#",
- icon: IconReport,
- },
- {
- name: "Word Assistant",
- url: "#",
- icon: IconFileWord,
- },
- ],
-}
+import { makeControllerComponent } from "../lib/controller.tsx";
+import { AppSiderbarController } from "./app-sidebar.controller.ts";
+import { NavPayment } from "./nav-payment.tsx";
-export function AppSidebar({ ...props }: React.ComponentProps) {
+export const AppSidebar = makeControllerComponent(AppSiderbarController, ({ tenant }) => {
return (
-
+
-
-
-
- Acme Inc.
-
+
+
+
+
+
+
+ {tenant.name}
+ {tenant.domain}
+
+
-
-
-
+
-
+
- )
-}
+ );
+});
diff --git a/app/src/components/nav-main.tsx b/app/src/components/nav-main.tsx
deleted file mode 100644
index 82afe7f..0000000
--- a/app/src/components/nav-main.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
-
-import { Button } from "@/components/ui/button"
-import {
- SidebarGroup,
- SidebarGroupContent,
- SidebarMenu,
- SidebarMenuButton,
- SidebarMenuItem,
-} from "@/components/ui/sidebar"
-
-export function NavMain({
- items,
-}: {
- items: {
- title: string
- url: string
- icon?: Icon
- }[]
-}) {
- return (
-
-
-
-
-
-
- Quick Create
-
-
-
-
-
- {items.map((item) => (
-
-
- {item.icon && }
- {item.title}
-
-
- ))}
-
-
-
- )
-}
diff --git a/app/src/components/nav-payment.controller.ts b/app/src/components/nav-payment.controller.ts
new file mode 100644
index 0000000..a8cb04e
--- /dev/null
+++ b/app/src/components/nav-payment.controller.ts
@@ -0,0 +1,85 @@
+import type { Beneficiary } from "@module/payment/client";
+import { Book, CirclePlusIcon, type LucideIcon, SquareTerminal } from "lucide-react";
+import type { FunctionComponent } from "react";
+
+import { loadBeneficiaries, payment } from "@/database/payment.ts";
+import { Controller } from "@/lib/controller.tsx";
+import { User } from "@/services/user.ts";
+
+import { DialogCreateBeneficiary } from "./payment/create-beneficiary.tsx";
+
+export class NavPaymentController extends Controller<{
+ items: MenuItem[];
+}> {
+ #subscriptions: any[] = [];
+
+ async onInit() {
+ await this.#subscribe();
+ return {
+ items: [
+ {
+ title: "Dashboard",
+ url: "/payment",
+ icon: SquareTerminal,
+ },
+ ],
+ };
+ }
+
+ async #subscribe() {
+ await loadBeneficiaries();
+ this.#subscriptions.push(
+ await payment
+ .collection("beneficiary")
+ .subscribe({ tenantId: await User.getTenantId() }, { sort: { label: 1 } }, (beneficiaries) => {
+ this.setState("items", this.#getMenuItems(beneficiaries));
+ }),
+ );
+ }
+
+ #getMenuItems(beneficiaries: Beneficiary[]): MenuItem[] {
+ return [
+ {
+ title: "Dashboard",
+ url: "/payment",
+ icon: SquareTerminal,
+ },
+ {
+ title: "Beneficiaries",
+ url: "#",
+ icon: Book,
+ isActive: true,
+ items: [
+ {
+ title: "Create Beneficiary",
+ icon: CirclePlusIcon,
+ component: DialogCreateBeneficiary,
+ },
+ ...beneficiaries.map((beneficiary) => ({
+ title: beneficiary.label ?? "Unlabeled",
+ url: `/payment/${beneficiary._id}`,
+ })),
+ ],
+ },
+ ];
+ }
+}
+
+type MenuItem = {
+ title: string;
+ url: string;
+ icon?: LucideIcon;
+ isActive?: boolean;
+ items?: SubMenuItem[];
+};
+
+type SubMenuItem =
+ | {
+ title: string;
+ url: string;
+ }
+ | {
+ title: string;
+ icon: LucideIcon;
+ component: FunctionComponent<{ title: string; icon: LucideIcon }>;
+ };
diff --git a/app/src/components/nav-payment.tsx b/app/src/components/nav-payment.tsx
new file mode 100644
index 0000000..299f989
--- /dev/null
+++ b/app/src/components/nav-payment.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { Link } from "@tanstack/react-router";
+import { ChevronRight } from "lucide-react";
+
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import {
+ SidebarGroup,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+} from "@/components/ui/sidebar";
+
+import { makeControllerComponent } from "../lib/controller.tsx";
+import { NavPaymentController } from "./nav-payment.controller.ts";
+
+export const NavPayment = makeControllerComponent(NavPaymentController, ({ items }) => {
+ return (
+
+ Payment
+
+ {items.map((item) => {
+ if (item.items !== undefined) {
+ return (
+
+
+
+
+ {item.icon && }
+ {item.title}
+
+
+
+
+
+ {item.items?.map((subItem) => {
+ if ("component" in subItem) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ {subItem.title}
+
+
+
+ );
+ })}
+
+
+
+
+ );
+ }
+ return (
+
+
+
+ {item.icon && }
+ {item.title}
+
+
+
+ );
+ })}
+
+
+ );
+});
diff --git a/app/src/components/nav-user.controller.ts b/app/src/components/nav-user.controller.ts
index 6f8def2..76397ca 100644
--- a/app/src/components/nav-user.controller.ts
+++ b/app/src/components/nav-user.controller.ts
@@ -1,5 +1,6 @@
import { Controller } from "@/lib/controller.tsx";
-import { type User as ZitadelUser, zitadel } from "@/services/zitadel.ts";
+import { User } from "@/services/user.ts";
+import { zitadel } from "@/services/zitadel.ts";
export class NavUserController extends Controller<{
user: User;
@@ -11,38 +12,14 @@ export class NavUserController extends Controller<{
}
async #getAuthenticatedUser(): Promise {
- const user = await zitadel.userManager.getUser();
- if (user === null) {
+ const user = await User.resolve();
+ if (user === undefined) {
throw new Error("Failed to resolve user session");
}
- return getUserProfile(user);
+ return user;
}
signout() {
zitadel.signout();
}
}
-
-function getUserProfile({ profile }: ZitadelUser): User {
- const user: User = { name: "Unknown", email: "unknown@acme.none", avatar: "" };
- if (profile.name) {
- user.name = profile.name;
- } else if (profile.given_name && profile.family_name) {
- user.name = `${profile.given_name} ${profile.family_name}`;
- } else if (profile.given_name) {
- user.name = profile.given_name;
- }
- if (profile.email) {
- user.email = profile.email;
- }
- if (profile.picture !== undefined) {
- user.avatar = profile.picture;
- }
- return user;
-}
-
-type User = {
- name: string;
- email: string;
- avatar: string;
-};
diff --git a/app/src/components/payment/create-beneficiary.controller.ts b/app/src/components/payment/create-beneficiary.controller.ts
new file mode 100644
index 0000000..20486bb
--- /dev/null
+++ b/app/src/components/payment/create-beneficiary.controller.ts
@@ -0,0 +1,41 @@
+import type { LucideIcon } from "lucide-react";
+
+import { payment } from "@/database/payment.ts";
+import { Controller } from "@/lib/controller.tsx";
+import { api, getSuccessResponse } from "@/services/api.ts";
+import { User } from "@/services/user.ts";
+
+export class CreateBeneficiaryController extends Controller<
+ {
+ isSubmitting: boolean;
+ },
+ { title: string; icon: LucideIcon }
+> {
+ #label?: string;
+
+ async onInit() {
+ return {
+ isSubmitting: false,
+ };
+ }
+
+ setLabel(label: string) {
+ this.#label = label;
+ }
+
+ async submit() {
+ this.setState("isSubmitting", true);
+ const res = await api.payment.benficiaries.create({
+ body: {
+ tenantId: await User.getTenantId(),
+ label: this.#label,
+ },
+ });
+ if ("data" in res) {
+ await payment
+ .collection("beneficiary")
+ .insertOne(await getSuccessResponse(api.payment.benficiaries.id({ params: { id: res.data } })));
+ }
+ this.setState("isSubmitting", false);
+ }
+}
diff --git a/app/src/components/payment/create-beneficiary.tsx b/app/src/components/payment/create-beneficiary.tsx
new file mode 100644
index 0000000..3015786
--- /dev/null
+++ b/app/src/components/payment/create-beneficiary.tsx
@@ -0,0 +1,58 @@
+import { useId } from "react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { LoadingSwap } from "@/components/ui/loading-swap";
+import { SidebarMenuSubButton } from "@/components/ui/sidebar.tsx";
+
+import { makeControllerComponent } from "../../lib/controller.tsx";
+import { CreateBeneficiaryController } from "./create-beneficiary.controller.ts";
+
+export const DialogCreateBeneficiary = makeControllerComponent(
+ CreateBeneficiaryController,
+ ({ title, setLabel, submit, isSubmitting, ...props }) => {
+ const labelId = useId();
+ return (
+
+ );
+ },
+);
diff --git a/app/src/components/payment/create-ledger.controller.ts b/app/src/components/payment/create-ledger.controller.ts
new file mode 100644
index 0000000..d75989e
--- /dev/null
+++ b/app/src/components/payment/create-ledger.controller.ts
@@ -0,0 +1,43 @@
+import { CurrencySchema } from "@module/payment/client";
+import type { LucideIcon } from "lucide-react";
+
+import { api } from "@/services/api.ts";
+
+import { Controller } from "../../lib/controller.tsx";
+
+export class CreateLedgerController extends Controller<
+ {
+ isSubmitting: boolean;
+ },
+ { title: string; icon: LucideIcon }
+> {
+ #label?: string;
+ #currencies: string[] = [];
+
+ async onInit() {
+ return {
+ isSubmitting: false,
+ };
+ }
+
+ setLabel(label: string) {
+ this.#label = label;
+ }
+
+ setCurrencies(currencies: string[]) {
+ this.#currencies = currencies;
+ }
+
+ async submit() {
+ this.setState("isSubmitting", true);
+ const result = await api.payment.ledger.create({
+ body: {
+ beneficiaryId: crypto.randomUUID(),
+ label: this.#label,
+ currencies: this.#currencies.map((currency) => CurrencySchema.parse(currency)),
+ },
+ });
+ console.log(result);
+ this.setState("isSubmitting", false);
+ }
+}
diff --git a/app/src/components/payment/create-ledger.tsx b/app/src/components/payment/create-ledger.tsx
new file mode 100644
index 0000000..e7e5ad0
--- /dev/null
+++ b/app/src/components/payment/create-ledger.tsx
@@ -0,0 +1,84 @@
+import { currencies } from "@module/payment/client";
+import { useId } from "react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { LoadingSwap } from "@/components/ui/loading-swap";
+import {
+ MultiSelect,
+ MultiSelectContent,
+ MultiSelectGroup,
+ MultiSelectItem,
+ MultiSelectTrigger,
+ MultiSelectValue,
+} from "@/components/ui/multi-select";
+import { SidebarMenuSubButton } from "@/components/ui/sidebar.tsx";
+
+import { makeControllerComponent } from "../../lib/controller.tsx";
+import { CreateLedgerController } from "./create-ledger.controller.ts";
+
+export const DialogCreateLedger = makeControllerComponent(
+ CreateLedgerController,
+ ({ title, setLabel, setCurrencies, submit, isSubmitting, ...props }) => {
+ const labelId = useId();
+ return (
+
+ );
+ },
+);
diff --git a/app/src/components/tenant-switcher.controller.ts b/app/src/components/tenant-switcher.controller.ts
new file mode 100644
index 0000000..e70bd96
--- /dev/null
+++ b/app/src/components/tenant-switcher.controller.ts
@@ -0,0 +1,45 @@
+import { AudioWaveform, Command, GalleryVerticalEnd } from "lucide-react";
+
+import { Controller } from "@/lib/controller.tsx";
+
+type Tenant = {
+ name: string;
+ logo: React.ElementType;
+ plan: string;
+};
+
+export class TenantSwitcherController extends Controller<{
+ activeTenant?: Tenant;
+ tenants: Tenant[];
+}> {
+ async onInit() {
+ return {
+ activeTenant: {
+ name: "Acme Inc",
+ logo: GalleryVerticalEnd,
+ plan: "Enterprise",
+ },
+ tenants: [
+ {
+ name: "Acme Inc",
+ logo: GalleryVerticalEnd,
+ plan: "Enterprise",
+ },
+ {
+ name: "Acme Corp.",
+ logo: AudioWaveform,
+ plan: "Startup",
+ },
+ {
+ name: "Evil Corp.",
+ logo: Command,
+ plan: "Free",
+ },
+ ],
+ };
+ }
+
+ setActiveTenant(tenant: Tenant) {
+ this.setState("activeTenant", tenant);
+ }
+}
diff --git a/app/src/components/tenant-switcher.tsx b/app/src/components/tenant-switcher.tsx
new file mode 100644
index 0000000..1659ef6
--- /dev/null
+++ b/app/src/components/tenant-switcher.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { ChevronsUpDown, Plus } from "lucide-react";
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar";
+
+import { makeControllerComponent } from "../lib/controller.tsx";
+import { TenantSwitcherController } from "./tenant-switcher.controller.ts";
+
+export const TenantSwitcher = makeControllerComponent(
+ TenantSwitcherController,
+ ({ tenants, activeTenant, setActiveTenant }) => {
+ const { isMobile } = useSidebar();
+ if (activeTenant === undefined) {
+ return null;
+ }
+ return (
+
+
+
+
+
+
+
+ {activeTenant.name}
+ {activeTenant.plan}
+
+
+
+
+
+ Teams
+ {tenants.map((tenant, index) => (
+ setActiveTenant(tenant)} className="gap-2 p-2">
+
+
+
+ {tenant.name}
+ ⌘{index + 1}
+
+ ))}
+
+
+
+ Add team
+
+
+
+
+
+ );
+ },
+);
diff --git a/app/src/components/ui/button.tsx b/app/src/components/ui/button.tsx
index 21409a0..1eede63 100644
--- a/app/src/components/ui/button.tsx
+++ b/app/src/components/ui/button.tsx
@@ -1,8 +1,8 @@
-import * as React from "react"
-import { Slot } from "@radix-ui/react-slot"
-import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import type * as React from "react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@@ -14,10 +14,8 @@ const buttonVariants = cva(
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
- secondary:
- "bg-secondary text-secondary-foreground hover:bg-secondary/80",
- ghost:
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
@@ -33,8 +31,8 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
- }
-)
+ },
+);
function Button({
className,
@@ -44,17 +42,11 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps & {
- asChild?: boolean
+ asChild?: boolean;
}) {
- const Comp = asChild ? Slot : "button"
+ const Comp = asChild ? Slot : "button";
- return (
-
- )
+ return ;
}
-export { Button, buttonVariants }
+export { Button, buttonVariants };
diff --git a/app/src/components/ui/collapsible.tsx b/app/src/components/ui/collapsible.tsx
new file mode 100644
index 0000000..77f86be
--- /dev/null
+++ b/app/src/components/ui/collapsible.tsx
@@ -0,0 +1,31 @@
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
+
+function Collapsible({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function CollapsibleTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CollapsibleContent({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/app/src/components/ui/command.tsx b/app/src/components/ui/command.tsx
new file mode 100644
index 0000000..e5bd56b
--- /dev/null
+++ b/app/src/components/ui/command.tsx
@@ -0,0 +1,182 @@
+import * as React from "react"
+import { Command as CommandPrimitive } from "cmdk"
+import { SearchIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+
+function Command({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandDialog({
+ title = "Command Palette",
+ description = "Search for a command to run...",
+ children,
+ className,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ title?: string
+ description?: string
+ className?: string
+ showCloseButton?: boolean
+}) {
+ return (
+
+ )
+}
+
+function CommandInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function CommandList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandEmpty({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+}
diff --git a/app/src/components/ui/dialog.tsx b/app/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..6cb123b
--- /dev/null
+++ b/app/src/components/ui/dialog.tsx
@@ -0,0 +1,141 @@
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { XIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Dialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/app/src/components/ui/loading-swap.tsx b/app/src/components/ui/loading-swap.tsx
new file mode 100644
index 0000000..cdcd76e
--- /dev/null
+++ b/app/src/components/ui/loading-swap.tsx
@@ -0,0 +1,33 @@
+import { Loader2Icon } from "lucide-react";
+import type { ReactNode } from "react";
+
+import { cn } from "@/lib/utils";
+
+export function LoadingSwap({
+ isLoading,
+ children,
+ className,
+}: {
+ isLoading: boolean;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/app/src/components/ui/multi-select.tsx b/app/src/components/ui/multi-select.tsx
new file mode 100644
index 0000000..2d59a07
--- /dev/null
+++ b/app/src/components/ui/multi-select.tsx
@@ -0,0 +1,325 @@
+"use client";
+
+import { CheckIcon, ChevronsUpDownIcon, XIcon } from "lucide-react";
+import {
+ type ComponentPropsWithoutRef,
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "@/components/ui/command";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
+
+type MultiSelectContextType = {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ selectedValues: Set;
+ toggleValue: (value: string) => void;
+ items: Map;
+ onItemAdded: (value: string, label: ReactNode) => void;
+};
+const MultiSelectContext = createContext(null);
+
+export function MultiSelect({
+ children,
+ values,
+ defaultValues,
+ onValuesChange,
+}: {
+ children: ReactNode;
+ values?: string[];
+ defaultValues?: string[];
+ onValuesChange?: (values: string[]) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [internalValues, setInternalValues] = useState(new Set(values ?? defaultValues));
+ const selectedValues = values ? new Set(values) : internalValues;
+ const [items, setItems] = useState