{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "event-card", "title": "Event Card", "description": "Event card with CTA that opens a responsive drawer (right on desktop, bottom on mobile).", "dependencies": [ "@radix-ui/react-dialog", "@phosphor-icons/react", "lucide-react", "react-markdown", "remark-gfm", "rehype-raw", "rehype-sanitize" ], "files": [ { "path": "registry/components/event-card/index.ts", "content": "export { EventCard } from \"./index.tsx\"\nexport type { EventCardProps, EventTimelineItem, DrawerDirection } from \"./types\"\n", "type": "registry:block" }, { "path": "registry/components/event-card/index.tsx", "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { EventCardDrawer } from \"./event-card-drawer\"\nimport { EventCardPreview } from \"./event-card-preview\"\nimport type { DrawerDirection, EventCardProps } from \"./types\"\n\nfunction useResolvedDirection(direction: DrawerDirection) {\n const [isDesktop, setIsDesktop] = React.useState(false)\n\n React.useEffect(() => {\n const mediaQuery = window.matchMedia(\"(min-width: 768px)\")\n const sync = () => setIsDesktop(mediaQuery.matches)\n sync()\n mediaQuery.addEventListener(\"change\", sync)\n return () => mediaQuery.removeEventListener(\"change\", sync)\n }, [])\n\n return direction === \"auto\" ? (isDesktop ? \"right\" : \"bottom\") : direction\n}\n\nexport function EventCard({\n title,\n miniDescription,\n ctaText,\n cardCtaText,\n drawerCtaText,\n hideCardCta = false,\n hideDrawerCta = false,\n isActive,\n category,\n image,\n date,\n time,\n isOnline,\n eventLocationLink,\n showLocationTextInMapsTag = true,\n eventLocationText,\n eventMode,\n eventDetailDescription,\n eventTimeline,\n onCtaClick,\n onDrawerOpen,\n onDrawerCtaClick,\n drawerDirection = \"auto\",\n drawerBottomInsetClassName,\n drawerBodyClassName,\n drawerPinFooter = true,\n drawerFooterClassName,\n className,\n}: EventCardProps) {\n const [open, setOpen] = React.useState(false)\n const wasOpenRef = React.useRef(false)\n const resolvedDirection = useResolvedDirection(drawerDirection)\n const resolvedCardCtaText = cardCtaText ?? ctaText\n const resolvedDrawerCtaText = drawerCtaText ?? ctaText\n\n const handleCtaClick = () => {\n setOpen(true)\n }\n const handleDrawerCtaClick = () => {\n onDrawerCtaClick?.()\n onCtaClick?.()\n }\n\n React.useEffect(() => {\n if (open && !wasOpenRef.current) {\n onDrawerOpen?.()\n }\n wasOpenRef.current = open\n }, [open, onDrawerOpen])\n\n return (\n <>\n \n \n \n )\n}\n\nexport type { EventCardProps, EventTimelineItem, DrawerDirection } from \"./types\"\n", "type": "registry:block" }, { "path": "registry/components/event-card/event-card-preview.tsx", "content": "import { cn } from \"@/lib/utils\";\n\nimport type { EventCardProps } from \"./types\";\nimport { CardCtaButton } from \"../shared/card-cta-button\";\nimport { toRichPreviewText } from \"./rich-content\";\n\nfunction toCapitalizedWords(value: string) {\n return value.replace(/\\b\\w/g, (char) => char.toUpperCase());\n}\n\nfunction getDateParts(value: string) {\n const parsed = new Date(value);\n if (!Number.isNaN(parsed.getTime())) {\n return {\n day: String(parsed.getDate()).padStart(2, \"0\"),\n month: parsed.toLocaleString(\"en-US\", { month: \"short\" }).toUpperCase(),\n };\n }\n\n const tokens = value.trim().split(/\\s+/);\n const monthToken = tokens.find((token) => /[A-Za-z]/.test(token)) ?? \"\";\n const dayToken =\n tokens.find((token) => /^\\d{1,2}$/.test(token)) ??\n (tokens[0]?.replace(/\\D/g, \"\") || \"\");\n\n return {\n day: dayToken.padStart(2, \"0\").slice(-2) || \"--\",\n month: monthToken.slice(0, 3).toUpperCase() || \"---\",\n };\n}\n\ntype EventCardPreviewProps = Pick<\n EventCardProps,\n | \"title\"\n | \"miniDescription\"\n | \"ctaText\"\n | \"hideCardCta\"\n | \"isActive\"\n | \"category\"\n | \"image\"\n | \"date\"\n | \"className\"\n> & {\n onCtaClick: () => void;\n};\n\nexport function EventCardPreview({\n title,\n miniDescription,\n ctaText,\n hideCardCta,\n isActive,\n category,\n image,\n date,\n onCtaClick,\n className,\n}: EventCardPreviewProps) {\n const resolvedTitle = toCapitalizedWords(title);\n const resolvedMiniDescription = toRichPreviewText(miniDescription);\n const resolvedCategory = toCapitalizedWords(category);\n const resolvedCtaText = toCapitalizedWords(ctaText);\n const { day, month } = getDateParts(date);\n\n return (\n \n
\n \n
\n {day}\n {month}\n
\n
\n\n
\n
\n \n {resolvedCategory}\n \n \n {isActive ? \"Active\" : \"Inactive\"}\n \n
\n

\n {resolvedTitle}\n

\n

\n {resolvedMiniDescription}\n

\n\n {!hideCardCta ? (\n
\n \n
\n ) : null}\n
\n \n );\n}\n", "type": "registry:block" }, { "path": "registry/components/event-card/event-card-drawer.tsx", "content": "\"use client\";\n\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport {\n CalendarDots,\n ClockAfternoon,\n GlobeHemisphereWest,\n MapPin,\n X,\n} from \"@phosphor-icons/react\";\n\nimport { CardCtaButton } from \"../shared/card-cta-button\";\nimport type { DrawerDirection, EventCardProps } from \"./types\";\nimport type { ReactNode } from \"react\";\nimport { RichContent } from \"./rich-content\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype EventMetaTagProps = {\n icon: ReactNode;\n value: string;\n href?: string;\n className?: string;\n};\n\nfunction toCapitalizedWords(value: string) {\n return value.replace(/\\b\\w/g, (char) => char.toUpperCase());\n}\n\nfunction EventMetaTag({\n icon,\n value,\n href,\n className = \"\",\n}: EventMetaTagProps) {\n const resolvedValue = toCapitalizedWords(value);\n const sharedClassName = `flex min-w-0 flex-1 items-center gap-2 rounded-[8px] border border-[#E5E7EB] bg-[#FFF4ED] px-2 py-2 text-[12px] text-[#374151] ${className}`;\n\n if (href) {\n return (\n \n {icon}\n \n {resolvedValue}\n \n \n );\n }\n\n return (\n
\n {icon}\n {resolvedValue}\n
\n );\n}\n\ntype EventCardDrawerProps = Pick<\n EventCardProps,\n | \"title\"\n | \"ctaText\"\n | \"hideDrawerCta\"\n | \"isActive\"\n | \"category\"\n | \"image\"\n | \"date\"\n | \"time\"\n | \"isOnline\"\n | \"eventLocationLink\"\n | \"showLocationTextInMapsTag\"\n | \"eventLocationText\"\n | \"eventMode\"\n | \"eventDetailDescription\"\n | \"eventTimeline\"\n | \"onCtaClick\"\n | \"drawerBottomInsetClassName\"\n | \"drawerBodyClassName\"\n | \"drawerPinFooter\"\n | \"drawerFooterClassName\"\n> & {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n resolvedDirection: Exclude;\n};\n\nexport function EventCardDrawer({\n title,\n ctaText,\n hideDrawerCta,\n isActive,\n category,\n image,\n date,\n time,\n isOnline,\n eventLocationLink,\n showLocationTextInMapsTag,\n eventLocationText,\n eventMode,\n eventDetailDescription,\n eventTimeline,\n onCtaClick,\n drawerBottomInsetClassName,\n drawerBodyClassName,\n drawerPinFooter = true,\n drawerFooterClassName,\n open,\n onOpenChange,\n resolvedDirection,\n}: EventCardDrawerProps) {\n const resolvedTitle = toCapitalizedWords(title);\n const resolvedCategory = toCapitalizedWords(category);\n const resolvedCtaText = toCapitalizedWords(ctaText);\n const mapTagValue =\n showLocationTextInMapsTag && eventLocationText?.trim()\n ? `${eventLocationText.trim()} - View On Maps`\n : \"View On Maps\";\n\n const footerCta =\n !hideDrawerCta ? (\n \n \n \n ) : null;\n\n return (\n \n \n \n \n
\n \n Event Details\n \n \n \n Close\n \n
\n\n \n \n\n
\n \n {resolvedCategory}\n \n \n {isActive ? \"Active\" : \"Inactive\"}\n \n
\n\n

\n {resolvedTitle}\n

\n\n {isOnline ? (\n
\n }\n value={date}\n />\n }\n value={time}\n />\n }\n value={eventMode}\n />\n
\n ) : (\n
\n
\n }\n value={date}\n />\n }\n value={time}\n />\n
\n }\n value={mapTagValue}\n href={eventLocationLink}\n className=\"w-full flex-none\"\n />\n
\n )}\n\n \n\n {eventTimeline.length > 0 ? (\n
\n

\n Event timeline\n

\n
\n {eventTimeline.map((item, index) => (\n
\n {index < eventTimeline.length - 1 ? (\n \n ) : null}\n \n

\n {toCapitalizedWords(item.time)}\n

\n

\n {toCapitalizedWords(item.text)}\n

\n
\n ))}\n
\n
\n ) : null}\n\n {!drawerPinFooter ? footerCta : null}\n \n\n {drawerPinFooter ? footerCta : null}\n \n
\n
\n );\n}\n", "type": "registry:block" }, { "path": "registry/components/event-card/rich-content.tsx", "content": "\"use client\"\n\nimport { cn } from \"@/lib/utils\"\nimport ReactMarkdown from \"react-markdown\"\nimport rehypeRaw from \"rehype-raw\"\nimport rehypeSanitize, { defaultSchema } from \"rehype-sanitize\"\nimport remarkGfm from \"remark-gfm\"\nimport type { Options as SanitizeOptions } from \"rehype-sanitize\"\nimport type { Components } from \"react-markdown\"\n\ntype RichContentProps = {\n value: string\n className?: string\n}\n\nconst markdownComponents: Components = {\n h1: ({ node, ...props }) => (\n

