diff --git a/apps/platform/trpc/routers/orgRouter/users/invitesRouter.ts b/apps/platform/trpc/routers/orgRouter/users/invitesRouter.ts index 07408bc7..49f163d6 100644 --- a/apps/platform/trpc/routers/orgRouter/users/invitesRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/users/invitesRouter.ts @@ -99,7 +99,7 @@ export const invitesRouter = router({ // Insert teamMemberships - save ID if (teamsInput) { for (const teamPublicId of teamsInput.teamsPublicIds) { - await addOrgMemberToTeamHandler({ + await addOrgMemberToTeamHandler(db, { orgId: org.id, teamPublicId: teamPublicId, orgMemberPublicId: orgMemberPublicId, diff --git a/apps/platform/trpc/routers/orgRouter/users/teamsHandler.ts b/apps/platform/trpc/routers/orgRouter/users/teamsHandler.ts index e677f7bb..8f731ea2 100644 --- a/apps/platform/trpc/routers/orgRouter/users/teamsHandler.ts +++ b/apps/platform/trpc/routers/orgRouter/users/teamsHandler.ts @@ -1,5 +1,5 @@ import { TRPCError } from '@trpc/server'; -import { db } from '@u22n/database'; +import type { DBType } from '@u22n/database'; import { and, eq } from '@u22n/database/orm'; import { convoParticipantTeamMembers, @@ -10,17 +10,20 @@ import { } from '@u22n/database/schema'; import { typeIdGenerator, type TypeId } from '@u22n/utils/typeid'; -export async function addOrgMemberToTeamHandler({ - orgId, - orgMemberId, - orgMemberPublicId, - teamPublicId -}: { - orgId: number; - orgMemberId: number; - orgMemberPublicId: TypeId<'orgMembers'>; - teamPublicId: TypeId<'teams'>; -}) { +export async function addOrgMemberToTeamHandler( + db: DBType, + { + orgId, + orgMemberId, + orgMemberPublicId, + teamPublicId + }: { + orgId: number; + orgMemberId: number; + orgMemberPublicId: TypeId<'orgMembers'>; + teamPublicId: TypeId<'teams'>; + } +) { const orgMember = await db.query.orgMembers.findFirst({ columns: { id: true, diff --git a/apps/platform/trpc/routers/orgRouter/users/teamsRouter.ts b/apps/platform/trpc/routers/orgRouter/users/teamsRouter.ts index 0af744fb..dec738a4 100644 --- a/apps/platform/trpc/routers/orgRouter/users/teamsRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/users/teamsRouter.ts @@ -178,7 +178,7 @@ export const teamsRouter = router({ message: 'Account or Organization is not defined' }); } - const { org } = ctx; + const { org, db } = ctx; const { teamPublicId, orgMemberPublicId } = input; const isAdmin = await isAccountAdminOfOrg(org); @@ -189,7 +189,7 @@ export const teamsRouter = router({ }); } - const newTeamMemberPublicId = await addOrgMemberToTeamHandler({ + const newTeamMemberPublicId = await addOrgMemberToTeamHandler(db, { orgId: org.id, teamPublicId: teamPublicId, orgMemberPublicId: orgMemberPublicId, diff --git a/apps/web/package.json b/apps/web/package.json index 57779b0c..3978ad3f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,10 +13,17 @@ "@calcom/embed-react": "^1.5.0", "@phosphor-icons/react": "^2.1.5", "@radix-ui/react-avatar": "^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-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle-group": "^1.0.4", + "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/themes": "^3.0.2", "@simplewebauthn/browser": "^10.0.0", diff --git a/apps/web/src/app/[orgShortCode]/settings/layout.tsx b/apps/web/src/app/[orgShortCode]/settings/layout.tsx index e3e879ff..5d78c3a6 100644 --- a/apps/web/src/app/[orgShortCode]/settings/layout.tsx +++ b/apps/web/src/app/[orgShortCode]/settings/layout.tsx @@ -1,5 +1,5 @@ 'use client'; -import { Flex } from '@radix-ui/themes'; +import { Flex, ScrollArea } from '@radix-ui/themes'; import SettingsSidebar from './_components/settings-sidebar'; export default function Layout({ @@ -8,7 +8,9 @@ export default function Layout({ return ( - {children} + + {children} + ); } diff --git a/apps/web/src/app/[orgShortCode]/settings/org/users/invites/_components/columns.tsx b/apps/web/src/app/[orgShortCode]/settings/org/users/invites/_components/columns.tsx new file mode 100644 index 00000000..48b80f4c --- /dev/null +++ b/apps/web/src/app/[orgShortCode]/settings/org/users/invites/_components/columns.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { createColumnHelper, type ColumnDef } from '@tanstack/react-table'; +import type { RouterOutputs } from '@/src/lib/trpc'; +import { generateAvatarUrl, getInitials } from '@/src/lib/utils'; +import { + Avatar, + AvatarFallback, + AvatarImage +} from '@/src/components/shadcn-ui/avatar'; +import { Badge } from '@/src/components/shadcn-ui/badge'; +import { format } from 'date-fns'; +import { ScrollArea } from '@radix-ui/themes'; +import { Tooltip } from '@radix-ui/themes'; +import CopyButton from '@/src/components/copy-button'; +import { env } from 'next-runtime-env'; + +const WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL'); + +type Member = + RouterOutputs['org']['users']['invites']['viewInvites']['invites'][number]; + +const columnHelper = createColumnHelper(); + +export const columns: ColumnDef[] = [ + columnHelper.display({ + id: 'status', + header: 'Status', + cell: ({ row }) => ( + + + {row.original.acceptedAt ? 'Used' : 'Pending'} + + + ) + }), + columnHelper.display({ + id: 'user', + header: 'User', + cell: ({ row }) => { + const { publicId, avatarTimestamp, firstName, lastName } = + row.original.orgMember?.profile ?? {}; + + const avatarUrl = + avatarTimestamp && publicId + ? generateAvatarUrl({ + avatarTimestamp, + publicId, + size: 'lg' + }) + : null; + const initials = getInitials(`${firstName} ${lastName}`); + return ( + + + + {initials} + + + {firstName} {lastName} + + + ); + } + }), + columnHelper.display({ + id: 'invite-code', + header: 'Invite Code', + cell: ({ row }) => { + const inviteCode = row.original.inviteToken; + return inviteCode ? ( + + + {inviteCode} + + + + ) : null; + } + }), + columnHelper.display({ + id: 'invite-link', + header: 'Invite Link', + cell: ({ row }) => { + const inviteCode = row.original.inviteToken; + return inviteCode ? ( + + + {`${WEBAPP_URL}/join/invite/${inviteCode}`} + + + + ) : null; + } + }), + columnHelper.display({ + id: 'email', + header: 'Email', + cell: ({ row }) => { + const email = row.original.email; + return {email}; + } + }), + columnHelper.display({ + id: 'role', + header: 'Role', + cell: ({ row }) => { + const role = row.original.role; + return ( + + {role} + + ); + } + }), + columnHelper.display({ + id: 'admin', + header: 'Admin', + cell: ({ row }) => { + const { publicId, avatarTimestamp, firstName, lastName } = + row.original.invitedByOrgMember.profile; + + const avatarUrl = + avatarTimestamp && publicId + ? generateAvatarUrl({ + avatarTimestamp, + publicId, + size: 'lg' + }) + : null; + const initials = getInitials(`${firstName} ${lastName}`); + return ( + + + + + {initials} + + + + ); + } + }), + columnHelper.display({ + id: 'expiry', + header: 'Expiry', + cell: ({ row }) => { + const expiry = row.original.expiresAt; + return expiry ? ( + + {format(expiry, 'eee, do MMM yyyy')} + + ) : null; + } + }) +]; diff --git a/apps/web/src/app/[orgShortCode]/settings/org/users/invites/_components/invite-modal.tsx b/apps/web/src/app/[orgShortCode]/settings/org/users/invites/_components/invite-modal.tsx new file mode 100644 index 00000000..bf293141 --- /dev/null +++ b/apps/web/src/app/[orgShortCode]/settings/org/users/invites/_components/invite-modal.tsx @@ -0,0 +1,492 @@ +'use client'; + +import { api } from '@/src/lib/trpc'; +import { useGlobalStore } from '@/src/providers/global-store-provider'; +import { useForm } from '@tanstack/react-form'; +import { Input } from '@/src/components/shadcn-ui/input'; +import { Switch } from '@/src/components/shadcn-ui/switch'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue +} from '@/src/components/shadcn-ui/select'; +import { MultiSelect } from '@/src/components/shared/multiselect'; +import { Button } from '@/src/components/shadcn-ui/button'; +import { z } from 'zod'; +import { zodValidator } from '@tanstack/zod-form-adapter'; +import { type TypeId } from '@u22n/utils/typeid'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTrigger, + DialogTitle, + DialogDescription, + DialogClose +} from '@/src/components/shadcn-ui/dialog'; +import { useState } from 'react'; + +export function InviteModal() { + const orgShortCode = useGlobalStore((state) => state.currentOrg.shortCode); + const invalidateInvites = api.useUtils().org.users.invites.viewInvites; + + const { mutateAsync: createInvite, error: inviteError } = + api.org.users.invites.createNewInvite.useMutation({ + onSuccess: () => { + void invalidateInvites.invalidate(); + setOpen(false); + } + }); + + const form = useForm({ + defaultValues: { + firstName: '', + lastName: '', + role: 'member' as 'member' | 'admin', + title: '', + invite: { + sendInvite: false, + email: '' + }, + email: { + create: false, + address: '', + domain: '' as TypeId<'domains'>, + sendName: '' + }, + team: { + addToTeams: false, + teams: [] as TypeId<'teams'>[] + } + }, + validatorAdapter: zodValidator, + onSubmit: async ({ value }) => { + await createInvite({ + orgShortCode, + newOrgMember: { + firstName: value.firstName, + lastName: value.lastName.length ? value.lastName : undefined, + role: value.role, + title: value.title.length ? value.title : undefined + }, + email: value.email.create + ? { + emailUsername: value.email.address, + domainPublicId: value.email.domain, + sendName: value.email.sendName + } + : undefined, + notification: value.invite.sendInvite + ? { notificationEmailAddress: value.invite.email } + : undefined, + teams: value.team.addToTeams + ? { teamsPublicIds: value.team.teams } + : undefined + }); + } + }); + + const { data: orgDomains, isLoading: orgDomainsLoading } = + api.org.mail.domains.getOrgDomains.useQuery({ + orgShortCode + }); + + const { data: orgTeams, isLoading: orgTeamsLoading } = + api.org.users.teams.getOrgTeams.useQuery({ orgShortCode }); + + const [open, setOpen] = useState(false); + + return ( + { + if (form.state.isSubmitting) return; + setOpen(!open); + }}> + + setOpen(true)}>New Invite + + + + + Create New Invite + + Create a new Invite for your Org + + + { + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }}> + + + + First Name + + ( + <> + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + {field.state.meta.errorMap.onBlur && ( + + {field.state.meta.errorMap.onBlur} + + )} + > + )} + /> + + + + Last Name (Optional) + + ( + <> + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + {field.state.meta.errorMap.onBlur && ( + + {field.state.meta.errorMap.onBlur} + + )} + > + )} + /> + + + + + Role + ( + + field.handleChange(e) + }> + + + + + + Admin + Member + + + + )} + /> + + + + Title (Optional) + + ( + <> + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + {field.state.meta.errorMap.onBlur && ( + + {field.state.meta.errorMap.onBlur} + + )} + > + )} + /> + + + + + Send Invitation via Email + ( + + )} + /> + + form.values.invite.sendInvite} + children={(sendInvite) => + sendInvite && ( + ( + <> + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + placeholder="somebody@email.com" + /> + {field.state.meta.errorMap.onBlur && ( + + {field.state.meta.errorMap.onBlur} + + )} + > + )} + /> + ) + } + /> + + + + + Create an Address for the User + + ( + + )} + /> + + form.values.email.create} + children={(createEmail) => + createEmail && ( + <> + {orgDomainsLoading && Loading...} + {orgDomains && ( + <> + + ( + + field.handleChange(e.target.value) + } + onBlur={field.handleBlur} + placeholder="username" + /> + )} + /> + @ + ( + ) => + field.handleChange(e) + }> + + + + + + {orgDomains.domainData.map((domain) => ( + + {domain.domain} + + ))} + + + + )} + /> + + ( + <> + + field.handleChange(e.target.value) + } + onBlur={field.handleBlur} + placeholder="Send Name" + /> + {field.state.meta.errorMap.onBlur && ( + + {field.state.meta.errorMap.onBlur} + + )} + > + )} + /> + > + )} + > + ) + } + /> + + + + Add User to Teams + ( + + )} + /> + + form.values.team.addToTeams} + children={(createEmail) => + createEmail && ( + <> + {orgTeamsLoading && Loading...} + {orgTeams && ( + ( + + field.handleChange(values as TypeId<'teams'>[]) + } + items={orgTeams.teams.map((item) => ({ + ...item, + value: item.publicId, + keywords: [ + item.name, + item.description ?? '', + item.color ?? '' + ] + }))} + ItemRenderer={(item) => ( + + + {item.name} + + )} + TriggerRenderer={({ items }) => ( + + {items.map((item) => ( + + + {item.name} + + ))} + + )} + emptyPlaceholder="Select teams" + /> + )} + /> + )} + > + ) + } + /> + {inviteError?.message} + [ + form.isTouched, + form.canSubmit, + form.isSubmitting + ]} + children={([isTouched, canSubmit, isSubmitting]) => ( + + {isSubmitting ? 'Creating...' : 'Create New Invite'} + + )} + /> + + form.isSubmitting} + children={(isSubmitting) => ( + setOpen(false)}> + Cancel + + )} + /> + + + + + + ); +} diff --git a/apps/web/src/app/[orgShortCode]/settings/org/users/invites/page.tsx b/apps/web/src/app/[orgShortCode]/settings/org/users/invites/page.tsx new file mode 100644 index 00000000..793ce8a7 --- /dev/null +++ b/apps/web/src/app/[orgShortCode]/settings/org/users/invites/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { DataTable } from '@/src/components/shared/table'; +import { api } from '@/src/lib/trpc'; +import { useGlobalStore } from '@/src/providers/global-store-provider'; +import { columns } from './_components/columns'; +import { InviteModal } from './_components/invite-modal'; + +export default function Page() { + const orgShortCode = useGlobalStore((state) => state.currentOrg.shortCode); + const { data: inviteList, isLoading } = + api.org.users.invites.viewInvites.useQuery({ + orgShortCode + }); + + return ( + + + + Invites + Manage Your Org Invitation + + + + {isLoading && Loading...} + {inviteList && ( + + )} + + ); +} diff --git a/apps/web/src/app/[orgShortCode]/settings/org/users/members/_components/columns.tsx b/apps/web/src/app/[orgShortCode]/settings/org/users/members/_components/columns.tsx new file mode 100644 index 00000000..d3d2b73b --- /dev/null +++ b/apps/web/src/app/[orgShortCode]/settings/org/users/members/_components/columns.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { createColumnHelper, type ColumnDef } from '@tanstack/react-table'; +import type { RouterOutputs } from '@/src/lib/trpc'; +import { generateAvatarUrl, getInitials } from '@/src/lib/utils'; +import { + Avatar, + AvatarFallback, + AvatarImage +} from '@/src/components/shadcn-ui/avatar'; +import { Badge } from '@/src/components/shadcn-ui/badge'; +import { format } from 'date-fns'; + +type Member = + // eslint-disable-next-line @typescript-eslint/ban-types + (RouterOutputs['org']['users']['members']['getOrgMembers']['members'] & {})[number]; + +const columnHelper = createColumnHelper(); + +export const columns: ColumnDef[] = [ + columnHelper.display({ + id: 'name', + header: 'Name', + cell: ({ row }) => { + const { publicId, avatarTimestamp, firstName, lastName } = + row.original.profile; + + const avatarUrl = generateAvatarUrl({ + avatarTimestamp, + publicId, + size: 'lg' + }); + const initials = getInitials(`${firstName} ${lastName}`); + + return ( + + + + {initials} + + + {firstName} {lastName} + + + ); + } + }), + columnHelper.display({ + id: 'username', + header: 'Username', + cell: ({ row }) => { + const username = row.original.profile.handle; + return @{username}; + } + }), + columnHelper.display({ + id: 'title', + header: 'Title', + cell: ({ row }) => { + const title = row.original.profile.title; + return {title}; + } + }), + columnHelper.display({ + id: 'role', + header: 'Role', + cell: ({ row }) => { + const role = row.original.role; + return ( + + {role} + + ); + } + }), + columnHelper.display({ + id: 'status', + header: 'Status', + cell: ({ row }) => { + const status = row.original.status; + return ( + + {status} + + ); + } + }), + columnHelper.display({ + id: 'joined', + header: 'Joined', + cell: ({ row }) => { + const joined = row.original.addedAt; + return ( + + {format(joined, 'eee, do MMM yyyy')} + + ); + } + }) +]; diff --git a/apps/web/src/app/[orgShortCode]/settings/org/users/members/page.tsx b/apps/web/src/app/[orgShortCode]/settings/org/users/members/page.tsx new file mode 100644 index 00000000..cd60e88d --- /dev/null +++ b/apps/web/src/app/[orgShortCode]/settings/org/users/members/page.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { DataTable } from '@/src/components/shared/table'; +import { api } from '@/src/lib/trpc'; +import { useGlobalStore } from '@/src/providers/global-store-provider'; +import { columns } from './_components/columns'; +import { Button } from '@/src/components/shadcn-ui/button'; +import Link from 'next/link'; + +export default function Page() { + const orgShortCode = useGlobalStore((state) => state.currentOrg.shortCode); + const { data: memberList, isLoading } = + api.org.users.members.getOrgMembers.useQuery({ + orgShortCode + }); + + return ( + + + + Members + Manage Your Org Members + + + + Invite a Member + + + + {isLoading && Loading...} + {memberList && ( + + )} + + ); +} diff --git a/apps/web/src/app/[orgShortCode]/settings/user/addresses/page.tsx b/apps/web/src/app/[orgShortCode]/settings/user/addresses/page.tsx index 81061018..f87ec34a 100644 --- a/apps/web/src/app/[orgShortCode]/settings/user/addresses/page.tsx +++ b/apps/web/src/app/[orgShortCode]/settings/user/addresses/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { Button, Flex, Heading, Text, Card, Spinner } from '@radix-ui/themes'; -import { DataTable } from './_components/table'; +import { DataTable } from '@/src/components/shared/table'; import { useGlobalStore } from '@/src/providers/global-store-provider'; import { api } from '@/src/lib/trpc'; import { columns } from './_components/columns'; diff --git a/apps/web/src/components/shadcn-ui/command.tsx b/apps/web/src/components/shadcn-ui/command.tsx new file mode 100644 index 00000000..9e6b39f6 --- /dev/null +++ b/apps/web/src/components/shadcn-ui/command.tsx @@ -0,0 +1,154 @@ +import * as React from 'react'; +import { type DialogProps } from '@radix-ui/react-dialog'; +import { Command as CommandPrimitive } from 'cmdk'; +import { MagnifyingGlass } from '@phosphor-icons/react'; +import { cn } from '@/src/lib/utils'; +import { Dialog, DialogContent } from '@/src/components/shadcn-ui/dialog'; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +type CommandDialogProps = DialogProps; + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = 'CommandShortcut'; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator +}; diff --git a/apps/web/src/components/shadcn-ui/dialog.tsx b/apps/web/src/components/shadcn-ui/dialog.tsx new file mode 100644 index 00000000..34894c3b --- /dev/null +++ b/apps/web/src/components/shadcn-ui/dialog.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from '@phosphor-icons/react'; + +import { cn } from '@/src/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( + +); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( + +); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription +}; diff --git a/apps/web/src/components/shadcn-ui/input.tsx b/apps/web/src/components/shadcn-ui/input.tsx new file mode 100644 index 00000000..fe914e6c --- /dev/null +++ b/apps/web/src/components/shadcn-ui/input.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; + +import { cn } from '@/src/lib/utils'; + +export type InputProps = React.InputHTMLAttributes; + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/apps/web/src/components/shadcn-ui/label.tsx b/apps/web/src/components/shadcn-ui/label.tsx new file mode 100644 index 00000000..63c7db9e --- /dev/null +++ b/apps/web/src/components/shadcn-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 '@/src/lib/utils'; + +const labelVariants = cva( + 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' +); + +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/shadcn-ui/popover.tsx b/apps/web/src/components/shadcn-ui/popover.tsx new file mode 100644 index 00000000..e7afad3b --- /dev/null +++ b/apps/web/src/components/shadcn-ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; + +import { cn } from '@/src/lib/utils'; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/apps/web/src/components/shadcn-ui/scroll-area.tsx b/apps/web/src/components/shadcn-ui/scroll-area.tsx new file mode 100644 index 00000000..6911ffaf --- /dev/null +++ b/apps/web/src/components/shadcn-ui/scroll-area.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; + +import { cn } from '@/src/lib/utils'; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/apps/web/src/components/shadcn-ui/select.tsx b/apps/web/src/components/shadcn-ui/select.tsx new file mode 100644 index 00000000..e6b0494f --- /dev/null +++ b/apps/web/src/components/shadcn-ui/select.tsx @@ -0,0 +1,152 @@ +import * as React from 'react'; +import * as SelectPrimitive from '@radix-ui/react-select'; +import { Check, CaretDown, CaretUp } from '@phosphor-icons/react'; + +import { cn } from '@/src/lib/utils'; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className + )} + {...props}> + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton +}; diff --git a/apps/web/src/components/shadcn-ui/separator.tsx b/apps/web/src/components/shadcn-ui/separator.tsx new file mode 100644 index 00000000..6bcaaabb --- /dev/null +++ b/apps/web/src/components/shadcn-ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import * as SeparatorPrimitive from '@radix-ui/react-separator'; + +import { cn } from '@/src/lib/utils'; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = 'horizontal', decorative = true, ...props }, + ref + ) => ( + + ) +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/apps/web/src/components/shadcn-ui/switch.tsx b/apps/web/src/components/shadcn-ui/switch.tsx new file mode 100644 index 00000000..a3511ef0 --- /dev/null +++ b/apps/web/src/components/shadcn-ui/switch.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import * as SwitchPrimitives from '@radix-ui/react-switch'; + +import { cn } from '@/src/lib/utils'; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/apps/web/src/components/shadcn-ui/table.tsx b/apps/web/src/components/shadcn-ui/table.tsx new file mode 100644 index 00000000..5dc250a1 --- /dev/null +++ b/apps/web/src/components/shadcn-ui/table.tsx @@ -0,0 +1,121 @@ +import * as React from 'react'; + +import { cn } from '@/src/lib/utils'; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + + + +)); +Table.displayName = 'Table'; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = 'TableHeader'; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = 'TableBody'; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0', + className + )} + {...props} + /> +)); +TableFooter.displayName = 'TableFooter'; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = 'TableRow'; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHead.displayName = 'TableHead'; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = 'TableCell'; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCaption.displayName = 'TableCaption'; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption +}; diff --git a/apps/web/src/components/shared/multiselect.tsx b/apps/web/src/components/shared/multiselect.tsx new file mode 100644 index 00000000..8dfd7ab4 --- /dev/null +++ b/apps/web/src/components/shared/multiselect.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { + type FC, + useState, + type ReactNode, + type Dispatch, + type SetStateAction +} from 'react'; +import { Check, CaretUpDown } from '@phosphor-icons/react'; +import { cn } from '@/src/lib/utils'; +import { Button } from '../shadcn-ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from '../shadcn-ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '../shadcn-ui/popover'; + +type Item> = { + value: string; + keywords?: string[]; +} & T; + +type MultiSelectProps> = { + items: Item[]; + emptyPlaceholder?: ReactNode; + searchPlaceholder?: string; + noResultsPlaceholder?: ReactNode; + ItemRenderer: FC>; + TriggerRenderer: FC<{ items: Item[] }>; + values: string[]; + setValues: Dispatch>; +}; + +export function MultiSelect>({ + items, + emptyPlaceholder, + searchPlaceholder, + noResultsPlaceholder, + ItemRenderer, + TriggerRenderer, + values, + setValues +}: MultiSelectProps) { + const [open, setOpen] = useState(false); + + return ( + + + + {values.length > 0 ? ( + values.includes(item.value))} + /> + ) : ( + emptyPlaceholder ?? 'Select an Item' + )} + + + + + { + const extendedValue = value + ' ' + keywords?.join(' ') ?? ''; + return extendedValue.toLowerCase().includes(search.toLowerCase()) + ? 1 + : 0; + }}> + + + + {noResultsPlaceholder ?? 'Nothing Found'} + + + {items.map((item) => ( + { + setValues((prevValues) => + prevValues.includes(currentValue) + ? prevValues.filter((value) => value !== currentValue) + : prevValues.concat(currentValue) + ); + }}> + + + + ))} + + + + + + ); +} diff --git a/apps/web/src/app/[orgShortCode]/settings/user/addresses/_components/table.tsx b/apps/web/src/components/shared/table.tsx similarity index 71% rename from apps/web/src/app/[orgShortCode]/settings/user/addresses/_components/table.tsx rename to apps/web/src/components/shared/table.tsx index adc49dda..27e370e2 100644 --- a/apps/web/src/app/[orgShortCode]/settings/user/addresses/_components/table.tsx +++ b/apps/web/src/components/shared/table.tsx @@ -7,7 +7,14 @@ import { useReactTable } from '@tanstack/react-table'; -import { Table } from '@radix-ui/themes'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '../shadcn-ui/table'; interface DataTableProps { columns: ColumnDef[]; @@ -25,38 +32,38 @@ export function DataTable({ }); return ( - - + + {table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => { return ( - + {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} - + ); })} - + ))} - - + + {table.getRowModel().rows.map((row) => ( - {row.getVisibleCells().map((cell) => ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} - + ))} - + ))} - - + + ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f09a31e7..abf86486 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,12 +269,33 @@ importers: '@radix-ui/react-avatar': specifier: ^1.0.4 version: 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': + specifier: ^1.0.5 + version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dropdown-menu': specifier: ^2.0.6 version: 2.0.6(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-label': + specifier: ^2.0.2 + version: 2.0.2(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popover': + specifier: ^1.0.7 + version: 1.0.7(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-scroll-area': + specifier: ^1.0.5 + version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-select': + specifier: ^2.0.0 + version: 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-separator': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': specifier: ^1.0.2 version: 1.0.2(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-switch': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-toggle': specifier: ^1.0.3 version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3055,6 +3076,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-separator@1.0.3': + resolution: {integrity: sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slider@1.1.2': resolution: {integrity: sha512-NKs15MJylfzVsCagVSWKhGGLNR1W9qWs+HtgbmjjVUB3B9+lb3PYoXxVju3kOrpf0VKyVCtZp+iTwVoqpa1Chw==} peerDependencies: @@ -11579,6 +11613,16 @@ snapshots: '@types/react': 18.3.1 '@types/react-dom': 18.3.0 + '@radix-ui/react-separator@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.5 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.1 + '@types/react-dom': 18.3.0 + '@radix-ui/react-slider@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.24.5