{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-26",
  "title": "Invitation with its answer",
  "description": "A team invitation whose Accept and Decline turn into the result, with Undo.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/animated-size.json",
    "https://ui.ndekocode.dev/r/avatar.json",
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/mesh-tile.json",
    "https://ui.ndekocode.dev/r/status-orb.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/notification/notification-26.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, motion, type Transition } from 'motion/react';\nimport { useEffect, useRef, useState } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport {\n  ArrowRightIcon,\n  LoaderIcon,\n  RotateCcwIcon,\n} from '@/registry/ndeko/lib/icons';\nimport { AnimatedSize } from '@/registry/ndeko/ui/animated-size';\nimport { Avatar } from '@/registry/ndeko/ui/avatar';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport { MeshTile } from '@/registry/ndeko/ui/mesh-tile';\nimport { StatusOrb } from '@/registry/ndeko/ui/status-orb';\n\nimport { PEOPLE, type Person } from './_parts/people';\nimport { SenderAvatar } from './_parts/sender-avatar';\n\ntype InviteState = 'pending' | 'joining' | 'joined' | 'declined';\n\nconst JOIN_DELAY_MS = 900;\n\nconst TEAM_MEMBERS: Person[] = [\n  PEOPLE.amara,\n  PEOPLE.leo,\n  PEOPLE.mei,\n  PEOPLE.tomas,\n];\n\nconst MORPH: Transition = { type: 'spring', bounce: 0, duration: 0.3 };\n\nconst INSTANT: Transition = { duration: 0 };\n\nconst BLUR_IN: Transition = { duration: 0.18, ease: [0.23, 1, 0.32, 1] };\n\nconst BLUR_OUT: Transition = { duration: 0.12, ease: [0.23, 1, 0.32, 1] };\n\nfunction resultKeyOf(state: InviteState) {\n  if (state === 'joined') {\n    return 'joined';\n  }\n  if (state === 'declined') {\n    return 'declined';\n  }\n  return 'actions';\n}\n\nfunction morphOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return INSTANT;\n  }\n  return MORPH;\n}\n\nfunction acceptLabelOf(state: InviteState) {\n  if (state === 'joining') {\n    return 'Joining';\n  }\n  return 'Accept';\n}\n\nfunction MemberStack({ members }: { members: Person[] }) {\n  return (\n    <span className='flex -space-x-1.5'>\n      {members.map((member) => (\n        <Avatar\n          key={member.id}\n          className='ring-card size-5 ring-2'\n          seed={member.name}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction TeamChip() {\n  return (\n    <span className='bg-foreground/[0.03] hairline-ring ring-border inline-flex items-center gap-2 rounded-lg py-1.5 ps-1.5 pe-2.5'>\n      <MeshTile\n        seed='Atlas Redesign'\n        color='var(--info)'\n        className='size-6 rounded-md'\n      />\n      <span className='flex flex-col'>\n        <span className='text-[13px] leading-4 font-medium'>\n          Atlas Redesign\n        </span>\n        <span className='text-muted-foreground text-xs leading-4'>\n          Design team · 6 members\n        </span>\n      </span>\n      <span className='ms-2'>\n        <MemberStack members={TEAM_MEMBERS} />\n      </span>\n    </span>\n  );\n}\n\ntype ActionsProps = {\n  state: InviteState;\n  onAccept: () => void;\n  onDecline: () => void;\n};\n\nfunction InviteActions({ state, onAccept, onDecline }: ActionsProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const morph = morphOf(shouldReduceMotion);\n  const isJoining = state === 'joining';\n  const acceptLabel = acceptLabelOf(state);\n\n  return (\n    <div className='flex gap-2'>\n      <Button\n        size='sm'\n        variant='outline'\n        disabled={isJoining}\n        onClick={onDecline}\n      >\n        Decline\n      </Button>\n      <Button size='sm' onClick={onAccept} aria-disabled={isJoining}>\n        <AnimatePresence initial={false}>\n          {isJoining && (\n            <motion.span\n              key='spinner'\n              initial={{ opacity: 0, width: 0 }}\n              animate={{ opacity: 1, width: 'auto' }}\n              exit={{ opacity: 0, width: 0 }}\n              transition={morph}\n              className='-ms-0.5 flex overflow-hidden'\n            >\n              <LoaderIcon className='animate-spin' />\n            </motion.span>\n          )}\n        </AnimatePresence>\n        <motion.span layout transition={morph}>\n          {acceptLabel}\n        </motion.span>\n      </Button>\n    </div>\n  );\n}\n\nfunction JoinedResult({ onUndo }: { onUndo: () => void }) {\n  return (\n    <div className='flex flex-col gap-3'>\n      <div className='flex items-center gap-2.5'>\n        <StatusOrb tone='success' size='sm' />\n        <p className='text-[13px] leading-5'>\n          You joined <span className='font-medium'>Atlas Redesign</span>\n        </p>\n      </div>\n      <div className='flex gap-2'>\n        <Button size='sm' variant='outline'>\n          Open team\n          <ArrowRightIcon />\n        </Button>\n        <Button variant='ghost' size='sm' onClick={onUndo}>\n          <RotateCcwIcon />\n          Undo\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nfunction DeclinedResult({ onUndo }: { onUndo: () => void }) {\n  return (\n    <div className='flex items-center gap-2.5'>\n      <StatusOrb tone='neutral' size='sm' />\n      <p className='text-muted-foreground text-[13px] leading-5'>\n        Invite declined, Amara will be told\n      </p>\n      <Button variant='ghost' size='sm' onClick={onUndo} className='ms-auto'>\n        <RotateCcwIcon />\n        Undo\n      </Button>\n    </div>\n  );\n}\n\nfunction useJoinTimer(onJoined: () => void) {\n  const timer = useRef<number | null>(null);\n  const onJoinedRef = useRef(onJoined);\n\n  useEffect(() => {\n    onJoinedRef.current = onJoined;\n  }, [onJoined]);\n\n  useEffect(() => {\n    return () => {\n      if (timer.current !== null) {\n        window.clearTimeout(timer.current);\n      }\n    };\n  }, []);\n\n  function start() {\n    timer.current = window.setTimeout(() => {\n      timer.current = null;\n      onJoinedRef.current();\n    }, JOIN_DELAY_MS);\n  }\n\n  return start;\n}\n\nexport default function Notification26() {\n  const [state, setState] = useState<InviteState>('pending');\n  const resultKey = resultKeyOf(state);\n  const isAnswered = resultKey !== 'actions';\n\n  function markJoined() {\n    setState('joined');\n  }\n\n  const startJoining = useJoinTimer(markJoined);\n\n  function accept() {\n    if (state !== 'pending') {\n      return;\n    }\n    setState('joining');\n    startJoining();\n  }\n\n  function decline() {\n    setState('declined');\n  }\n\n  function undo() {\n    setState('pending');\n  }\n\n  return (\n    <article\n      aria-label='Invitation from Amara Okafor'\n      className='bg-card hairline-ring ring-border flex w-full max-w-[26rem] items-start gap-3 rounded-xl p-4 shadow-[0_1px_2px_rgb(0_0_0/0.04)]'\n    >\n      <SenderAvatar person={PEOPLE.amara} kind='invite' />\n      <div className='flex min-w-0 flex-1 flex-col gap-3'>\n        <div className='flex flex-col gap-0.5'>\n          <p className='text-muted-foreground text-[13px] leading-5'>\n            <span className='text-foreground font-medium'>Amara Okafor</span>{' '}\n            invited you to join a team as a{' '}\n            <span className='text-foreground font-medium'>Member</span>\n          </p>\n          <p className='text-muted-foreground text-xs tabular-nums'>2m</p>\n        </div>\n        <div>\n          <TeamChip />\n        </div>\n        <AnimatedSize>\n          <div aria-live='polite' className='relative'>\n            <AnimatePresence mode='popLayout' initial={false}>\n              <motion.div\n                key={resultKey}\n                initial={{ opacity: 0, filter: 'blur(2px)' }}\n                animate={{\n                  opacity: 1,\n                  filter: 'blur(0px)',\n                  transition: BLUR_IN,\n                }}\n                exit={{ opacity: 0, filter: 'blur(2px)', transition: BLUR_OUT }}\n              >\n                {!isAnswered && (\n                  <InviteActions\n                    state={state}\n                    onAccept={accept}\n                    onDecline={decline}\n                  />\n                )}\n                {state === 'joined' && <JoinedResult onUndo={undo} />}\n                {state === 'declined' && <DeclinedResult onUndo={undo} />}\n              </motion.div>\n            </AnimatePresence>\n          </div>\n        </AnimatedSize>\n      </div>\n    </article>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/notification/notification-26.tsx"
    },
    {
      "path": "registry/ndeko/components/notification/_parts/people.ts",
      "content": "export type Person = {\n  id: string;\n  name: string;\n};\n\nexport const PEOPLE = {\n  amara: {\n    id: 'amara',\n    name: 'Amara Okafor',\n  },\n  leo: {\n    id: 'leo',\n    name: 'Léo Martin',\n  },\n  sana: {\n    id: 'sana',\n    name: 'Sana Iqbal',\n  },\n  tomas: {\n    id: 'tomas',\n    name: 'Tomás Rivera',\n  },\n  mei: {\n    id: 'mei',\n    name: 'Mei Lin',\n  },\n  jonas: { id: 'jonas', name: 'Jonas Weber' },\n} satisfies Record<string, Person>;\n\nexport function firstNameOf(person: Person) {\n  const [firstName] = person.name.split(' ');\n  return firstName;\n}\n",
      "type": "registry:component",
      "target": "components/notification/_parts/people.ts"
    },
    {
      "path": "registry/ndeko/components/notification/_parts/sender-avatar.tsx",
      "content": "import {\n  AtSignIcon,\n  CircleDashedIcon,\n  MailIcon,\n  MessageCircleIcon,\n  UserRoundPlusIcon,\n  type Icon,\n} from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Avatar } from '@/registry/ndeko/ui/avatar';\n\nimport type { Person } from './people';\n\nexport type NotificationKind =\n  'mention' | 'comment' | 'assigned' | 'status' | 'invite';\n\ntype KindMeta = { label: string; color: string; glyph: Icon };\n\nexport const KIND_META: Record<NotificationKind, KindMeta> = {\n  mention: { label: 'Mention', color: 'var(--info)', glyph: AtSignIcon },\n  comment: {\n    label: 'Comment',\n    color: 'var(--success)',\n    glyph: MessageCircleIcon,\n  },\n  assigned: {\n    label: 'Assignment',\n    color: 'var(--primary)',\n    glyph: UserRoundPlusIcon,\n  },\n  status: {\n    label: 'Status change',\n    color: 'var(--muted-foreground)',\n    glyph: CircleDashedIcon,\n  },\n  invite: { label: 'Invitation', color: 'var(--warning)', glyph: MailIcon },\n};\n\ntype SenderAvatarProps = {\n  person: Person;\n  kind: NotificationKind;\n  /** The colour behind the badge's cut-out: the surface the avatar sits on. */\n  surfaceClassName?: string;\n  className?: string;\n};\n\n/** A sender's face with the kind of the notification on its lower corner. */\nexport function SenderAvatar({\n  person,\n  kind,\n  surfaceClassName = 'bg-card',\n  className,\n}: SenderAvatarProps) {\n  const meta = KIND_META[kind];\n  const Glyph = meta.glyph;\n  const badgeStyle = { '--chip': meta.color } as React.CSSProperties;\n\n  return (\n    <span className={cn('relative inline-flex shrink-0', className)}>\n      <Avatar className='size-8' seed={person.name} />\n      <span\n        aria-hidden='true'\n        className={cn(\n          'absolute -end-1 -bottom-1 inline-flex rounded-full p-px',\n          surfaceClassName\n        )}\n      >\n        <span\n          style={badgeStyle}\n          className='chip-3d inline-flex size-4 items-center justify-center rounded-full'\n        >\n          <Glyph strokeWidth={2.25} className='size-2.5' />\n        </span>\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/notification/_parts/sender-avatar.tsx"
    }
  ],
  "categories": [
    "components",
    "notification"
  ],
  "type": "registry:component"
}