{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "work-experience",
  "title": "Work Experience",
  "author": "ncdai <dai@chanhdai.com>",
  "description": "Display work experiences with role details, company logos, and durations.",
  "dependencies": [
    "react-markdown",
    "date-fns"
  ],
  "registryDependencies": [
    "collapsible",
    "separator",
    "https://chanhdai.com/r/chevrons-up-down-icon.json",
    "https://chanhdai.com/r/typography.json"
  ],
  "files": [
    {
      "path": "src/registry/components/work-experience/work-experience.tsx",
      "content": "\"use client\"\n\nimport { useCallback, useRef, type ComponentProps } from \"react\"\nimport { differenceInMonths, parse } from \"date-fns\"\nimport ReactMarkdown from \"react-markdown\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\"\nimport { Separator } from \"@/components/ui/separator\"\nimport type { ChevronsUpDownIconHandle } from \"@/registry/components/chevrons-up-down-icon\"\nimport { ChevronsUpDownIcon } from \"@/registry/components/chevrons-up-down-icon\"\nimport { IconPlaceholder } from \"@/registry/icons/icon-placeholder\"\n\nexport type ExperiencePositionItemType = {\n  /** Unique identifier for the position */\n  id: string\n  /** The job title or position name */\n  title: string\n  /**\n   * Employment period of the position.\n   * Use \"MM.YYYY\" or \"YYYY\" format. Omit `end` for current roles.\n   */\n  employmentPeriod: {\n    /** Start date (e.g., \"10.2022\" or \"2020\"). */\n    start: string\n    /** End date; leave undefined for \"Present\". */\n    end?: string\n  }\n  /** The type of employment (e.g., \"Full-time\", \"Part-time\", \"Contract\") */\n  employmentType?: string\n  /** A brief description of the position or responsibilities */\n  description?: string\n  /** An icon representing the position */\n  icon?: React.ReactElement\n  /** A list of skills associated with the position */\n  skills?: string[]\n  /** Indicates if the position details are expanded in the UI */\n  isExpanded?: boolean\n}\n\nexport type ExperienceItemType = {\n  /** Unique identifier for the experience item */\n  id: string\n  /** Name of the company where the experience was gained */\n  companyName: string\n  /** URL or path to the company's logo image */\n  companyLogo?: string\n  /** URL to the company's website. */\n  companyWebsite?: string\n  /**\n   * List of positions held at the company\n   * @fumadocsHref #experiencepositionitemtype\n   * */\n  positions: ExperiencePositionItemType[]\n  /** Indicates if this is the user's current employer */\n  isCurrentEmployer?: boolean\n}\n\nexport type WorkExperienceProps = {\n  className?: string\n  /** @fumadocsHref #experienceitemtype */\n  experiences: ExperienceItemType[]\n}\n\nexport function WorkExperience({\n  className,\n  experiences,\n}: WorkExperienceProps) {\n  return (\n    <div className={cn(\"bg-background px-4 text-foreground\", className)}>\n      {experiences.map((experience) => (\n        <ExperienceItem key={experience.id} experience={experience} />\n      ))}\n    </div>\n  )\n}\n\nexport type ExperienceItemProps = {\n  experience: ExperienceItemType\n}\n\nexport function ExperienceItem({ experience }: ExperienceItemProps) {\n  return (\n    <div className=\"space-y-4 py-4\">\n      <div className=\"not-prose flex items-center gap-3\">\n        <div className=\"flex size-6 shrink-0 items-center justify-center\">\n          {experience.companyLogo ? (\n            <img\n              src={experience.companyLogo}\n              alt={experience.companyName}\n              className=\"size-6 rounded-full\"\n              aria-hidden\n            />\n          ) : (\n            <span className=\"flex size-2 rounded-full bg-zinc-300 dark:bg-zinc-600\" />\n          )}\n        </div>\n\n        <h3 className=\"text-lg leading-snug font-semibold\">\n          {experience.companyWebsite ? (\n            <a\n              className=\"link\"\n              href={experience.companyWebsite}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n            >\n              {experience.companyName}\n            </a>\n          ) : (\n            experience.companyName\n          )}\n        </h3>\n\n        {experience.isCurrentEmployer && (\n          <span\n            className=\"relative flex items-center justify-center\"\n            aria-label=\"Current Employer\"\n          >\n            <span className=\"absolute inline-flex size-3 animate-ping rounded-full bg-sky-500 opacity-50\" />\n            <span className=\"relative inline-flex size-2 rounded-full bg-sky-500\" />\n          </span>\n        )}\n      </div>\n\n      <div className=\"relative space-y-4 before:absolute before:left-3 before:h-full before:w-px before:bg-border\">\n        {experience.positions.map((position) => (\n          <ExperiencePositionItem key={position.id} position={position} />\n        ))}\n      </div>\n    </div>\n  )\n}\n\nexport type ExperiencePositionItemProps = {\n  position: ExperiencePositionItemType\n}\n\nexport function ExperiencePositionItem({\n  position,\n}: ExperiencePositionItemProps) {\n  const chevronsUpDownIconRef = useRef<ChevronsUpDownIconHandle>(null)\n\n  const handleOpenChange = useCallback((open: boolean) => {\n    const controls = chevronsUpDownIconRef.current\n    if (!controls) return\n\n    if (open) {\n      controls.startAnimation()\n    } else {\n      controls.stopAnimation()\n    }\n  }, [])\n\n  const { start, end } = position.employmentPeriod\n  const isOngoing = !end\n  const duration = formatDuration(start, end)\n\n  return (\n    <Collapsible\n      defaultOpen={position.isExpanded}\n      onOpenChange={handleOpenChange}\n      disabled={!position.description}\n      asChild\n    >\n      <div className=\"relative last:before:absolute last:before:h-full last:before:w-4 last:before:bg-background\">\n        <CollapsibleTrigger\n          className={cn(\n            \"group/experience-position not-prose block w-full text-left select-none\",\n            \"relative before:absolute before:-top-1 before:-right-1 before:-bottom-1.5 before:left-7 before:rounded-lg hover:before:bg-muted/30\",\n            \"data-disabled:before:content-none\"\n          )}\n        >\n          <div className=\"relative z-1 mb-1 flex items-start gap-3 text-base\">\n            <div\n              className={cn(\n                \"flex size-6 shrink-0 items-center justify-center rounded-lg\",\n                \"bg-muted text-muted-foreground\",\n                \"border border-muted-foreground/15 ring-1 ring-line ring-offset-1 ring-offset-background\",\n                \"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n              )}\n            >\n              {position.icon ?? (\n                <IconPlaceholder\n                  lucide=\"BriefcaseBusinessIcon\"\n                  tabler=\"IconBriefcase\"\n                  hugeicons=\"Briefcase01Icon\"\n                  phosphor=\"BriefcaseIcon\"\n                  remixicon=\"RiBriefcaseLine\"\n                />\n              )}\n            </div>\n\n            <h4 className=\"flex-1 font-medium text-balance text-foreground\">\n              {position.title}\n            </h4>\n\n            <div className=\"shrink-0 text-muted-foreground group-disabled/experience-position:hidden [&_svg]:h-lh [&_svg]:w-4\">\n              <ChevronsUpDownIcon ref={chevronsUpDownIconRef} duration={0.15} />\n            </div>\n          </div>\n\n          <dl className=\"relative z-1 flex items-center gap-2 pl-9 text-sm text-muted-foreground\">\n            {position.employmentType && (\n              <>\n                <div>\n                  <dt className=\"sr-only\">Employment Type</dt>\n                  <dd>{position.employmentType}</dd>\n                </div>\n\n                <Separator\n                  className=\"data-vertical:h-4 data-vertical:self-center\"\n                  orientation=\"vertical\"\n                />\n              </>\n            )}\n\n            <div>\n              <dt className=\"sr-only\">Employment Period</dt>\n              <dd className=\"flex items-center gap-0.5 tabular-nums\">\n                <span>{start}</span>\n                <span className=\"font-mono\">—</span>\n                {isOngoing ? (\n                  <IconPlaceholder\n                    lucide=\"InfinityIcon\"\n                    tabler=\"IconInfinity\"\n                    hugeicons=\"Infinity01Icon\"\n                    phosphor=\"InfinityIcon\"\n                    remixicon=\"RiInfinityFill\"\n                    className=\"size-4.5 translate-y-[0.5px]\"\n                    aria-label=\"Present\"\n                  />\n                ) : (\n                  <span>{end}</span>\n                )}\n              </dd>\n            </div>\n\n            {duration && (\n              <>\n                <Separator\n                  className=\"data-vertical:h-4 data-vertical:self-center\"\n                  orientation=\"vertical\"\n                />\n                <div>\n                  <dt className=\"sr-only\">Duration</dt>\n                  <dd className=\"tabular-nums\">{duration}</dd>\n                </div>\n              </>\n            )}\n          </dl>\n        </CollapsibleTrigger>\n\n        <CollapsibleContent className=\"overflow-hidden\">\n          {position.description && (\n            <Prose className=\"pt-2 pl-9\">\n              <ReactMarkdown>{position.description}</ReactMarkdown>\n            </Prose>\n          )}\n        </CollapsibleContent>\n\n        {Array.isArray(position.skills) && position.skills.length > 0 && (\n          <ul className=\"not-prose flex flex-wrap gap-1.5 pt-3 pl-9\">\n            {position.skills.map((skill, index) => (\n              <li key={index} className=\"flex\">\n                <Skill>{skill}</Skill>\n              </li>\n            ))}\n          </ul>\n        )}\n      </div>\n    </Collapsible>\n  )\n}\n\nfunction Prose({ className, ...props }: ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\n        \"prose max-w-none prose-ncdai prose-zinc dark:prose-invert\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction Skill({ className, ...props }: ComponentProps<\"span\">) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex items-center rounded-md border bg-muted/50 px-1.5 py-0.5 font-mono text-xs text-muted-foreground\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction formatDuration(start: string, end?: string): string {\n  const startHasMonth = start.includes(\".\")\n  const endHasMonth = end ? end.includes(\".\") : true\n\n  // Both year-only: granularity is years, no month arithmetic needed.\n  if (!startHasMonth && end && !endHasMonth) {\n    const years = parseInt(end, 10) - parseInt(start, 10)\n    if (years <= 0) {\n      return \"\"\n    }\n    return `${years}y`\n  }\n\n  const startDate = parsePeriodDate(start, \"first\")\n  const endDate = end ? parsePeriodDate(end, \"last\") : new Date()\n\n  // +1 to count both the start and end months inclusively.\n  const totalMonths = differenceInMonths(endDate, startDate) + 1\n  if (totalMonths <= 0) {\n    return \"\"\n  }\n\n  if (totalMonths < 12) {\n    return `${totalMonths}m`\n  }\n\n  const years = Math.floor(totalMonths / 12)\n  const months = totalMonths % 12\n  if (months === 0) {\n    return `${years}y`\n  }\n  return `${years}y ${months}m`\n}\n\nfunction parsePeriodDate(str: string, fallbackMonth: \"first\" | \"last\"): Date {\n  if (str.includes(\".\")) {\n    return parse(str, \"MM.yyyy\", new Date())\n  }\n  return parse(\n    `${fallbackMonth === \"last\" ? \"12\" : \"01\"}.${str}`,\n    \"MM.yyyy\",\n    new Date()\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/work-experience.tsx"
    }
  ],
  "docs": "https://chanhdai.com/components/work-experience-component",
  "categories": [
    "marketing"
  ],
  "type": "registry:component"
}