Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
500 changes: 500 additions & 0 deletions app/docs/components/producthunt-button/page.tsx

Large diffs are not rendered by default.

158 changes: 158 additions & 0 deletions app/docs/components/producthunt-button/playground.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
viewBox="0 0 26.245 26.256"
aria-hidden="true"
className={className}
>
<path d="M26.254 13.128c0 7.253-5.875 13.128-13.128 13.128S-.003 20.382-.003 13.128 5.872 0 13.125 0s13.128 5.875 13.128 13.128" fill="#DA552F" />
<path d="M14.876 13.128h-3.72V9.2h3.72c1.083 0 1.97.886 1.97 1.97s-.886 1.97-1.97 1.97m0-6.564H8.53v13.128h2.626v-3.938h3.72c2.538 0 4.595-2.057 4.595-4.595s-2.057-4.595-4.595-4.595" fill="#fff" />
</svg>
)
}

return (
<svg
viewBox="0 0 26.245 26.256"
aria-hidden="true"
className={cn(className, iconStyle === "muted" && "opacity-50 grayscale")}
fill="currentColor"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M26.254 13.128c0 7.253-5.875 13.128-13.128 13.128S-.003 20.382-.003 13.128 5.872 0 13.125 0s13.128 5.875 13.128 13.128ZM14.876 6.564H8.53v13.128h2.626v-3.938h3.72c2.538 0 4.595-2.057 4.595-4.595s-2.057-4.595-4.595-4.595Zm0 6.564h-3.72V9.2h3.72c1.083 0 1.97.886 1.97 1.97s-.886 1.97-1.97 1.97Z"
/>
</svg>
)
}

function UpvoteIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
fill="currentColor"
className={className}
>
<path d="M6.579 3.467c.71-1.067 2.132-1.067 2.842 0L12.975 8.8c.878 1.318.043 3.2-1.422 3.2H4.447c-1.464 0-2.3-1.882-1.422-3.2z" />
</svg>
)
}

function PreviewButton({
upvotes,
variant,
size,
showName,
iconStyle,
}: {
upvotes: number
variant: string
size: string
showName: boolean
iconStyle: IconStyle
}) {
const name = "Notion"

return (
<a
href="https://www.producthunt.com/posts/notion"
target="_blank"
rel="noopener noreferrer"
aria-label={`${name} on Product Hunt — ${upvotes.toLocaleString("en-US")} upvotes`}
className={cn(
producthuntButtonVariants({
variant: variant as "default",
size: size as "default",
})
)}
>
<ProductHuntIcon iconStyle={iconStyle} className="shrink-0" />
{showName && (
<span className="max-w-[12rem] truncate">{name}</span>
)}
{upvotes !== null && (
<>
{showName && (
<span className="h-3.5 w-px shrink-0 bg-border" aria-hidden="true" />
)}
<span className="inline-flex items-center gap-1 tabular-nums">
<UpvoteIcon className="size-2.5 opacity-60" />
{formatCount(upvotes)}
</span>
</>
)}
</a>
)
}

export function ProductHuntButtonPlayground({ upvotes }: { upvotes: number }) {
return (
<ComponentPlayground
componentName="ProductHuntButton"
importPath="@/components/producthunt-button"
staticProps={{ slug: "notion", upvotes, name: "Notion" }}
hideFromCode={["upvotes", "name"]}
controls={controls}
render={(props) => (
<PreviewButton
upvotes={upvotes}
variant={(props.variant as string) ?? "default"}
size={(props.size as string) ?? "default"}
showName={(props.showName as boolean) ?? false}
iconStyle={(props.iconStyle as IconStyle) ?? "currentColor"}
/>
)}
/>
)
}
7 changes: 7 additions & 0 deletions app/docs/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -36,6 +37,12 @@ export default function DocsLayout({ children }: { children: ReactNode }) {
</nav>

<div className="ml-auto flex items-center gap-1.5">
<ProductHuntButton
slug="jalco-ui"
variant="producthunt"
size="sm"
iconStyle="brand"
/>
<GitHubStarsButton
owner="jal-co"
repo="ui"
Expand Down
28 changes: 28 additions & 0 deletions components/docs/previews/producthunt-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ProductHuntButton } from "@/registry/producthunt-button/producthunt-button"

export default async function Preview() {
return (
<div className="flex flex-col items-center gap-6">
<div className="flex flex-wrap items-center justify-center gap-3">
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="default" />
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="producthunt" />
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="primary" />
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="outline" />
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="ghost" />
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="subtle" />
</div>
<div className="flex flex-wrap items-center justify-center gap-3">
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="producthunt" showName iconStyle="brand" />
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="outline" showName />
</div>
<ProductHuntButton
slug="notion"
upvotes={12843}
name="Notion"
tagline="The all-in-one workspace for notes, tasks, and wikis"
layout="card"
className="w-full max-w-xs"
/>
</div>
)
}
1 change: 1 addition & 0 deletions lib/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
},
{
Expand Down
Binary file added public/previews/producthunt-button-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/previews/producthunt-button-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
100 changes: 100 additions & 0 deletions registry/producthunt-button/lib/producthunt.ts
Original file line number Diff line number Diff line change
@@ -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<ProductHuntPost | null> {
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")
}
Loading
Loading