{totalMemberCount === 0
diff --git a/src/features/dashboard/members/members-page-content.tsx b/src/features/dashboard/members/members-page-content.tsx
index b43e7e772..16f24bb03 100644
--- a/src/features/dashboard/members/members-page-content.tsx
+++ b/src/features/dashboard/members/members-page-content.tsx
@@ -6,16 +6,19 @@ import type { TeamMember } from '@/core/modules/teams/models'
import { cn } from '@/lib/utils'
import { pluralize } from '@/lib/utils/formatting'
import { Input } from '@/ui/primitives/input'
+import { Skeleton } from '@/ui/primitives/skeleton'
import { AddMemberDialog } from './add-member-dialog'
import MemberTable from './member-table'
interface MembersPageContentProps {
members: TeamMember[]
+ isLoading?: boolean
className?: string
}
-const MembersPageContent = ({
+export const MembersPageContent = ({
members,
+ isLoading = false,
className,
}: MembersPageContentProps) => {
const [query, setQuery] = useState('')
@@ -55,12 +58,17 @@ const MembersPageContent = ({
All members have the same roles & permissions
-
{totalLabel}
+ {isLoading ? (
+
+ ) : (
+
{totalLabel}
+ )}
@@ -68,5 +76,3 @@ const MembersPageContent = ({
)
}
-
-export default MembersPageContent
diff --git a/src/features/dashboard/settings/general/info-card.tsx b/src/features/dashboard/settings/general/info-card.tsx
deleted file mode 100644
index aaab3dd2a..000000000
--- a/src/features/dashboard/settings/general/info-card.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-'use client'
-
-import { useDashboard } from '@/features/dashboard/context'
-import CopyButton from '@/ui/copy-button'
-import { Badge } from '@/ui/primitives/badge'
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/ui/primitives/card'
-
-interface InfoCardProps {
- className?: string
-}
-
-export function InfoCard({ className }: InfoCardProps) {
- const { team } = useDashboard()
-
- return (
-
-
- Information
-
- Additional information about this team.
-
-
-
-
-
- E-Mail
- {team.email}
-
-
-
- Team ID
- {team.id}
-
-
-
- Team Slug
- {team.slug}
-
-
-
-
-
- )
-}
diff --git a/src/features/dashboard/settings/general/name-card.tsx b/src/features/dashboard/settings/general/name-card.tsx
deleted file mode 100644
index b9d629b78..000000000
--- a/src/features/dashboard/settings/general/name-card.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-'use client'
-
-import { zodResolver } from '@hookform/resolvers/zod'
-import { useHookFormOptimisticAction } from '@next-safe-action/adapter-react-hook-form/hooks'
-import { useQueryClient } from '@tanstack/react-query'
-import { AnimatePresence, motion } from 'motion/react'
-import { USER_MESSAGES } from '@/configs/user-messages'
-import { getTransformedDefaultTeamName } from '@/core/modules/teams/utils'
-import { updateTeamNameAction } from '@/core/server/actions/team-actions'
-import { UpdateTeamNameSchema } from '@/core/server/functions/team/types'
-import { useDashboard } from '@/features/dashboard/context'
-import {
- defaultErrorToast,
- defaultSuccessToast,
- useToast,
-} from '@/lib/hooks/use-toast'
-import { exponentialSmoothing } from '@/lib/utils'
-import { useTRPC } from '@/trpc/client'
-import { Button } from '@/ui/primitives/button'
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/ui/primitives/card'
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormMessage,
-} from '@/ui/primitives/form'
-import { Input } from '@/ui/primitives/input'
-
-interface NameCardProps {
- className?: string
-}
-
-export function NameCard({ className }: NameCardProps) {
- 'use no memo'
-
- const { team } = useDashboard()
- const trpc = useTRPC()
- const queryClient = useQueryClient()
-
- const { toast } = useToast()
-
- const {
- form,
- handleSubmitWithAction,
- action: { isExecuting, optimisticState },
- } = useHookFormOptimisticAction(
- updateTeamNameAction,
- zodResolver(UpdateTeamNameSchema),
- {
- formProps: {
- defaultValues: {
- teamSlug: team.slug,
- name: team.name,
- },
- },
- actionProps: {
- currentState: {
- team,
- },
- updateFn: (state, input) => {
- if (!state.team) return state
-
- return {
- team: {
- ...state.team,
- name: input.name,
- },
- }
- },
- onSuccess: async () => {
- await queryClient.invalidateQueries({
- queryKey: trpc.teams.list.queryKey(),
- })
- toast(defaultSuccessToast(USER_MESSAGES.teamNameUpdated.message))
- },
- onError: ({ error }) => {
- if (!error.serverError) return
-
- toast(
- defaultErrorToast(
- error.serverError || USER_MESSAGES.failedUpdateTeamName.message
- )
- )
- },
- },
- }
- )
-
- const { watch } = form
- const displayedNameHint = getTransformedDefaultTeamName(
- optimisticState?.team ?? team
- )
-
- return (
-
-
- Name
-
- Change your team name to display on your invoices and receipts.
-
-
-
-
-
-
-
- )
-}
diff --git a/src/features/dashboard/settings/general/profile-picture-card.tsx b/src/features/dashboard/settings/general/profile-picture-card.tsx
deleted file mode 100644
index e6193d522..000000000
--- a/src/features/dashboard/settings/general/profile-picture-card.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-'use client'
-
-import { useQueryClient } from '@tanstack/react-query'
-import { AnimatePresence, motion } from 'framer-motion'
-import { ChevronsUp, ImagePlusIcon, Loader2, Pencil } from 'lucide-react'
-import { useAction } from 'next-safe-action/hooks'
-import { useRef, useState } from 'react'
-import { USER_MESSAGES } from '@/configs/user-messages'
-import { uploadTeamProfilePictureAction } from '@/core/server/actions/team-actions'
-import { useDashboard } from '@/features/dashboard/context'
-import {
- defaultErrorToast,
- defaultSuccessToast,
- useToast,
-} from '@/lib/hooks/use-toast'
-import { cn, exponentialSmoothing } from '@/lib/utils'
-import { useTRPC } from '@/trpc/client'
-import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar'
-import { Badge } from '@/ui/primitives/badge'
-import { cardVariants } from '@/ui/primitives/card'
-
-interface ProfilePictureCardProps {
- className?: string
-}
-
-export function ProfilePictureCard({ className }: ProfilePictureCardProps) {
- const { team } = useDashboard()
- const trpc = useTRPC()
- const queryClient = useQueryClient()
- const { toast } = useToast()
- const fileInputRef = useRef(null)
- const [isHovered, setIsHovered] = useState(false)
-
- const { execute: uploadProfilePicture, isExecuting: isUploading } = useAction(
- uploadTeamProfilePictureAction,
- {
- onSuccess: async () => {
- await queryClient.invalidateQueries({
- queryKey: trpc.teams.list.queryKey(),
- })
- toast(defaultSuccessToast(USER_MESSAGES.teamLogoUpdated.message))
- },
- onError: ({ error }) => {
- if (error.validationErrors?.fieldErrors.image) {
- toast(defaultErrorToast(error.validationErrors.fieldErrors.image[0]))
- return
- }
-
- toast(
- defaultErrorToast(
- error.serverError || USER_MESSAGES.failedUpdateLogo.message
- )
- )
- },
- onSettled: () => {
- if (fileInputRef.current) {
- fileInputRef.current.value = ''
- }
- },
- }
- )
-
- const handleUpload = (e: React.ChangeEvent) => {
- const file = e.target.files?.[0]
- if (file) {
- const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB in bytes
-
- if (file.size > MAX_FILE_SIZE) {
- toast(
- defaultErrorToast(
- `Profile picture must be less than ${MAX_FILE_SIZE / (1024 * 1024)}MB.`
- )
- )
-
- if (fileInputRef.current) {
- fileInputRef.current.value = ''
- }
- return
- }
-
- uploadProfilePicture({
- teamSlug: team.slug,
- image: file,
- })
- }
- }
-
- const handleAvatarClick = () => {
- fileInputRef.current?.click()
- }
-
- return (
- <>
-
-
- >
- )
-}
diff --git a/src/features/dashboard/settings/general/remove-photo-dialog.tsx b/src/features/dashboard/settings/general/remove-photo-dialog.tsx
new file mode 100644
index 000000000..2ae1a6c4c
--- /dev/null
+++ b/src/features/dashboard/settings/general/remove-photo-dialog.tsx
@@ -0,0 +1,60 @@
+'use client'
+
+import { Button } from '@/ui/primitives/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+} from '@/ui/primitives/dialog'
+import { TrashIcon } from '@/ui/primitives/icons'
+
+interface RemovePhotoDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onConfirm: () => void
+ isRemoving: boolean
+}
+
+export const RemovePhotoDialog = ({
+ open,
+ onOpenChange,
+ onConfirm,
+ isRemoving,
+}: RemovePhotoDialogProps) => (
+
+)
diff --git a/src/features/dashboard/settings/general/team-avatar.tsx b/src/features/dashboard/settings/general/team-avatar.tsx
new file mode 100644
index 000000000..0bb505ed7
--- /dev/null
+++ b/src/features/dashboard/settings/general/team-avatar.tsx
@@ -0,0 +1,174 @@
+'use client'
+
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { type ReactElement, useRef, useState } from 'react'
+import { USER_MESSAGES } from '@/configs/user-messages'
+import { useDashboard } from '@/features/dashboard/context'
+import {
+ defaultErrorToast,
+ defaultSuccessToast,
+ useToast,
+} from '@/lib/hooks/use-toast'
+import { useTRPC } from '@/trpc/client'
+import { Avatar, AvatarImage, PatternAvatar } from '@/ui/primitives/avatar'
+import { Button } from '@/ui/primitives/button'
+import { EditIcon, PhotoIcon, TrashIcon } from '@/ui/primitives/icons'
+import { RemovePhotoDialog } from './remove-photo-dialog'
+
+const MAX_PROFILE_PICTURE_SIZE_BYTES = 5 * 1024 * 1024
+
+// Converts a file into a base64 payload string; example: File("logo.png") -> "iVBORw0KGgo..."
+const fileToBase64 = (file: File): Promise =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => {
+ const result = typeof reader.result === 'string' ? reader.result : null
+ resolve(result?.split(',')[1] ?? '')
+ }
+ reader.onerror = reject
+ reader.readAsDataURL(file)
+ })
+
+export const TeamAvatar = (): ReactElement => {
+ const { team } = useDashboard()
+ const trpc = useTRPC()
+ const queryClient = useQueryClient()
+ const { toast } = useToast()
+ const fileInputRef = useRef(null)
+ const [removeDialogOpen, setRemoveDialogOpen] = useState(false)
+ const hasPhoto = Boolean(team.profilePictureUrl)
+ const UploadIcon = hasPhoto ? EditIcon : PhotoIcon
+ const uploadLabel = hasPhoto ? 'Change' : 'Add photo'
+
+ const resetFileInput = (): void => {
+ if (fileInputRef.current) fileInputRef.current.value = ''
+ }
+
+ const invalidateTeams = async (): Promise => {
+ await queryClient.invalidateQueries({
+ queryKey: trpc.teams.list.queryKey(),
+ })
+ }
+
+ const uploadProfilePictureMutation = useMutation(
+ trpc.teams.uploadProfilePicture.mutationOptions({
+ onSuccess: async () => {
+ await invalidateTeams()
+ toast(defaultSuccessToast(USER_MESSAGES.teamLogoUpdated.message))
+ },
+ onError: (error) => {
+ toast(
+ defaultErrorToast(
+ error.message || USER_MESSAGES.failedUpdateLogo.message
+ )
+ )
+ },
+ onSettled: resetFileInput,
+ })
+ )
+
+ const removeProfilePictureMutation = useMutation(
+ trpc.teams.removeProfilePicture.mutationOptions({
+ onSuccess: async () => {
+ await invalidateTeams()
+ setRemoveDialogOpen(false)
+ toast(defaultSuccessToast(USER_MESSAGES.teamLogoRemoved.message))
+ },
+ onError: (error) => {
+ toast(
+ defaultErrorToast(
+ error.message || USER_MESSAGES.failedRemoveLogo.message
+ )
+ )
+ },
+ })
+ )
+
+ const handleUpload = async ({
+ target,
+ }: React.ChangeEvent): Promise => {
+ const file = target.files?.[0]
+ if (!file) return
+
+ if (file.size > MAX_PROFILE_PICTURE_SIZE_BYTES) {
+ toast(defaultErrorToast('Profile picture must be less than 5MB.'))
+ resetFileInput()
+ return
+ }
+
+ try {
+ const base64 = await fileToBase64(file)
+ uploadProfilePictureMutation.mutate({
+ teamSlug: team.slug,
+ image: {
+ base64,
+ name: file.name,
+ type: file.type,
+ },
+ })
+ } catch {
+ toast(defaultErrorToast('Failed to read file. Please try again.'))
+ resetFileInput()
+ }
+ }
+
+ const handleUploadClick = (): void => fileInputRef.current?.click()
+ const handleRemoveClick = (): void => setRemoveDialogOpen(true)
+ const handleRemoveConfirm = (): void =>
+ removeProfilePictureMutation.mutate({ teamSlug: team.slug })
+
+ return (
+
+ {hasPhoto ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ {hasPhoto && (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/features/dashboard/settings/general/team-info.tsx b/src/features/dashboard/settings/general/team-info.tsx
new file mode 100644
index 000000000..1c63c3406
--- /dev/null
+++ b/src/features/dashboard/settings/general/team-info.tsx
@@ -0,0 +1,27 @@
+'use client'
+
+import { useDashboard } from '@/features/dashboard/context'
+import { formatDate } from '@/lib/utils/formatting'
+
+const InfoRow = ({ label, value }: { label: string; value: string }) => (
+
+
+ {label}
+
+
+ {value}
+
+
+)
+
+export const TeamInfo = () => {
+ const { team } = useDashboard()
+ const createdAt = formatDate(new Date(team.createdAt), 'MMM d, yyyy') ?? '--'
+
+ return (
+
+
+
+
+ )
+}
diff --git a/src/features/dashboard/settings/general/team-name.tsx b/src/features/dashboard/settings/general/team-name.tsx
new file mode 100644
index 000000000..4be003a65
--- /dev/null
+++ b/src/features/dashboard/settings/general/team-name.tsx
@@ -0,0 +1,211 @@
+'use client'
+
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import {
+ type ReactElement,
+ type ReactNode,
+ useEffect,
+ useRef,
+ useState,
+} from 'react'
+import { USER_MESSAGES } from '@/configs/user-messages'
+import { useDashboard } from '@/features/dashboard/context'
+import {
+ defaultErrorToast,
+ defaultSuccessToast,
+ useToast,
+} from '@/lib/hooks/use-toast'
+import { getTRPCValidationMessages } from '@/lib/utils/trpc-errors'
+import { useTRPC } from '@/trpc/client'
+import { Button } from '@/ui/primitives/button'
+import { CheckIcon, EditIcon } from '@/ui/primitives/icons'
+
+const TEAM_NAME_MAX_FONT_SIZE_PX = 32
+const TEAM_NAME_MIN_FONT_SIZE_PX = 18
+const TEAM_NAME_INPUT_HEIGHT_PX = 40
+
+const getValidationToastContent = (messages: string[]): ReactNode =>
+ messages.length === 1 ? (
+ messages[0]
+ ) : (
+
+ {messages.map((message) => (
+ - {message}
+ ))}
+
+ )
+
+export const TeamName = (): ReactElement => {
+ const { team } = useDashboard()
+ const trpc = useTRPC()
+ const queryClient = useQueryClient()
+ const { toast } = useToast()
+ const [isEditing, setIsEditing] = useState(false)
+ const [name, setName] = useState(team.name)
+ const [fontSize, setFontSize] = useState(TEAM_NAME_MAX_FONT_SIZE_PX)
+ const inputRef = useRef(null)
+ const textMeasureRef = useRef(null)
+ const trimmedName = name.trim()
+ const isSaveDisabled = !trimmedName || trimmedName === team.name
+
+ const updateNameMutation = useMutation(
+ trpc.teams.updateName.mutationOptions({
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({
+ queryKey: trpc.teams.list.queryKey(),
+ })
+ toast(defaultSuccessToast(USER_MESSAGES.teamNameUpdated.message))
+ setIsEditing(false)
+ },
+ onError: (error): void => {
+ const validationMessages = getTRPCValidationMessages(error)
+ if (validationMessages.length > 0) {
+ toast(
+ defaultErrorToast(getValidationToastContent(validationMessages))
+ )
+ return
+ }
+
+ toast(
+ defaultErrorToast(
+ error.message || USER_MESSAGES.failedUpdateTeamName.message
+ )
+ )
+ },
+ })
+ )
+
+ const handleSubmit = (event?: React.FormEvent): void => {
+ event?.preventDefault()
+ if (updateNameMutation.isPending || isSaveDisabled) return
+ updateNameMutation.mutate({ teamSlug: team.slug, name: trimmedName })
+ }
+
+ const handleCancel = (): void => {
+ setName(team.name)
+ setIsEditing(false)
+ }
+
+ useEffect(() => {
+ if (!isEditing || !inputRef.current) return
+ inputRef.current.focus()
+ const cursorPosition = inputRef.current.value.length
+ inputRef.current.setSelectionRange(cursorPosition, cursorPosition)
+ }, [isEditing])
+
+ useEffect(() => {
+ const input = inputRef.current
+ const textMeasure = textMeasureRef.current
+ if (!input || !textMeasure) return
+
+ let frameId = 0
+
+ const updateFontSize = (): void => {
+ const availableWidth = input.clientWidth
+ if (!availableWidth) return
+
+ let nextFontSize = TEAM_NAME_MAX_FONT_SIZE_PX
+
+ textMeasure.textContent = name || ' '
+ textMeasure.style.fontSize = `${nextFontSize}px`
+
+ while (
+ nextFontSize > TEAM_NAME_MIN_FONT_SIZE_PX &&
+ textMeasure.scrollWidth > availableWidth
+ ) {
+ nextFontSize -= 1
+ textMeasure.style.fontSize = `${nextFontSize}px`
+ }
+
+ setFontSize((currentFontSize) =>
+ currentFontSize === nextFontSize ? currentFontSize : nextFontSize
+ )
+ }
+
+ const scheduleFontSizeUpdate = (): void => {
+ window.cancelAnimationFrame(frameId)
+ frameId = window.requestAnimationFrame(updateFontSize)
+ }
+
+ scheduleFontSizeUpdate()
+
+ const resizeObserver = new ResizeObserver(scheduleFontSizeUpdate)
+ resizeObserver.observe(input)
+
+ return () => {
+ resizeObserver.disconnect()
+ window.cancelAnimationFrame(frameId)
+ }
+ }, [name])
+
+ const handleStartEditing = (): void => {
+ setName(team.name)
+ setIsEditing(true)
+ }
+
+ const handleNameChange = ({
+ target,
+ }: React.ChangeEvent): void => setName(target.value)
+
+ return (
+
+ )
+}
diff --git a/src/features/dashboard/sidebar/menu-teams.tsx b/src/features/dashboard/sidebar/menu-teams.tsx
index 1a41b6159..d5ca86260 100644
--- a/src/features/dashboard/sidebar/menu-teams.tsx
+++ b/src/features/dashboard/sidebar/menu-teams.tsx
@@ -4,7 +4,6 @@ import { useCallback } from 'react'
import { TEAM_SPECIFIC_RESOURCE_SEGMENTS } from '@/configs/urls'
import type { TeamModel } from '@/core/modules/teams/models'
import { getTeamDisplayName } from '@/core/modules/teams/utils'
-import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar'
import {
DropdownMenuItem,
DropdownMenuLabel,
@@ -12,6 +11,7 @@ import {
DropdownMenuRadioItem,
} from '@/ui/primitives/dropdown-menu'
import { useDashboard } from '../context'
+import { TeamAvatar } from './team-avatar'
const PRESERVED_SEARCH_PARAMS = ['tab'] as const
@@ -67,12 +67,7 @@ export default function DashboardSidebarMenuTeams() {
teams.map((team) => (
-
-
-
- {team.name?.charAt(0).toUpperCase() || '?'}
-
-
+
{getTeamDisplayName(team)}
diff --git a/src/features/dashboard/sidebar/menu.tsx b/src/features/dashboard/sidebar/menu.tsx
index 8beb64593..88c20b9ce 100644
--- a/src/features/dashboard/sidebar/menu.tsx
+++ b/src/features/dashboard/sidebar/menu.tsx
@@ -7,7 +7,6 @@ import { PROTECTED_URLS } from '@/configs/urls'
import { getTeamDisplayName } from '@/core/modules/teams/utils'
import { signOutAction } from '@/core/server/actions/auth-actions'
import { cn } from '@/lib/utils'
-import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar'
import {
DropdownMenu,
DropdownMenuContent,
@@ -20,6 +19,7 @@ import { SidebarMenuButton, SidebarMenuItem } from '@/ui/primitives/sidebar'
import { useDashboard } from '../context'
import { CreateTeamDialog } from './create-team-dialog'
import DashboardSidebarMenuTeams from './menu-teams'
+import { TeamAvatar } from './team-avatar'
interface DashboardSidebarMenuProps {
className?: string
@@ -49,7 +49,8 @@ export default function DashboardSidebarMenu({
className
)}
>
-
-
-
- {team.name?.charAt(0).toUpperCase() || '?'}
-
-
+ imageClassName="group-data-[collapsible=icon]:size-full"
+ />
TEAM
diff --git a/src/features/dashboard/sidebar/team-avatar.tsx b/src/features/dashboard/sidebar/team-avatar.tsx
new file mode 100644
index 000000000..558815f38
--- /dev/null
+++ b/src/features/dashboard/sidebar/team-avatar.tsx
@@ -0,0 +1,34 @@
+import type { TeamModel } from '@/core/modules/teams/models'
+import { cn } from '@/lib/utils'
+import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar'
+
+interface TeamAvatarProps {
+ team: TeamModel
+ className?: string
+ imageClassName?: string
+}
+
+export const TeamAvatar = ({
+ team,
+ className,
+ imageClassName,
+}: TeamAvatarProps) => {
+ if (!team.profilePictureUrl) {
+ return (
+
+
+ {team.name?.charAt(0).toUpperCase() || '?'}
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/src/lib/utils/trpc-errors.ts b/src/lib/utils/trpc-errors.ts
index 0ef73764b..f08ced663 100644
--- a/src/lib/utils/trpc-errors.ts
+++ b/src/lib/utils/trpc-errors.ts
@@ -1,11 +1,26 @@
import { TRPCClientError, type TRPCClientErrorLike } from '@trpc/client'
+import { z } from 'zod'
import type { TRPCAppRouter } from '@/core/server/api/routers'
-export function isNotFoundError(
+const TrpcErrorWithZodDataSchema = z.object({
+ data: z
+ .object({
+ zodError: z
+ .object({
+ formErrors: z.array(z.string()),
+ fieldErrors: z.record(z.string(), z.array(z.string()).optional()),
+ })
+ .nullable()
+ .optional(),
+ })
+ .optional(),
+})
+
+const isNotFoundError = (
error: unknown
): error is
| TRPCClientErrorLike
- | TRPCClientError {
+ | TRPCClientError => {
if (error instanceof TRPCClientError) {
return error.data?.code === 'NOT_FOUND'
}
@@ -24,3 +39,17 @@ export function isNotFoundError(
trpcLikeError.shape?.data?.code === 'NOT_FOUND'
)
}
+
+const getTRPCValidationMessages = (error: unknown): string[] => {
+ const parsedError = TrpcErrorWithZodDataSchema.safeParse(error)
+ if (!parsedError.success || !parsedError.data.data?.zodError) return []
+
+ const { formErrors, fieldErrors } = parsedError.data.data.zodError
+
+ return [
+ ...formErrors,
+ ...Object.values(fieldErrors).flatMap((messages) => messages ?? []),
+ ]
+}
+
+export { getTRPCValidationMessages, isNotFoundError }
diff --git a/src/ui/primitives/avatar.tsx b/src/ui/primitives/avatar.tsx
index c70e9049b..d0ad3c247 100644
--- a/src/ui/primitives/avatar.tsx
+++ b/src/ui/primitives/avatar.tsx
@@ -2,6 +2,7 @@
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import * as React from 'react'
+import { useId } from 'react'
import { cn } from '@/lib/utils'
const Avatar = React.forwardRef<
@@ -49,4 +50,91 @@ const AvatarFallback = React.forwardRef<
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
-export { Avatar, AvatarFallback, AvatarImage }
+const PATTERN_COLUMN_COUNT = 16
+const PATTERN_ROW_COUNT = 16
+const PATTERN_CELL_SIZE = 8
+const PATTERN_FONT_SIZE = 10
+
+const patternCells = Array.from(
+ { length: PATTERN_ROW_COUNT * PATTERN_COLUMN_COUNT },
+ (_, index) => {
+ const row = Math.floor(index / PATTERN_COLUMN_COUNT)
+ const col = index % PATTERN_COLUMN_COUNT
+ const glyph = (row + col * 2) % 5 === 0 ? '-' : '*'
+ const isAccent = (row * 5 + col * 3) % 11 === 0
+
+ return {
+ col,
+ glyph,
+ isAccent,
+ row,
+ x: 8 + col * PATTERN_CELL_SIZE,
+ y: 14 + row * PATTERN_CELL_SIZE,
+ }
+ }
+)
+
+interface PatternAvatarProps {
+ className?: string
+ letter: string
+}
+
+const PatternAvatar = ({ className, letter }: PatternAvatarProps) => {
+ const clipPathId = useId().replaceAll(':', '')
+ const normalizedLetter = letter.trim().charAt(0).toUpperCase() || '?'
+
+ return (
+
+
+
+ )
+}
+
+export { Avatar, AvatarFallback, AvatarImage, PatternAvatar }
diff --git a/src/ui/primitives/loader.tsx b/src/ui/primitives/loader.tsx
index 0fbb58cb5..5a9fccd3d 100644
--- a/src/ui/primitives/loader.tsx
+++ b/src/ui/primitives/loader.tsx
@@ -1,3 +1,5 @@
+'use client'
+
import * as React from 'react'
import styled, { css } from 'styled-components'
diff --git a/src/ui/primitives/table.tsx b/src/ui/primitives/table.tsx
index 67bdc1eb9..b75848ace 100644
--- a/src/ui/primitives/table.tsx
+++ b/src/ui/primitives/table.tsx
@@ -1,6 +1,8 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
+import { Loader } from './loader'
+import { Skeleton } from './skeleton'
import { TableEmptyRowBorder } from './table-empty-row-border'
const Table = React.forwardRef<
@@ -135,6 +137,13 @@ interface TableEmptyStateProps {
className?: string
}
+interface TableLoadingStateProps {
+ colSpan: number
+ label: string
+ rowCount?: number
+ className?: string
+}
+
const EMPTY_STATE_ROWS = Array.from({ length: 3 })
const TableEmptyState = ({
@@ -165,6 +174,42 @@ const TableEmptyState = ({
)
+const TableLoadingState = ({
+ colSpan,
+ label,
+ rowCount = 3,
+ className,
+}: TableLoadingStateProps) => (
+
+
+
+ {Array.from({ length: rowCount }).map((_, index) => (
+
+
+
+ {index === Math.floor(rowCount / 2) ? (
+ <>
+
+
+ {label}
+
+ >
+ ) : null}
+
+ ))}
+
+
+
+)
+
export {
Table,
TableBody,
@@ -174,5 +219,6 @@ export {
TableFooter,
TableHead,
TableHeader,
+ TableLoadingState,
TableRow,
}