{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "club-card", "title": "Club Card", "description": "Club 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/club-card/index.ts", "content": "export { ClubCard } from \"./index.tsx\"\nexport type { ClubCardProps, CtaTheme, DrawerDirection } from \"./types\"\n", "type": "registry:block" }, { "path": "registry/components/club-card/index.tsx", "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ClubCardPreview } from \"./club-card-preview\"\nimport { ClubCardDrawer } from \"./club-card-drawer\"\nimport type { ClubCardProps, DrawerDirection } 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 ClubCard({\n domain,\n name,\n imageUrl,\n miniDescription,\n shouldCompress = false,\n showSuccessIcon = false,\n ctaText,\n cardCtaText,\n drawerCtaText,\n onCtaClick,\n onDrawerOpen,\n onDrawerCtaClick,\n totalMembers,\n detailPoints,\n detailDescription,\n drawerDirection = \"auto\",\n ctaTheme,\n drawerBottomInsetClassName,\n drawerBodyClassName,\n drawerPinFooter = true,\n drawerFooterClassName,\n className,\n}: ClubCardProps) {\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 { ClubCardProps, DrawerDirection, CtaTheme } from \"./types\"\n", "type": "registry:block" }, { "path": "registry/components/club-card/club-card-preview.tsx", "content": "import { cn } from \"@/lib/utils\";\n\nimport type { ClubCardProps } from \"./types\";\nimport { CardCtaButton } from \"../shared/card-cta-button\";\nimport { CheckCircle } from \"@phosphor-icons/react\";\nimport { toRichPreviewText } from \"./rich-content\";\n\nfunction toCapitalizedWords(value: string) {\n return value.replace(/\\b\\w/g, (char) => char.toUpperCase());\n}\n\ntype ClubCardPreviewProps = Pick<\n ClubCardProps,\n | \"domain\"\n | \"name\"\n | \"imageUrl\"\n | \"miniDescription\"\n | \"shouldCompress\"\n | \"showSuccessIcon\"\n | \"ctaText\"\n | \"ctaTheme\"\n | \"className\"\n> & {\n onCtaClick: () => void;\n};\n\nexport function ClubCardPreview({\n domain,\n name,\n imageUrl,\n miniDescription,\n shouldCompress,\n showSuccessIcon,\n ctaText,\n ctaTheme,\n onCtaClick,\n className,\n}: ClubCardPreviewProps) {\n const resolvedName = toCapitalizedWords(name);\n const resolvedDomain = toCapitalizedWords(domain);\n const resolvedMiniDescription = toRichPreviewText(miniDescription);\n const resolvedCtaText = toCapitalizedWords(ctaText);\n\n const iconBlock = (\n
\n
\n \n
\n {showSuccessIcon ? (\n \n \n \n ) : null}\n
\n );\n\n if (shouldCompress) {\n return (\n \n
\n {iconBlock}\n
\n

\n {resolvedName}\n

\n

\n {resolvedDomain}\n

\n
\n
\n \n );\n }\n\n return (\n \n
\n
{iconBlock}
\n

\n {resolvedDomain}\n

\n
\n
\n

\n {resolvedName}\n

\n

\n {resolvedMiniDescription}\n

\n
\n
\n \n
\n \n );\n}\n", "type": "registry:block" }, { "path": "registry/components/club-card/club-card-drawer.tsx", "content": "\"use client\";\n\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport { Users } from \"@phosphor-icons/react\";\nimport { X } from \"lucide-react\";\nimport { CardCtaButton } from \"../shared/card-cta-button\";\nimport type { ClubCardProps, DrawerDirection } from \"./types\";\nimport { RichContent } from \"./rich-content\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction toCapitalizedWords(value: string) {\n return value.replace(/\\b\\w/g, (char) => char.toUpperCase());\n}\n\n/** Positive member count, or `null` to hide the badge (0, N/A, invalid). */\nfunction parsePositiveMemberCount(value: number | string): number | null {\n if (typeof value === \"number\") {\n if (!Number.isFinite(value) || value <= 0) return null;\n return Math.floor(value);\n }\n const trimmed = value.trim();\n if (!trimmed || /^n\\/?a$/i.test(trimmed)) return null;\n const parsed = Number.parseInt(trimmed.replace(/,/g, \"\"), 10);\n if (Number.isNaN(parsed) || parsed <= 0) return null;\n return parsed;\n}\n\ntype ClubCardDrawerProps = Pick<\n ClubCardProps,\n | \"domain\"\n | \"name\"\n | \"imageUrl\"\n | \"shouldCompress\"\n | \"totalMembers\"\n | \"detailPoints\"\n | \"detailDescription\"\n | \"ctaText\"\n | \"ctaTheme\"\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 ClubCardDrawer({\n domain,\n name,\n imageUrl,\n shouldCompress = false,\n totalMembers,\n detailPoints,\n detailDescription,\n ctaText,\n ctaTheme,\n onCtaClick,\n drawerBottomInsetClassName,\n drawerBodyClassName,\n drawerPinFooter = true,\n drawerFooterClassName,\n open,\n onOpenChange,\n resolvedDirection,\n}: ClubCardDrawerProps) {\n const resolvedDomain = toCapitalizedWords(domain);\n const resolvedName = toCapitalizedWords(name);\n const resolvedCtaText = toCapitalizedWords(ctaText);\n const memberCount = parsePositiveMemberCount(totalMembers);\n\n const footerCta = (\n \n \n \n );\n\n return (\n \n \n \n \n
\n
\n

\n {resolvedDomain}\n

\n \n About the Club\n \n
\n \n \n Close\n \n
\n\n
\n
\n \n
\n \n

\n {resolvedName}\n

\n \n {memberCount !== null ? (\n
\n \n

\n {memberCount}{\" \"}\n {memberCount === 1 ? \"member\" : \"members\"}\n

\n
\n ) : null}\n\n {detailPoints.length > 0 ? (\n
\n

\n What you’ll do here\n

\n
    \n {detailPoints.map((point, index) => (\n
  • \n {toCapitalizedWords(point)}\n
  • \n ))}\n
\n
\n ) : null}\n\n {!drawerPinFooter && !shouldCompress ? footerCta : null}\n
\n \n {drawerPinFooter && !shouldCompress ? footerCta : null}\n \n
\n
\n );\n}\n", "type": "registry:block" }, { "path": "registry/components/club-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/club-card/types.ts", "content": "export type DrawerDirection = \"right\" | \"bottom\" | \"auto\"\nexport type CtaTheme = \"yellow\" | \"red\"\n\nexport type ClubCardProps = {\n domain: string\n name: string\n imageUrl: string\n miniDescription: string\n shouldCompress?: boolean\n showSuccessIcon?: boolean\n ctaText: string\n cardCtaText?: string\n drawerCtaText?: string\n onCtaClick?: () => void\n onDrawerOpen?: () => void\n onDrawerCtaClick?: () => void\n totalMembers: number | string\n detailPoints: string[]\n detailDescription: string\n drawerDirection?: DrawerDirection\n ctaTheme: CtaTheme\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 club details area. */\n drawerBodyClassName?: string\n /**\n * When `true` (default), the CTA sits in a fixed footer below the scroll area.\n * When `false`, the CTA scrolls with the club details.\n */\n drawerPinFooter?: boolean\n /** Merged onto the footer row when `drawerPinFooter` is true. */\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" }