diff --git a/README.md b/README.md index a919bb31..42e78242 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,26 @@ Mixan is a simple analytics tool for logging events on web and react-native. My goal is to make a minimal mixpanel copy with the most basic features (for now). +## Whats left? + +* [ ] Real time data (mostly screen_views stats) + * [ ] Active users (5min, 10min, 30min) +* [ ] Save report to a specific dashboard +* [ ] View events in a list +* [ ] View profiles in a list +* [ ] Invite users +* [ ] Drag n Drop reports on dashboard +* [ ] Manage dashboards +* [ ] Support more chart types + * [ ] Bar + * [ ] Pie + * [ ] Area +* [ ] Support funnels +* [ ] Create native sdk +* [ ] Create web sdk +* [ ] Support multiple breakdowns +* [ ] Aggregations (sum, average...) + ## @mixan/sdk For pushing events diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index 2ba00197..3898ad56 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -22,6 +22,8 @@ const config = { "@typescript-eslint/no-unsafe-argument": "off", "@typescript-eslint/no-unsafe-return": "off", "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-misused-promises": "off", + "@typescript-eslint/no-floating-promises": "off", "@typescript-eslint/consistent-type-imports": [ "warn", { diff --git a/apps/web/components.json b/apps/web/components.json index b25b509f..9078e044 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -4,7 +4,7 @@ "rsc": false, "tsx": true, "tailwind": { - "config": "tailwind.config.js", + "config": "tailwind.config.ts", "css": "src/styles/globals.css", "baseColor": "slate", "cssVariables": true diff --git a/apps/web/package.json b/apps/web/package.json index bedcd065..d928b143 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,19 +11,25 @@ "start": "next start" }, "dependencies": { + "@hookform/resolvers": "^3.3.2", "@mixan/types": "^0.0.2-alpha", "@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "^5.1.1", + "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-tooltip": "^1.0.7", "@reduxjs/toolkit": "^1.9.7", "@t3-oss/env-nextjs": "^0.7.0", "@tanstack/react-query": "^4.32.6", + "@tanstack/react-table": "^8.10.7", "@trpc/client": "^10.37.1", "@trpc/next": "^10.37.1", "@trpc/react-query": "^10.37.1", @@ -33,13 +39,16 @@ "clsx": "^2.0.0", "cmdk": "^0.2.0", "lucide-react": "^0.286.0", + "mitt": "^3.0.1", "next": "^13.5.4", "next-auth": "^4.23.0", "ramda": "^0.29.1", "random-animal-name": "^0.1.1", "react": "18.2.0", "react-dom": "18.2.0", + "react-hook-form": "^7.47.0", "react-redux": "^8.1.3", + "react-syntax-highlighter": "^15.5.0", "react-virtualized-auto-sizer": "^1.0.20", "recharts": "^2.8.0", "superjson": "^1.13.1", @@ -55,6 +64,7 @@ "@types/ramda": "^0.29.6", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", + "@types/react-syntax-highlighter": "^15.5.9", "@typescript-eslint/eslint-plugin": "^6.3.0", "@typescript-eslint/parser": "^6.3.0", "autoprefixer": "^10.4.14", diff --git a/apps/web/prisma/migrations/20231023172003_org_to_clients/migration.sql b/apps/web/prisma/migrations/20231023172003_org_to_clients/migration.sql new file mode 100644 index 00000000..f19f019f --- /dev/null +++ b/apps/web/prisma/migrations/20231023172003_org_to_clients/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "clients" ADD COLUMN "organization_id" UUID; + +-- AddForeignKey +ALTER TABLE "clients" ADD CONSTRAINT "clients_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/web/prisma/migrations/20231023172105_org_required_for_client/migration.sql b/apps/web/prisma/migrations/20231023172105_org_required_for_client/migration.sql new file mode 100644 index 00000000..4ec880f9 --- /dev/null +++ b/apps/web/prisma/migrations/20231023172105_org_required_for_client/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `organization_id` on table `clients` required. This step will fail if there are existing NULL values in that column. + +*/ +-- DropForeignKey +ALTER TABLE "clients" DROP CONSTRAINT "clients_organization_id_fkey"; + +-- AlterTable +ALTER TABLE "clients" ALTER COLUMN "organization_id" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "clients" ADD CONSTRAINT "clients_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/web/prisma/migrations/20231024074846_add_slugs/migration.sql b/apps/web/prisma/migrations/20231024074846_add_slugs/migration.sql new file mode 100644 index 00000000..fec4a484 --- /dev/null +++ b/apps/web/prisma/migrations/20231024074846_add_slugs/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - A unique constraint covering the columns `[slug]` on the table `dashboards` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[slug]` on the table `organizations` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[slug]` on the table `projects` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "dashboards" ADD COLUMN "slug" TEXT NOT NULL DEFAULT gen_random_uuid(); + +-- AlterTable +ALTER TABLE "organizations" ADD COLUMN "slug" TEXT NOT NULL DEFAULT gen_random_uuid(); + +-- AlterTable +ALTER TABLE "projects" ADD COLUMN "slug" TEXT NOT NULL DEFAULT gen_random_uuid(); + +-- CreateIndex +CREATE UNIQUE INDEX "dashboards_slug_key" ON "dashboards"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "projects_slug_key" ON "projects"("slug"); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 646f9af6..57a1366e 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -13,11 +13,13 @@ datasource db { model Organization { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid name String + slug String @unique @default(dbgenerated("gen_random_uuid()")) projects Project[] users User[] createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + clients Client[] @@map("organizations") } @@ -25,6 +27,7 @@ model Organization { model Project { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid name String + slug String @unique @default(dbgenerated("gen_random_uuid()")) organization_id String @db.Uuid organization Organization @relation(fields: [organization_id], references: [id]) events Event[] @@ -88,11 +91,13 @@ model Profile { } model Client { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - name String - secret String - project_id String @db.Uuid - project Project @relation(fields: [project_id], references: [id]) + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + secret String + project_id String @db.Uuid + project Project @relation(fields: [project_id], references: [id]) + organization_id String @db.Uuid + organization Organization @relation(fields: [organization_id], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@ -117,6 +122,7 @@ enum ChartType { model Dashboard { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid name String + slug String @unique @default(dbgenerated("gen_random_uuid()")) project_id String @db.Uuid project Project @relation(fields: [project_id], references: [id]) reports Report[] diff --git a/apps/web/src/_middleware.ts b/apps/web/src/_middleware.ts new file mode 100644 index 00000000..a127de59 --- /dev/null +++ b/apps/web/src/_middleware.ts @@ -0,0 +1,3 @@ +export { default } from "next-auth/middleware" + +export const config = { matcher: ["/dashboard"] } \ No newline at end of file diff --git a/apps/web/src/components/ButtonContainer.tsx b/apps/web/src/components/ButtonContainer.tsx new file mode 100644 index 00000000..d8bd6c71 --- /dev/null +++ b/apps/web/src/components/ButtonContainer.tsx @@ -0,0 +1,8 @@ +import { type HtmlProps } from "@/types"; +import { cn } from "@/utils/cn"; + +export function ButtonContainer({className,...props}: HtmlProps) { + return ( +
+ ); +} diff --git a/apps/web/src/components/Card.tsx b/apps/web/src/components/Card.tsx new file mode 100644 index 00000000..67ba3155 --- /dev/null +++ b/apps/web/src/components/Card.tsx @@ -0,0 +1,9 @@ +import { type HtmlProps } from "@/types"; + +export function Card({children}: HtmlProps) { + return ( +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/Container.tsx b/apps/web/src/components/Container.tsx index 7bdede15..2647fb52 100644 --- a/apps/web/src/components/Container.tsx +++ b/apps/web/src/components/Container.tsx @@ -3,6 +3,6 @@ import { cn } from "@/utils/cn"; export function Container({className,...props}: HtmlProps) { return ( -
+
); } diff --git a/apps/web/src/components/Content.tsx b/apps/web/src/components/Content.tsx new file mode 100644 index 00000000..9818b936 --- /dev/null +++ b/apps/web/src/components/Content.tsx @@ -0,0 +1,50 @@ +import { cn } from "@/utils/cn"; + +type ContentHeaderProps = { + title: string; + text: string; + children?: React.ReactNode; +}; + +export function ContentHeader({ title, text, children }: ContentHeaderProps) { + return ( +
+
+

{title}

+

{text}

+
+
{children}
+
+ ); +} + +type ContentSectionProps = { + title: string; + text: string; + children: React.ReactNode; + asCol?: boolean; +}; + +export function ContentSection({ + title, + text, + children, + asCol, +}: ContentSectionProps) { + return ( +
+ {title && ( +
+

{title}

+

{text}

+
+ )} +
{children}
+
+ ); +} diff --git a/apps/web/src/components/DataTable.tsx b/apps/web/src/components/DataTable.tsx new file mode 100644 index 00000000..57f832a3 --- /dev/null +++ b/apps/web/src/components/DataTable.tsx @@ -0,0 +1,66 @@ +import { type ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ) +} + diff --git a/apps/web/src/components/Dropdown.tsx b/apps/web/src/components/Dropdown.tsx new file mode 100644 index 00000000..a9f34cc4 --- /dev/null +++ b/apps/web/src/components/Dropdown.tsx @@ -0,0 +1,49 @@ +import { cloneElement } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; + +type DropdownProps = { + children: React.ReactNode; + label?: string; + items: Array<{ + label: string; + value: Value; + }>; + onChange?: (value: Value) => void; +}; + +export function Dropdown({ children, label, items, onChange }: DropdownProps) { + return ( + + {children} + + {label && ( + <> + {label} + + + )} + + {items.map((item) => ( + { + onChange?.(item.value); + }} + > + {item.label} + + ))} + + + + ); +} diff --git a/apps/web/src/components/Navbar.tsx b/apps/web/src/components/Navbar.tsx deleted file mode 100644 index 9f1cdd5f..00000000 --- a/apps/web/src/components/Navbar.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import Link from "next/link"; - -export function Navbar() { - return ( -
- Dashboards - Reports -
- ); -} diff --git a/apps/web/src/components/PageTitle.tsx b/apps/web/src/components/PageTitle.tsx new file mode 100644 index 00000000..23470338 --- /dev/null +++ b/apps/web/src/components/PageTitle.tsx @@ -0,0 +1,11 @@ +import { type HtmlProps } from "@/types"; + +type PageTitleProps = HtmlProps; + +export function PageTitle({ children }: PageTitleProps) { + return ( +
+

{children}

+
+ ); +} diff --git a/apps/web/src/components/Syntax.tsx b/apps/web/src/components/Syntax.tsx new file mode 100644 index 00000000..9df05e13 --- /dev/null +++ b/apps/web/src/components/Syntax.tsx @@ -0,0 +1,13 @@ +import { Light as SyntaxHighlighter } from "react-syntax-highlighter"; +import ts from "react-syntax-highlighter/dist/cjs/languages/hljs/typescript"; +import docco from "react-syntax-highlighter/dist/cjs/styles/hljs/docco"; + +SyntaxHighlighter.registerLanguage("typescript", ts); + +type SyntaxProps = { + code: string; +}; + +export default function Syntax({ code }: SyntaxProps) { + return {code}; +} diff --git a/apps/web/src/components/UserDropdown.tsx b/apps/web/src/components/UserDropdown.tsx deleted file mode 100644 index 3b67adf8..00000000 --- a/apps/web/src/components/UserDropdown.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { User } from "lucide-react"; - -export function UserDropdown() { - return ( - - - - - - - - - Profile - - - - Organization - - - - - Logout - - - - - ) -} \ No newline at end of file diff --git a/apps/web/src/components/WithSidebar.tsx b/apps/web/src/components/WithSidebar.tsx new file mode 100644 index 00000000..e566d83f --- /dev/null +++ b/apps/web/src/components/WithSidebar.tsx @@ -0,0 +1,21 @@ +import { type HtmlProps } from "@/types"; + +type WithSidebarProps = HtmlProps + +export function WithSidebar({children}: WithSidebarProps) { + return ( +
+ {children} +
+ ) +} + +type SidebarProps = HtmlProps + +export function Sidebar({children}: SidebarProps) { + return ( +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/clients/ClientActions.tsx b/apps/web/src/components/clients/ClientActions.tsx new file mode 100644 index 00000000..dba20f84 --- /dev/null +++ b/apps/web/src/components/clients/ClientActions.tsx @@ -0,0 +1,69 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { Button } from "../ui/button"; +import { MoreHorizontal } from "lucide-react"; +import { pushModal, showConfirm } from "@/modals"; +import { type IClientWithProject } from "@/types"; +import { clipboard } from "@/utils/clipboard"; +import { api } from "@/utils/api"; +import { useRefetchActive } from "@/hooks/useRefetchActive"; +import { toast } from "../ui/use-toast"; + +export function ClientActions({ id }: IClientWithProject) { + const refetch = useRefetchActive() + const deletion = api.client.remove.useMutation({ + onSuccess() { + toast({ + title: 'Success', + description: 'Client revoked, incoming requests will be rejected.', + }) + refetch() + } + }) + return ( + + + + + + Actions + clipboard(id)}> + Copy client ID + + { + pushModal("EditClient", { id }); + }} + > + Edit + + + { + showConfirm({ + title: 'Revoke client', + text: 'Are you sure you want to revoke this client? This action cannot be undone.', + onConfirm() { + deletion.mutate({ + id, + }) + } + }) + }} + > + Revoke + + + + ); +} diff --git a/apps/web/src/components/clients/table.tsx b/apps/web/src/components/clients/table.tsx new file mode 100644 index 00000000..16adabb1 --- /dev/null +++ b/apps/web/src/components/clients/table.tsx @@ -0,0 +1,43 @@ +import { formatDate } from "@/utils/date"; +import { type ColumnDef } from "@tanstack/react-table"; +import { type IClientWithProject } from "@/types"; +import { ClientActions } from "./ClientActions"; + +export const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => { + return ( +
+
{row.original.name}
+
+ {row.original.project.name} +
+
+ ); + }, + }, + { + accessorKey: "id", + header: "Client ID", + }, + { + accessorKey: "secret", + header: "Secret", + cell: () =>
Hidden
, + }, + { + accessorKey: "createdAt", + header: "Created at", + cell({ row }) { + const date = row.original.createdAt; + return
{formatDate(date)}
; + }, + }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => , + }, +]; diff --git a/apps/web/src/components/forms/InputWithLabel.tsx b/apps/web/src/components/forms/InputWithLabel.tsx new file mode 100644 index 00000000..2a89fcaa --- /dev/null +++ b/apps/web/src/components/forms/InputWithLabel.tsx @@ -0,0 +1,18 @@ +import { forwardRef } from "react"; +import { Input, type InputProps } from "../ui/input"; +import { Label } from "../ui/label"; + +type InputWithLabelProps = InputProps & { + label: string; +}; + +export const InputWithLabel = forwardRef(({ label, ...props }, ref) => { + return ( +
+ + +
+ ); +}); + +InputWithLabel.displayName = "InputWithLabel"; diff --git a/apps/web/src/components/layouts/Main.tsx b/apps/web/src/components/layouts/Main.tsx deleted file mode 100644 index 7b8f0cbd..00000000 --- a/apps/web/src/components/layouts/Main.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import Head from "next/head"; -import { UserDropdown } from "../UserDropdown"; -import { Navbar } from "../Navbar"; - -type MainLayoutProps = { - children: React.ReactNode; - className?: string; -} - -export function MainLayout({ children, className }: MainLayoutProps) { - return ( - <> - - Create T3 App - - - - -
- {children} -
- - ); -} diff --git a/apps/web/src/components/layouts/MainLayout.tsx b/apps/web/src/components/layouts/MainLayout.tsx new file mode 100644 index 00000000..8739ecf4 --- /dev/null +++ b/apps/web/src/components/layouts/MainLayout.tsx @@ -0,0 +1,29 @@ +import { NavbarUserDropdown } from "../navbar/NavbarUserDropdown"; +import { NavbarMenu } from "../navbar/NavbarMenu"; +import { Container } from "../Container"; +import Link from "next/link"; + +type MainLayoutProps = { + children: React.ReactNode; + className?: string; +}; + +export function MainLayout({ children, className }: MainLayoutProps) { + return ( + <> +
+ +
{children}
+ + ); +} diff --git a/apps/web/src/components/layouts/SettingsLayout.tsx b/apps/web/src/components/layouts/SettingsLayout.tsx new file mode 100644 index 00000000..e24d1b04 --- /dev/null +++ b/apps/web/src/components/layouts/SettingsLayout.tsx @@ -0,0 +1,52 @@ +import { Container } from "../Container"; +import { MainLayout } from "./MainLayout"; +import { usePathname } from "next/navigation"; +import { Sidebar, WithSidebar } from "../WithSidebar"; +import Link from "next/link"; +import { cn } from "@/utils/cn"; +import { PageTitle } from "../PageTitle"; +import { useOrganizationParams } from "@/hooks/useOrganizationParams"; + +type SettingsLayoutProps = { + children: React.ReactNode; + className?: string; +}; + +export function SettingsLayout({ children, className }: SettingsLayoutProps) { + const params = useOrganizationParams(); + const pathname = usePathname(); + const links = [ + { href: `/${params.organization}/settings/organization`, label: "Organization" }, + { href: `/${params.organization}/settings/projects`, label: "Projects" }, + { href: `/${params.organization}/settings/clients`, label: "Clients" }, + { href: `/${params.organization}/settings/profile`, label: "Profile" }, + ]; + return ( + + + Settings + + + {links.map(({ href, label }) => ( + + {label} + + ))} + +
+ {children} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/navbar/NavbarCreate.tsx b/apps/web/src/components/navbar/NavbarCreate.tsx new file mode 100644 index 00000000..53d7d0fb --- /dev/null +++ b/apps/web/src/components/navbar/NavbarCreate.tsx @@ -0,0 +1,36 @@ +import { LineChart } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useOrganizationParams } from "@/hooks/useOrganizationParams"; +import Link from "next/link"; + +export function NavbarCreate() { + const params = useOrganizationParams(); + return ( + + + + + + Actions + + + + + + Create a report + + + + + + ); +} diff --git a/apps/web/src/components/navbar/NavbarMenu.tsx b/apps/web/src/components/navbar/NavbarMenu.tsx new file mode 100644 index 00000000..7dca2029 --- /dev/null +++ b/apps/web/src/components/navbar/NavbarMenu.tsx @@ -0,0 +1,13 @@ +import { useOrganizationParams } from "@/hooks/useOrganizationParams"; +import Link from "next/link"; +import { NavbarCreate } from "./NavbarCreate"; + +export function NavbarMenu() { + const params = useOrganizationParams() + return ( +
+ Home + +
+ ); +} diff --git a/apps/web/src/components/navbar/NavbarUserDropdown.tsx b/apps/web/src/components/navbar/NavbarUserDropdown.tsx new file mode 100644 index 00000000..939574c0 --- /dev/null +++ b/apps/web/src/components/navbar/NavbarUserDropdown.tsx @@ -0,0 +1,67 @@ +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useOrganizationParams } from "@/hooks/useOrganizationParams"; +import { User } from "lucide-react"; +import { signOut } from "next-auth/react"; +import Link from "next/link"; + +export function NavbarUserDropdown() { + const params = useOrganizationParams(); + + return ( + + + + + + + + + + Organization + + + + + + Projects + + + + + + Clients + + + + + + Profile + + + + { + signOut().catch(console.error); + }} + > + + Logout + + + + + ); +} diff --git a/apps/web/src/components/projects/ProjectActions.tsx b/apps/web/src/components/projects/ProjectActions.tsx new file mode 100644 index 00000000..9163e7bb --- /dev/null +++ b/apps/web/src/components/projects/ProjectActions.tsx @@ -0,0 +1,70 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { Button } from "../ui/button"; +import { MoreHorizontal } from "lucide-react"; +import { pushModal, showConfirm } from "@/modals"; +import { type IProject } from "@/types"; +import { clipboard } from "@/utils/clipboard"; +import { useRefetchActive } from "@/hooks/useRefetchActive"; +import { api } from "@/utils/api"; +import { toast } from "../ui/use-toast"; + +export function ProjectActions({ id }: IProject) { + const refetch = useRefetchActive() + const deletion = api.project.remove.useMutation({ + onSuccess() { + toast({ + title: 'Success', + description: 'Project deleted successfully.', + }) + refetch() + } + }) + + return ( + + + + + + Actions + clipboard(id)}> + Copy project ID + + { + pushModal("EditProject", { id }); + }} + > + Edit + + + { + showConfirm({ + title: 'Delete project', + text: 'This will delete all events for this project. This action cannot be undone.', + onConfirm() { + deletion.mutate({ + id, + }) + } + }) + }} + > + Delete + + + + ); +} diff --git a/apps/web/src/components/projects/table.tsx b/apps/web/src/components/projects/table.tsx new file mode 100644 index 00000000..d967505b --- /dev/null +++ b/apps/web/src/components/projects/table.tsx @@ -0,0 +1,26 @@ +import { formatDate } from "@/utils/date"; +import { type ColumnDef } from "@tanstack/react-table"; +import { type Project as IProject } from "@prisma/client"; +import { ProjectActions } from "./ProjectActions"; + +export type Project = IProject; + +export const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + }, + { + accessorKey: "createdAt", + header: "Created at", + cell({ row }) { + const date = row.original.createdAt; + return
{formatDate(date)}
; + }, + }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => , + }, +]; diff --git a/apps/web/src/components/report/ReportDateRange.tsx b/apps/web/src/components/report/ReportDateRange.tsx index 938e8740..cf96683c 100644 --- a/apps/web/src/components/report/ReportDateRange.tsx +++ b/apps/web/src/components/report/ReportDateRange.tsx @@ -13,11 +13,18 @@ export function ReportDateRange() { { - dispatch(changeDateRanges(1)); + dispatch(changeDateRanges('today')); }} > Today + { + dispatch(changeDateRanges(1)); + }} + > + 24 hours + { dispatch(changeDateRanges(7)); diff --git a/apps/web/src/components/report/chart/ReportLineChart.tsx b/apps/web/src/components/report/chart/ReportLineChart.tsx index 486d67bb..dcd49ce0 100644 --- a/apps/web/src/components/report/chart/ReportLineChart.tsx +++ b/apps/web/src/components/report/chart/ReportLineChart.tsx @@ -31,7 +31,9 @@ export function ReportLineChart({ }: ReportLineChartProps) { const [visibleSeries, setVisibleSeries] = useState([]); - const chart = api.chartMeta.chart.useQuery( + const hasEmptyFilters = events.some((event) => event.filters.some((filter) => filter.value.length === 0)); + + const chart = api.chart.chart.useQuery( { interval, chartType, @@ -42,7 +44,7 @@ export function ReportLineChart({ name, }, { - enabled: events.length > 0, + enabled: events.length > 0 && !hasEmptyFilters, }, ); @@ -67,10 +69,11 @@ export function ReportLineChart({ {({ width }) => ( - + } /> { return formatDate(m); diff --git a/apps/web/src/components/report/chart/ReportLineChartTooltop.tsx b/apps/web/src/components/report/chart/ReportLineChartTooltop.tsx index a45208c8..3fa06eae 100644 --- a/apps/web/src/components/report/chart/ReportLineChartTooltop.tsx +++ b/apps/web/src/components/report/chart/ReportLineChartTooltop.tsx @@ -1,3 +1,4 @@ +import { useMappings } from "@/hooks/useMappings"; import { type IToolTipProps } from "@/types"; type ReportLineChartTooltipProps = IToolTipProps<{ @@ -8,13 +9,15 @@ type ReportLineChartTooltipProps = IToolTipProps<{ count: number; label: string; }; -}> +}>; export function ReportLineChartTooltip({ active, payload, }: ReportLineChartTooltipProps) { - if (!active || !payload) { + const getLabel = useMappings(); + + if (!active || !payload) { return null; } @@ -22,7 +25,6 @@ export function ReportLineChartTooltip({ return null; } - const limit = 3; const sorted = payload.slice(0).sort((a, b) => b.value - a.value); const visible = sorted.slice(0, limit); @@ -39,7 +41,7 @@ export function ReportLineChartTooltip({ >
- {item.payload.label} + {getLabel(item.payload.label)}
{item.payload.count}
diff --git a/apps/web/src/components/report/chart/ReportTable.tsx b/apps/web/src/components/report/chart/ReportTable.tsx index cfdf5708..fc43f589 100644 --- a/apps/web/src/components/report/chart/ReportTable.tsx +++ b/apps/web/src/components/report/chart/ReportTable.tsx @@ -5,9 +5,11 @@ import { useSelector } from "@/redux"; import { Checkbox } from "@/components/ui/checkbox"; import { getChartColor } from "@/utils/theme"; import { cn } from "@/utils/cn"; +import { useMappings } from "@/hooks/useMappings"; + type ReportTableProps = { - data: RouterOutputs["chartMeta"]["chart"]; + data: RouterOutputs["chart"]["chart"]; visibleSeries: string[]; setVisibleSeries: React.Dispatch>; }; @@ -19,6 +21,7 @@ export function ReportTable({ }: ReportTableProps) { const interval = useSelector((state) => state.report.interval); const formatDate = useFormatDateInterval(interval); + const getLabel = useMappings() function handleChange(name: string, checked: boolean) { setVisibleSeries((prev) => { @@ -34,7 +37,7 @@ export function ReportTable({ const cell = "p-2 last:pr-8 last:w-[8rem]"; const value = "min-w-[6rem] text-right"; const header = "text-sm font-medium"; - const total = 'bg-gray-50 text-emerald-600 font-bold border-r border-border' + const total = 'bg-gray-50 text-emerald-600 font-medium border-r border-border' return (
{/* Labels */} @@ -63,7 +66,7 @@ export function ReportTable({ checked={checked} />
- {serie.name} + {getLabel(serie.name)}
); diff --git a/apps/web/src/components/report/reportSlice.ts b/apps/web/src/components/report/reportSlice.ts index cd100eac..8e0438be 100644 --- a/apps/web/src/components/report/reportSlice.ts +++ b/apps/web/src/components/report/reportSlice.ts @@ -11,7 +11,7 @@ type InitialState = IChartInput; // First approach: define the initial state using that type const initialState: InitialState = { - name: "", + name: "screen_view", chartType: "linear", startDate: getDaysOldDate(7), endDate: new Date(), @@ -102,7 +102,18 @@ export const reportSlice = createSlice({ state.endDate = action.payload; }, - changeDateRanges: (state, action: PayloadAction) => { + changeDateRanges: (state, action: PayloadAction) => { + if(action.payload === 'today') { + state.startDate = new Date(); + state.endDate = new Date(); + state.startDate.setHours(0,0,0,0) + state.interval = 'hour' + return state + } + + state.startDate = getDaysOldDate(action.payload); + state.endDate = new Date(); + if (action.payload === 1) { state.interval = "hour"; } else if (action.payload <= 30) { @@ -110,8 +121,6 @@ export const reportSlice = createSlice({ } else { state.interval = "month"; } - state.startDate = getDaysOldDate(action.payload); - state.endDate = new Date(); }, }, }); diff --git a/apps/web/src/components/report/sidebar/ReportBreakdownMore.tsx b/apps/web/src/components/report/sidebar/ReportBreakdownMore.tsx index 324baaf1..3f65ce60 100644 --- a/apps/web/src/components/report/sidebar/ReportBreakdownMore.tsx +++ b/apps/web/src/components/report/sidebar/ReportBreakdownMore.tsx @@ -34,7 +34,7 @@ export function ReportBreakdownMore({ onClick }: ReportBreakdownMoreProps) { - + onClick('remove')}> diff --git a/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx b/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx index e03e7947..ecc80a82 100644 --- a/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx +++ b/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx @@ -5,14 +5,15 @@ import { addBreakdown, changeBreakdown, removeBreakdown } from "../reportSlice"; import { type ReportEventMoreProps } from "./ReportEventMore"; import { type IChartBreakdown } from "@/types"; import { ReportBreakdownMore } from "./ReportBreakdownMore"; +import { RenderDots } from "@/components/ui/RenderDots"; export function ReportBreakdowns() { const selectedBreakdowns = useSelector((state) => state.report.breakdowns); const dispatch = useDispatch(); - const propertiesQuery = api.chartMeta.properties.useQuery(); + const propertiesQuery = api.chart.properties.useQuery(); const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({ value: item, - label: item, + label: item, // {item}, })); const handleMore = (breakdown: IChartBreakdown) => { diff --git a/apps/web/src/components/report/sidebar/ReportEventFilters.tsx b/apps/web/src/components/report/sidebar/ReportEventFilters.tsx index b5c94edc..24d70e45 100644 --- a/apps/web/src/components/report/sidebar/ReportEventFilters.tsx +++ b/apps/web/src/components/report/sidebar/ReportEventFilters.tsx @@ -1,10 +1,10 @@ import { api } from "@/utils/api"; -import { type IChartEvent } from "@/types"; import { - CreditCard, - SlidersHorizontal, - Trash, -} from "lucide-react"; + type IChartEvent, + type IChartEventFilterValue, + type IChartEventFilter, +} from "@/types"; +import { CreditCard, SlidersHorizontal, Trash } from "lucide-react"; import { CommandDialog, CommandEmpty, @@ -18,8 +18,11 @@ import { type Dispatch } from "react"; import { RenderDots } from "@/components/ui/RenderDots"; import { useDispatch } from "@/redux"; import { changeEvent } from "../reportSlice"; -import { Combobox } from "@/components/ui/combobox"; import { Button } from "@/components/ui/button"; +import { ComboboxMulti } from "@/components/ui/combobox-multi"; +import { Dropdown } from "@/components/Dropdown"; +import { operators } from "@/utils/constants"; +import { useMappings } from "@/hooks/useMappings"; type ReportEventFiltersProps = { event: IChartEvent; @@ -33,7 +36,7 @@ export function ReportEventFilters({ setIsCreating, }: ReportEventFiltersProps) { const dispatch = useDispatch(); - const propertiesQuery = api.chartMeta.properties.useQuery( + const propertiesQuery = api.chart.properties.useQuery( { event: event.name, }, @@ -50,7 +53,7 @@ export function ReportEventFilters({ })} - + Such emptyness 🤨 @@ -67,7 +70,8 @@ export function ReportEventFilters({ { id: (event.filters.length + 1).toString(), name: item, - value: "", + operator: "is", + value: [], }, ], }), @@ -93,8 +97,9 @@ type FilterProps = { }; function Filter({ filter, event }: FilterProps) { + const getLabel = useMappings() const dispatch = useDispatch(); - const potentialValues = api.chartMeta.values.useQuery({ + const potentialValues = api.chart.values.useQuery({ event: event.name, property: filter.name, }); @@ -102,7 +107,7 @@ function Filter({ filter, event }: FilterProps) { const valuesCombobox = potentialValues.data?.values?.map((item) => ({ value: item, - label: item, + label: getLabel(item), })) ?? []; const removeFilter = () => { @@ -114,7 +119,9 @@ function Filter({ filter, event }: FilterProps) { ); }; - const changeFilter = (value: string) => { + const changeFilterValue = ( + value: IChartEventFilterValue | IChartEventFilterValue[], + ) => { dispatch( changeEvent({ ...event, @@ -122,7 +129,25 @@ function Filter({ filter, event }: FilterProps) { if (item.id === filter.id) { return { ...item, - value, + value: Array.isArray(value) ? value : [value], + }; + } + + return item; + }), + }), + ); + }; + + const changeFilterOperator = (operator: IChartEventFilter["operator"]) => { + dispatch( + changeEvent({ + ...event, + filters: event.filters.map((item) => { + if (item.id === filter.id) { + return { + ...item, + operator, }; } @@ -141,21 +166,54 @@ function Filter({ filter, event }: FilterProps) {
- {filter.name} +
+ {filter.name} +
- {/* { - return fn(filter.value) - // - }} /> */} - + ({ + value: key as IChartEventFilter["operator"], + label: value, + }))} + label="Segment" + > + + + ({ + value: item?.toString() ?? "__filter_value_null__", + label: getLabel(item?.toString() ?? "__filter_value_null__"), + }))} + setSelected={(setFn) => { + if(typeof setFn === "function") { + const newValues = setFn( + filter.value.map((item) => ({ + value: item?.toString() ?? "__filter_value_null__", + label: getLabel(item?.toString() ?? "__filter_value_null__"), + })), + ); + changeFilterValue(newValues.map((item) => item.value)); + } else { + changeFilterValue(setFn.map((item) => item.value)); + } + }} + /> +
+ {/* + /> */} {/* { diff --git a/apps/web/src/components/report/sidebar/ReportEvents.tsx b/apps/web/src/components/report/sidebar/ReportEvents.tsx index d0fdecf9..6b0b5a35 100644 --- a/apps/web/src/components/report/sidebar/ReportEvents.tsx +++ b/apps/web/src/components/report/sidebar/ReportEvents.tsx @@ -6,12 +6,14 @@ import { ReportEventFilters } from "./ReportEventFilters"; import { useState } from "react"; import { ReportEventMore, type ReportEventMoreProps } from "./ReportEventMore"; import { type IChartEvent } from "@/types"; +import { Filter, GanttChart, Users } from "lucide-react"; +import { Dropdown } from "@/components/Dropdown"; export function ReportEvents() { const [isCreating, setIsCreating] = useState(false); const selectedEvents = useSelector((state) => state.report.events); const dispatch = useDispatch(); - const eventsQuery = api.chartMeta.events.useQuery(); + const eventsQuery = api.chart.events.useQuery(); const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({ value: item.name, label: item.name, @@ -38,9 +40,11 @@ export function ReportEvents() {
{selectedEvents.map((event) => { return ( -
-
-
{event.id}
+
+
+
+ {event.id} +
{ @@ -57,10 +61,50 @@ export function ReportEvents() { />
- + {/* Segment and Filter buttons */} +
+ { + dispatch( + changeEvent({ + ...event, + segment, + }), + ); + }} + items={[ + { + value: "event", + label: "All events", + }, + { + value: "user", + label: "Unique users", + }, + ]} + label="Segment" + > + + + +
+ + {/* Filters */} +
); })} @@ -71,6 +115,7 @@ export function ReportEvents() { dispatch( addEvent({ name: value, + segment: "event", filters: [], }), ); diff --git a/apps/web/src/components/ui/RenderDots.tsx b/apps/web/src/components/ui/RenderDots.tsx index dfa8577c..b4db9468 100644 --- a/apps/web/src/components/ui/RenderDots.tsx +++ b/apps/web/src/components/ui/RenderDots.tsx @@ -1,21 +1,51 @@ import { cn } from "@/utils/cn"; -import { ChevronRight } from "lucide-react"; +import { Asterisk, ChevronRight } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"; interface RenderDotsProps extends React.HTMLAttributes { children: string; + truncate?: boolean; } -export function RenderDots({ children, className, ...props }: RenderDotsProps) { +export function RenderDots({ + children, + className, + truncate, + ...props +}: RenderDotsProps) { + const parts = children.split("."); + const sliceAt = truncate && parts.length > 3 ? 3 : 0; return ( -
- {children.split(".").map((str, index) => { - return ( -
- {index !== 0 && } - {str} -
- ); - })} -
+ + +
+ {parts.slice(-sliceAt).map((str, index) => { + return ( +
+ {index !== 0 && ( + + )} + {str.includes("[*]") ? ( + <> + {str.replace("[*]", "")} + + + ) : str === "*" ? ( + + ) : ( + str + )} +
+ ); + })} +
+
+ +

{children}

+
+
); } diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..b6ec12ef --- /dev/null +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/utils/cn" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 9c2f5082..39932cc0 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -52,5 +52,8 @@ const Button = React.forwardRef( } ) Button.displayName = "Button" +Button.defaultProps = { + type: 'button' +} export { Button, buttonVariants } diff --git a/apps/web/src/components/ui/combobox-multi.tsx b/apps/web/src/components/ui/combobox-multi.tsx index 0d4fdf37..0f1ec6ae 100644 --- a/apps/web/src/components/ui/combobox-multi.tsx +++ b/apps/web/src/components/ui/combobox-multi.tsx @@ -9,21 +9,22 @@ import { } from "@/components/ui/command"; import { Command as CommandPrimitive } from "cmdk"; -type Framework = Record<"value" | "label", string>; +type Item = Record<"value" | "label", string>; type ComboboxMultiProps = { - selected: Framework[]; - setSelected: React.Dispatch>; - items: Framework[]; + selected: Item[]; + setSelected: React.Dispatch>; + items: Item[]; + placeholder: string } -export function ComboboxMulti({ items, selected, setSelected, ...props }: ComboboxMultiProps) { +export function ComboboxMulti({ items, selected, setSelected, placeholder, ...props }: ComboboxMultiProps) { const inputRef = React.useRef(null); const [open, setOpen] = React.useState(false); const [inputValue, setInputValue] = React.useState(""); - const handleUnselect = React.useCallback((framework: Framework) => { - setSelected(prev => prev.filter(s => s.value !== framework.value)); + const handleUnselect = React.useCallback((item: Item) => { + setSelected(prev => prev.filter(s => s.value !== item.value)); }, []); const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { @@ -45,30 +46,30 @@ export function ComboboxMulti({ items, selected, setSelected, ...props }: Combob } }, []); - const selectables = items.filter(framework => !selected.includes(framework)); - + const selectables = items.filter(item => !selected.find(s => s.value === item.value)); + return (
- {selected.map((framework) => { + {selected.map((item) => { return ( - - {framework.label} + + {item.label} @@ -82,7 +83,7 @@ export function ComboboxMulti({ items, selected, setSelected, ...props }: Combob onValueChange={setInputValue} onBlur={() => setOpen(false)} onFocus={() => setOpen(true)} - placeholder="Select frameworks..." + placeholder={placeholder} className="ml-2 bg-transparent outline-none placeholder:text-muted-foreground flex-1" />
@@ -91,21 +92,21 @@ export function ComboboxMulti({ items, selected, setSelected, ...props }: Combob {open && selectables.length > 0 ?
- {selectables.map((framework) => { + {selectables.map((item) => { return ( { e.preventDefault(); e.stopPropagation(); }} onSelect={(value) => { setInputValue("") - setSelected(prev => [...prev, framework]) + setSelected(prev => [...prev, item]) }} className={"cursor-pointer"} > - {framework.label} + {item.label} ); })} diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index f16ff41c..9f09540f 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -24,6 +24,7 @@ type ComboboxProps = { }>; value: string; onChange: (value: string) => void; + children?: React.ReactNode }; export function Combobox({ @@ -31,6 +32,7 @@ export function Combobox({ items, value, onChange, + children }: ComboboxProps) { const [open, setOpen] = React.useState(false); @@ -43,17 +45,17 @@ export function Combobox({ return ( - + } - + Nothing selected @@ -61,8 +63,9 @@ export function Combobox({ {items.map((item) => ( { - const value = find(currentValue)?.value ?? ""; + const value = find(currentValue)?.value ?? currentValue; onChange(value); setOpen(false); }} diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx new file mode 100644 index 00000000..d69b724e --- /dev/null +++ b/apps/web/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/utils/cn" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 block mb-2" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/apps/web/src/components/ui/radio-group.tsx b/apps/web/src/components/ui/radio-group.tsx index 585853ae..3f7b7ead 100644 --- a/apps/web/src/components/ui/radio-group.tsx +++ b/apps/web/src/components/ui/radio-group.tsx @@ -10,7 +10,7 @@ const RadioGroup = React.forwardRef( return (
( const RadioGroupItem = React.forwardRef(({className, ...props}, ref) => { return ( -