diff --git a/app/docs/components/producthunt-button/page.tsx b/app/docs/components/producthunt-button/page.tsx new file mode 100644 index 0000000..c30559f --- /dev/null +++ b/app/docs/components/producthunt-button/page.tsx @@ -0,0 +1,500 @@ +import type { Metadata } from "next" +import { ProductHuntButton } from "@/registry/producthunt-button/producthunt-button" +import { ApiRefTable } from "@/registry/api-ref-table/api-ref-table" +import { ComponentDocsPage } from "@/components/docs/component-docs-page" +import { VariantGrid } from "@/components/docs/variant-grid" +import { ProductHuntButtonPlayground } from "./playground" +import { CodeLine } from "@/registry/code-line/code-line" + +export const metadata: Metadata = { + title: "Product Hunt Button", + description: + "Link button showing a Product Hunt post's upvote count with the PH cat icon.", +} + +const sourceFiles = [ + "registry/producthunt-button/producthunt-button.tsx", + "registry/producthunt-button/lib/producthunt.ts", +] + +const SAMPLE_UPVOTES = 12843 +const SAMPLE_NAME = "Notion" +const SAMPLE_SLUG = "notion" +const SAMPLE_TAGLINE = "The all-in-one workspace for notes, tasks, and wikis" + +export default async function ProductHuntButtonPage() { + return ( + + } + usage={ + <> + + `} + /> +

+ Async server component. Fetches the Product Hunt + GraphQL API at build time and caches the result for 1 hour via + Next.js ISR. Requires{" "} + + PRODUCTHUNT_TOKEN + {" "} + — get one at{" "} + + producthunt.com/v2/oauth/applications + + . Alternatively, pass pre-fetched data via{" "} + + upvotes + {" "} + and{" "} + name{" "} + props to skip the API call entirely. +

+ + } + > + {/* Playground */} +
+

Playground

+ +
+ + {/* Examples */} +
+

Examples

+ +
+

Variants

+ `, + preview: ( + + ), + }, + { + label: "Product Hunt", + code: ``, + preview: ( + + ), + }, + { + label: "Primary", + code: ``, + preview: ( + + ), + }, + { + label: "Secondary", + code: ``, + preview: ( + + ), + }, + { + label: "Outline", + code: ``, + preview: ( + + ), + }, + { + label: "Ghost", + code: ``, + preview: ( + + ), + }, + { + label: "Subtle", + code: ``, + preview: ( + + ), + }, + ]} + /> +
+ +
+

Sizes

+ `, + preview: ( + + ), + }, + { + label: "Default", + code: ``, + preview: ( + + ), + }, + { + label: "Large", + code: ``, + preview: ( + + ), + }, + ]} + /> +
+ +
+

With product name

+ `, + preview: ( + + ), + }, + { + label: "Product Hunt + Name", + code: ``, + preview: ( + + ), + }, + { + label: "Outline + Name + Brand", + code: ``, + preview: ( + + ), + }, + ]} + /> +
+ +
+

Icon styles

+ `, + preview: ( + + ), + }, + { + label: "Brand Orange", + code: ``, + preview: ( + + ), + }, + { + label: "Muted", + code: ``, + preview: ( + + ), + }, + ]} + /> +
+ +
+

Card layout

+ `, + preview: ( + + ), + }, + { + label: "Card (no tagline)", + code: ``, + preview: ( + + ), + }, + ]} + /> +
+
+ + {/* API Reference */} +
+

API Reference

+ +
+ + {/* Getting a Token */} +
+

+ Getting a Product Hunt Token +

+

+ The component fetches live data from the Product Hunt GraphQL API. + To enable this, you need a developer token. +

+
    +
  1. + Go to the{" "} + + Product Hunt API Dashboard + + . +
  2. +
  3. + Click Add an Application. + Enter any name and redirect URI (e.g. your site URL). +
  4. +
  5. + After creating the app, scroll down to the{" "} + Developer Token section. + Copy the token value. +
  6. +
  7. + Add it to your environment: +
  8. +
+ +

+ The developer token never expires and requires no OAuth flow. No + API key or secret exchange needed — the token from the dashboard + works directly as a bearer token. +

+
+ + {/* Notes */} +
+

Notes