\n ),\n h2: ({ node, ...props }) => (\n

\n ),\n h3: ({ node, ...props }) => (\n

\n ),\n h4: ({ node, ...props }) => (\n

\n ),\n h5: ({ node, ...props }) => (\n

\n ),\n h6: ({ node, ...props }) => (\n
\n ),\n hr: ({ node, ...props }) =>
,\n}\n\nconst sanitizeSchema: SanitizeOptions = {\n ...defaultSchema,\n tagNames: [...(defaultSchema.tagNames ?? []), \"video\"],\n attributes: {\n ...(defaultSchema.attributes ?? {}),\n video: [\n \"src\",\n \"controls\",\n \"poster\",\n \"preload\",\n \"playsInline\",\n \"muted\",\n \"loop\",\n \"width\",\n \"height\",\n ],\n source: [...(defaultSchema.attributes?.source ?? []), \"src\", \"type\"],\n },\n}\n\nconst HTML_ENTITY_MAP: Record = {\n \" \": \" \",\n \"&\": \"&\",\n \"<\": \"<\",\n \">\": \">\",\n \""\": '\"',\n \"'\": \"'\",\n}\n\nfunction decodeHtmlEntities(value: string) {\n return Object.entries(HTML_ENTITY_MAP).reduce(\n (result, [entity, replacement]) => result.split(entity).join(replacement),\n value,\n )\n}\n\nfunction decodeMarkdownPayload(content: string): string {\n if (!content) return \"\"\n\n const normalizedContent = content\n .replace(/\\\\r\\\\n/g, \"\\n\")\n .replace(/\\\\n/g, \"\\n\")\n .replace(/\\\\t/g, \"\\t\")\n\n if (normalizedContent.includes(\"<\") || normalizedContent.includes(\">\")) {\n const parser = new DOMParser()\n const decoded = parser\n .parseFromString(normalizedContent, \"text/html\")\n .documentElement.textContent\n return decoded ?? normalizedContent\n }\n\n return normalizedContent\n}\n\nexport function toRichPreviewText(value: string) {\n const decoded = decodeHtmlEntities(decodeMarkdownPayload(value || \"\"))\n\n return decoded\n .replace(//gi, \"\\n\")\n .replace(/<\\/(p|div|li|h[1-6]|blockquote)>/gi, \"\\n\")\n .replace(/<[^>]+>/g, \" \")\n .replace(/!\\[.*?\\]\\((.*?)\\)/g, \"$1\")\n .replace(/\\[(.*?)\\]\\((.*?)\\)/g, \"$1\")\n .replace(/[#>*_`~-]/g, \"\")\n .replace(/[^\\S\\n]+/g, \" \")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .trim()\n}\n\nexport function RichContent({ value, className }: RichContentProps) {\n if (!value.trim()) return null\n\n return (\n \n \n {decodeMarkdownPayload(value)}\n \n \n )\n}\n", "type": "registry:block" }, { "path": "registry/components/event-card/types.ts", "content": "export type DrawerDirection = \"right\" | \"bottom\" | \"auto\"\n\nexport type EventTimelineItem = {\n time: string\n text: string\n}\n\nexport type EventCardProps = {\n title: string\n miniDescription: string\n ctaText: string\n cardCtaText?: string\n drawerCtaText?: string\n hideCardCta?: boolean\n hideDrawerCta?: boolean\n isActive: boolean\n category: string\n image: string\n date: string\n time: string\n isOnline: boolean\n eventLocationLink?: string\n showLocationTextInMapsTag?: boolean\n eventLocationText?: string\n eventMode: string\n eventDetailDescription: string\n eventTimeline: EventTimelineItem[]\n onCtaClick?: () => void\n onDrawerOpen?: () => void\n onDrawerCtaClick?: () => void\n drawerDirection?: DrawerDirection\n /**\n * Merged onto `Dialog.Content` — use on bottom sheets to clear a fixed bottom tab bar, e.g.\n * `pb-[calc(4.5rem+env(safe-area-inset-bottom))]`.\n */\n drawerBottomInsetClassName?: string\n /** Extra classes on the scrollable body (e.g. `pb-4` so the last line clears the pinned footer). */\n drawerBodyClassName?: string\n /**\n * When `true` (default), the drawer CTA sits in a fixed footer below the scroll area.\n * When `false`, the CTA scrolls with the rest of the content.\n */\n drawerPinFooter?: boolean\n /** Merged onto the footer row when `drawerPinFooter` is true (e.g. shadow, safe-area padding). */\n drawerFooterClassName?: string\n /** Merged onto the visible card root; override e.g. max-w, h-full, min-h. */\n className?: string\n}\n", "type": "registry:block" }, { "path": "registry/components/shared/card-cta-button.tsx", "content": "type CardCtaButtonProps = {\n text: string;\n onClick?: () => void;\n theme?: \"yellow\" | \"red\" | \"purple\";\n fullWidth?: boolean;\n className?: string;\n};\n\nconst THEME_CLASSES = {\n yellow: \"bg-[#EF8833] text-[#fff] hover:bg-[#DC7A2D]\",\n red: \"bg-[#EF4444] text-white hover:bg-[#DC2626]\",\n purple: \"bg-[#6962AC] text-white hover:bg-[#5B5596]\",\n} as const;\n\nexport function CardCtaButton({\n text,\n onClick,\n theme = \"yellow\",\n fullWidth = false,\n className = \"\",\n}: CardCtaButtonProps) {\n return (\n \n {text}\n \n );\n}\n", "type": "registry:block" } ], "type": "registry:block" }