+
    +
  • + No token? No problem.{" "} + Pass{" "} + + upvotes + {" "} + and{" "} + name{" "} + props to skip the API call entirely — useful for static sites or + when you already have the data. +
  • +
  • + ISR caching. Results + cached for 1 hour via{" "} + + next.revalidate + + . +
  • +
  • + Graceful fallback.{" "} + Returns nothing when the post doesn't exist or the token is + missing — no broken UI. +
  • +
+
+
+ ) +} diff --git a/app/docs/components/producthunt-button/playground.tsx b/app/docs/components/producthunt-button/playground.tsx new file mode 100644 index 0000000..db1d17d --- /dev/null +++ b/app/docs/components/producthunt-button/playground.tsx @@ -0,0 +1,158 @@ +"use client" + +import { cn } from "@/lib/utils" +import { + producthuntButtonVariants, +} from "@/registry/producthunt-button/producthunt-button" +import { formatCount } from "@/registry/producthunt-button/lib/producthunt" +import { + ComponentPlayground, + type PlaygroundControl, +} from "@/components/docs/component-playground" + +const controls: PlaygroundControl[] = [ + { + name: "variant", + type: "select", + options: ["default", "producthunt", "primary", "secondary", "outline", "ghost", "subtle"], + default: "default", + }, + { + name: "size", + type: "select", + options: ["sm", "default", "lg"], + default: "default", + }, + { + name: "showName", + type: "boolean", + label: "showName", + default: false, + }, + { + name: "iconStyle", + type: "select", + label: "iconStyle", + options: ["currentColor", "brand", "muted"], + default: "currentColor", + }, +] + +type IconStyle = "currentColor" | "brand" | "muted" + +function ProductHuntIcon({ + iconStyle = "currentColor", + className, +}: { + iconStyle?: IconStyle + className?: string +}) { + if (iconStyle === "brand") { + return ( + + ) + } + + return ( + + ) +} + +function UpvoteIcon({ className }: { className?: string }) { + return ( + + ) +} + +function PreviewButton({ + upvotes, + variant, + size, + showName, + iconStyle, +}: { + upvotes: number + variant: string + size: string + showName: boolean + iconStyle: IconStyle +}) { + const name = "Notion" + + return ( + + + {showName && ( + {name} + )} + {upvotes !== null && ( + <> + {showName && ( + + ) +} + +export function ProductHuntButtonPlayground({ upvotes }: { upvotes: number }) { + return ( + ( + + )} + /> + ) +} diff --git a/app/docs/layout.tsx b/app/docs/layout.tsx index 5592cba..425f6cd 100644 --- a/app/docs/layout.tsx +++ b/app/docs/layout.tsx @@ -5,6 +5,7 @@ import { MobileNav } from "@/components/docs/mobile-nav" import { ThemeSwitcher } from "@/components/docs/theme-switcher" import { JalcoLogo } from "@/components/icons/jalco-logo" import { GitHubStarsButton } from "@/registry/github-stars-button/github-stars-button" +import { ProductHuntButton } from "@/registry/producthunt-button/producthunt-button" export default function DocsLayout({ children }: { children: ReactNode }) { return ( @@ -36,6 +37,12 @@ export default function DocsLayout({ children }: { children: ReactNode }) {
+ +
+ + + + + + +
+
+ + +
+ +
+ ) +} diff --git a/lib/docs.ts b/lib/docs.ts index 66ec571..7606f84 100644 --- a/lib/docs.ts +++ b/lib/docs.ts @@ -71,6 +71,7 @@ export const docsNav: NavGroup[] = [ href: "/docs/components/github-button-group", }, { title: "npm Badge", href: "/docs/components/npm-badge", badge: "New", badgeAdded: "2026-03-12" }, + { title: "Product Hunt", href: "/docs/components/producthunt-button", badge: "New", badgeAdded: "2026-03-12" }, ], }, { diff --git a/public/previews/producthunt-button-dark.png b/public/previews/producthunt-button-dark.png new file mode 100644 index 0000000..a3b81da Binary files /dev/null and b/public/previews/producthunt-button-dark.png differ diff --git a/public/previews/producthunt-button-light.png b/public/previews/producthunt-button-light.png new file mode 100644 index 0000000..f526a00 Binary files /dev/null and b/public/previews/producthunt-button-light.png differ diff --git a/registry.json b/registry.json index 68c0767..4248f8b 100644 --- a/registry.json +++ b/registry.json @@ -358,6 +358,29 @@ } ] }, + { + "name": "producthunt-button", + "type": "registry:component", + "title": "Product Hunt Button", + "description": "Link button showing a Product Hunt post's upvote count with the PH cat icon. Two layouts: inline button and expanded card. Async server component — fetches data at build time with ISR.", + "dependencies": [ + "class-variance-authority" + ], + "categories": [ + "marketing", + "launch" + ], + "files": [ + { + "path": "registry/producthunt-button/producthunt-button.tsx", + "type": "registry:component" + }, + { + "path": "registry/producthunt-button/lib/producthunt.ts", + "type": "registry:lib" + } + ] + }, { "name": "npm-badge", "type": "registry:component", diff --git a/registry/producthunt-button/lib/producthunt.ts b/registry/producthunt-button/lib/producthunt.ts new file mode 100644 index 0000000..7b0da42 --- /dev/null +++ b/registry/producthunt-button/lib/producthunt.ts @@ -0,0 +1,100 @@ +/** + * jalco-ui + * lib/producthunt + * by Justin Levine + * ui.justinlevine.me + * + * Product Hunt API client for fetching post metadata (upvotes, name, tagline). + */ + +export interface ProductHuntPost { + /** Post display name. */ + name: string + /** Short tagline describing the product. */ + tagline: string + /** Number of upvotes. */ + upvotes: number + /** Product Hunt URL for the post. */ + url: string + /** URL slug (e.g. "my-awesome-product"). */ + slug: string +} + +/** + * Fetch public metadata for a Product Hunt post. + * + * - Uses the Product Hunt GraphQL API v2. + * - Requires `process.env.PRODUCTHUNT_TOKEN` (developer token). + * - Caches the result for 1 hour via Next.js ISR (`next.revalidate`). + * + * Get a token at https://www.producthunt.com/v2/oauth/applications + * + * Returns `null` if the request fails, the token is missing, or the post + * doesn't exist. + */ +export async function fetchProductHuntPost( + slug: string +): Promise { + const token = process.env.PRODUCTHUNT_TOKEN + if (!token) return null + + try { + const response = await fetch("https://api.producthunt.com/v2/api/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: ` + query GetPost($slug: String!) { + post(slug: $slug) { + name + tagline + votesCount + url + slug + } + } + `, + variables: { slug }, + }), + next: { revalidate: 3600 }, + }) + + if (!response.ok) return null + + const json = await response.json() + const post = json?.data?.post + if (!post || typeof post.name !== "string") return null + + return { + name: post.name, + tagline: post.tagline ?? "", + upvotes: post.votesCount ?? 0, + url: post.url ?? `https://www.producthunt.com/posts/${slug}`, + slug: post.slug ?? slug, + } + } catch { + return null + } +} + +/** + * Format a number for compact display. + * + * - `1500` → `"1.5k"` + * - `236000` → `"236k"` + * - `842` → `"842"` + */ +export function formatCount(count: number): string { + if (count >= 1_000_000) { + const value = count / 1_000_000 + return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}m` + } + if (count >= 1_000) { + const value = count / 1_000 + return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}k` + } + return count.toLocaleString("en-US") +} diff --git a/registry/producthunt-button/producthunt-button.tsx b/registry/producthunt-button/producthunt-button.tsx new file mode 100644 index 0000000..458c713 --- /dev/null +++ b/registry/producthunt-button/producthunt-button.tsx @@ -0,0 +1,265 @@ +/** + * jalco-ui + * ProductHuntButton + * by Justin Levine + * ui.justinlevine.me + * + * Link button showing a Product Hunt post's upvote count with the PH logo. + * Supports pre-fetched data or async fetching via the PH GraphQL API. + * + * Props: + * - slug: Product Hunt post slug + * - upvotes?: pre-fetched upvote count (skips API call when provided with name) + * - name?: pre-fetched product name + * - tagline?: pre-fetched product tagline + * - showName?: show product name alongside the upvote count + * - showTagline?: show tagline below the button (card layout only) + * - layout?: "inline" | "card" (default "inline") + * - variant?: visual style variant + * - size?: button size + * - iconStyle?: icon color treatment + * + * Notes: + * - Async server component + * - Requires PRODUCTHUNT_TOKEN env var for API fetching + * - Works without token when upvotes/name are pre-fetched + */ + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" +import { + fetchProductHuntPost, + formatCount, + type ProductHuntPost, +} from "@/registry/producthunt-button/lib/producthunt" + +type IconStyle = "currentColor" | "brand" | "muted" + +function ProductHuntIcon({ + iconStyle = "currentColor", + className, +}: { + iconStyle?: IconStyle + className?: string +}) { + if (iconStyle === "brand") { + return ( + + ) + } + + return ( + + ) +} + +function UpvoteIcon({ className }: { className?: string }) { + return ( + + ) +} + +const producthuntButtonVariants = cva( + "inline-flex items-center shrink-0 whitespace-nowrap font-medium transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", + { + variants: { + variant: { + default: + "rounded-md border border-border bg-muted/50 text-muted-foreground shadow-xs hover:bg-accent hover:text-accent-foreground", + primary: + "rounded-md bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + producthunt: + "rounded-md bg-[#DA552F] text-white shadow-xs hover:bg-[#DA552F]/90", + secondary: + "rounded-md border border-transparent bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + outline: + "rounded-md border border-border bg-background text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + ghost: + "rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + subtle: + "rounded-full border border-border/60 bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground", + }, + size: { + sm: "h-7 gap-1.5 px-2.5 text-xs [&_svg]:size-3.5", + default: "h-8 gap-2 px-3 text-sm [&_svg]:size-4", + lg: "h-9 gap-2.5 px-4 text-sm [&_svg]:size-4", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +interface ProductHuntButtonBaseProps + extends Omit, "children">, + VariantProps { + /** Product Hunt post slug (e.g. "my-awesome-product"). */ + slug: string + /** Pre-fetched upvote count. When provided with `name`, skips the API call. */ + upvotes?: number + /** Pre-fetched product name. */ + name?: string + /** Pre-fetched product tagline. */ + tagline?: string + /** Show the product name alongside the upvote count. */ + showName?: boolean + /** + * Icon display style: + * - `"currentColor"` — inherits text color from the button variant (default) + * - `"brand"` — Product Hunt orange (#DA552F) + * - `"muted"` — grayscale with reduced opacity + */ + iconStyle?: IconStyle +} + +interface ProductHuntButtonInlineProps extends ProductHuntButtonBaseProps { + /** @default "inline" */ + layout?: "inline" +} + +interface ProductHuntButtonCardProps extends ProductHuntButtonBaseProps { + layout: "card" + /** Show tagline below the product name in card layout. @default true */ + showTagline?: boolean +} + +type ProductHuntButtonProps = + | ProductHuntButtonInlineProps + | ProductHuntButtonCardProps + +async function ProductHuntButton(props: ProductHuntButtonProps) { + const { + slug, + upvotes: upvotesProp, + name: nameProp, + tagline: taglineProp, + showName = false, + iconStyle = "currentColor", + variant, + size, + layout = "inline", + className, + ...anchorProps + } = props + + const hasPreFetched = upvotesProp != null && nameProp != null + const data = hasPreFetched ? null : await fetchProductHuntPost(slug) + + if (!hasPreFetched && !data) return null + + const upvotes = upvotesProp ?? data?.upvotes ?? null + const name = nameProp ?? data?.name ?? slug + const tagline = taglineProp ?? data?.tagline ?? "" + const url = data?.url ?? `https://www.producthunt.com/posts/${slug}` + + if (layout === "card") { + const showTagline = + (props as ProductHuntButtonCardProps).showTagline !== false + + return ( + + +
+
+ {name} + {upvotes !== null && ( + + + {formatCount(upvotes)} + + )} +
+ {showTagline && tagline && ( +

+ {tagline} +

+ )} +
+
+ ) + } + + return ( + + + {showName && ( + {name} + )} + {upvotes !== null && ( + <> + {showName && ( + + ) +} + +export { + ProductHuntButton, + producthuntButtonVariants, + type ProductHuntButtonProps, + type ProductHuntButtonInlineProps, + type ProductHuntButtonCardProps, + type ProductHuntPost, +}