Skip to content

Commit f952f43

Browse files
authored
feat(registry): add Product Hunt button component (#6)
* docs(registry): update install commands to use jalco/ namespace Now an official shadcn registry. Install commands change from: npx shadcn@latest add https://ui.justinlevine.me/r/code-line.json to: npx shadcn@latest add jalco/code-line Updated: install-command component, component-docs-page, prompts, installation page, ai-copy-button docs, and README. * feat(registry): add Product Hunt button component - Async server component showing upvote count with official PH icon - Two layouts: inline button and expanded card - Seven visual variants including branded Product Hunt orange - Three icon styles: currentColor (evenodd cutout), brand, muted - Fetches PH GraphQL API with ISR caching, or accepts pre-fetched data - Docs page with playground, variant grid, API reference - Card preview, screenshots, sidebar nav entry * feat(docs): add Product Hunt button to site header * fix(registry): use official PH upvote arrow, null guard, docs token guide, move to Open Source nav - Replace geometric triangle with official PH rounded upvote SVG - Return null when no post data and no pre-fetched props - Add 'Getting a Product Hunt Token' section with step-by-step instructions - Move nav entry from Marketing to Open Source group * fix(docs): shorten sidebar title to prevent text wrap --------- Co-authored-by: Justin Levine <20596508+justinlevinedotme@users.noreply.github.com>
1 parent 0b5aced commit f952f43

10 files changed

Lines changed: 1082 additions & 0 deletions

File tree

app/docs/components/producthunt-button/page.tsx

Lines changed: 500 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"use client"
2+
3+
import { cn } from "@/lib/utils"
4+
import {
5+
producthuntButtonVariants,
6+
} from "@/registry/producthunt-button/producthunt-button"
7+
import { formatCount } from "@/registry/producthunt-button/lib/producthunt"
8+
import {
9+
ComponentPlayground,
10+
type PlaygroundControl,
11+
} from "@/components/docs/component-playground"
12+
13+
const controls: PlaygroundControl[] = [
14+
{
15+
name: "variant",
16+
type: "select",
17+
options: ["default", "producthunt", "primary", "secondary", "outline", "ghost", "subtle"],
18+
default: "default",
19+
},
20+
{
21+
name: "size",
22+
type: "select",
23+
options: ["sm", "default", "lg"],
24+
default: "default",
25+
},
26+
{
27+
name: "showName",
28+
type: "boolean",
29+
label: "showName",
30+
default: false,
31+
},
32+
{
33+
name: "iconStyle",
34+
type: "select",
35+
label: "iconStyle",
36+
options: ["currentColor", "brand", "muted"],
37+
default: "currentColor",
38+
},
39+
]
40+
41+
type IconStyle = "currentColor" | "brand" | "muted"
42+
43+
function ProductHuntIcon({
44+
iconStyle = "currentColor",
45+
className,
46+
}: {
47+
iconStyle?: IconStyle
48+
className?: string
49+
}) {
50+
if (iconStyle === "brand") {
51+
return (
52+
<svg
53+
viewBox="0 0 26.245 26.256"
54+
aria-hidden="true"
55+
className={className}
56+
>
57+
<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" />
58+
<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" />
59+
</svg>
60+
)
61+
}
62+
63+
return (
64+
<svg
65+
viewBox="0 0 26.245 26.256"
66+
aria-hidden="true"
67+
className={cn(className, iconStyle === "muted" && "opacity-50 grayscale")}
68+
fill="currentColor"
69+
>
70+
<path
71+
fillRule="evenodd"
72+
clipRule="evenodd"
73+
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"
74+
/>
75+
</svg>
76+
)
77+
}
78+
79+
function UpvoteIcon({ className }: { className?: string }) {
80+
return (
81+
<svg
82+
viewBox="0 0 16 16"
83+
aria-hidden="true"
84+
fill="currentColor"
85+
className={className}
86+
>
87+
<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" />
88+
</svg>
89+
)
90+
}
91+
92+
function PreviewButton({
93+
upvotes,
94+
variant,
95+
size,
96+
showName,
97+
iconStyle,
98+
}: {
99+
upvotes: number
100+
variant: string
101+
size: string
102+
showName: boolean
103+
iconStyle: IconStyle
104+
}) {
105+
const name = "Notion"
106+
107+
return (
108+
<a
109+
href="https://www.producthunt.com/posts/notion"
110+
target="_blank"
111+
rel="noopener noreferrer"
112+
aria-label={`${name} on Product Hunt — ${upvotes.toLocaleString("en-US")} upvotes`}
113+
className={cn(
114+
producthuntButtonVariants({
115+
variant: variant as "default",
116+
size: size as "default",
117+
})
118+
)}
119+
>
120+
<ProductHuntIcon iconStyle={iconStyle} className="shrink-0" />
121+
{showName && (
122+
<span className="max-w-[12rem] truncate">{name}</span>
123+
)}
124+
{upvotes !== null && (
125+
<>
126+
{showName && (
127+
<span className="h-3.5 w-px shrink-0 bg-border" aria-hidden="true" />
128+
)}
129+
<span className="inline-flex items-center gap-1 tabular-nums">
130+
<UpvoteIcon className="size-2.5 opacity-60" />
131+
{formatCount(upvotes)}
132+
</span>
133+
</>
134+
)}
135+
</a>
136+
)
137+
}
138+
139+
export function ProductHuntButtonPlayground({ upvotes }: { upvotes: number }) {
140+
return (
141+
<ComponentPlayground
142+
componentName="ProductHuntButton"
143+
importPath="@/components/producthunt-button"
144+
staticProps={{ slug: "notion", upvotes, name: "Notion" }}
145+
hideFromCode={["upvotes", "name"]}
146+
controls={controls}
147+
render={(props) => (
148+
<PreviewButton
149+
upvotes={upvotes}
150+
variant={(props.variant as string) ?? "default"}
151+
size={(props.size as string) ?? "default"}
152+
showName={(props.showName as boolean) ?? false}
153+
iconStyle={(props.iconStyle as IconStyle) ?? "currentColor"}
154+
/>
155+
)}
156+
/>
157+
)
158+
}

app/docs/layout.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { MobileNav } from "@/components/docs/mobile-nav"
55
import { ThemeSwitcher } from "@/components/docs/theme-switcher"
66
import { JalcoLogo } from "@/components/icons/jalco-logo"
77
import { GitHubStarsButton } from "@/registry/github-stars-button/github-stars-button"
8+
import { ProductHuntButton } from "@/registry/producthunt-button/producthunt-button"
89

910
export default function DocsLayout({ children }: { children: ReactNode }) {
1011
return (
@@ -36,6 +37,12 @@ export default function DocsLayout({ children }: { children: ReactNode }) {
3637
</nav>
3738

3839
<div className="ml-auto flex items-center gap-1.5">
40+
<ProductHuntButton
41+
slug="jalco-ui"
42+
variant="producthunt"
43+
size="sm"
44+
iconStyle="brand"
45+
/>
3946
<GitHubStarsButton
4047
owner="jal-co"
4148
repo="ui"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { ProductHuntButton } from "@/registry/producthunt-button/producthunt-button"
2+
3+
export default async function Preview() {
4+
return (
5+
<div className="flex flex-col items-center gap-6">
6+
<div className="flex flex-wrap items-center justify-center gap-3">
7+
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="default" />
8+
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="producthunt" />
9+
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="primary" />
10+
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="outline" />
11+
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="ghost" />
12+
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="subtle" />
13+
</div>
14+
<div className="flex flex-wrap items-center justify-center gap-3">
15+
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="producthunt" showName iconStyle="brand" />
16+
<ProductHuntButton slug="notion" upvotes={12843} name="Notion" variant="outline" showName />
17+
</div>
18+
<ProductHuntButton
19+
slug="notion"
20+
upvotes={12843}
21+
name="Notion"
22+
tagline="The all-in-one workspace for notes, tasks, and wikis"
23+
layout="card"
24+
className="w-full max-w-xs"
25+
/>
26+
</div>
27+
)
28+
}

lib/docs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export const docsNav: NavGroup[] = [
7171
href: "/docs/components/github-button-group",
7272
},
7373
{ title: "npm Badge", href: "/docs/components/npm-badge", badge: "New", badgeAdded: "2026-03-12" },
74+
{ title: "Product Hunt", href: "/docs/components/producthunt-button", badge: "New", badgeAdded: "2026-03-12" },
7475
],
7576
},
7677
{
168 KB
Loading
176 KB
Loading

registry.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,29 @@
358358
}
359359
]
360360
},
361+
{
362+
"name": "producthunt-button",
363+
"type": "registry:component",
364+
"title": "Product Hunt Button",
365+
"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.",
366+
"dependencies": [
367+
"class-variance-authority"
368+
],
369+
"categories": [
370+
"marketing",
371+
"launch"
372+
],
373+
"files": [
374+
{
375+
"path": "registry/producthunt-button/producthunt-button.tsx",
376+
"type": "registry:component"
377+
},
378+
{
379+
"path": "registry/producthunt-button/lib/producthunt.ts",
380+
"type": "registry:lib"
381+
}
382+
]
383+
},
361384
{
362385
"name": "npm-badge",
363386
"type": "registry:component",
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* jalco-ui
3+
* lib/producthunt
4+
* by Justin Levine
5+
* ui.justinlevine.me
6+
*
7+
* Product Hunt API client for fetching post metadata (upvotes, name, tagline).
8+
*/
9+
10+
export interface ProductHuntPost {
11+
/** Post display name. */
12+
name: string
13+
/** Short tagline describing the product. */
14+
tagline: string
15+
/** Number of upvotes. */
16+
upvotes: number
17+
/** Product Hunt URL for the post. */
18+
url: string
19+
/** URL slug (e.g. "my-awesome-product"). */
20+
slug: string
21+
}
22+
23+
/**
24+
* Fetch public metadata for a Product Hunt post.
25+
*
26+
* - Uses the Product Hunt GraphQL API v2.
27+
* - Requires `process.env.PRODUCTHUNT_TOKEN` (developer token).
28+
* - Caches the result for 1 hour via Next.js ISR (`next.revalidate`).
29+
*
30+
* Get a token at https://www.producthunt.com/v2/oauth/applications
31+
*
32+
* Returns `null` if the request fails, the token is missing, or the post
33+
* doesn't exist.
34+
*/
35+
export async function fetchProductHuntPost(
36+
slug: string
37+
): Promise<ProductHuntPost | null> {
38+
const token = process.env.PRODUCTHUNT_TOKEN
39+
if (!token) return null
40+
41+
try {
42+
const response = await fetch("https://api.producthunt.com/v2/api/graphql", {
43+
method: "POST",
44+
headers: {
45+
"Content-Type": "application/json",
46+
Authorization: `Bearer ${token}`,
47+
},
48+
body: JSON.stringify({
49+
query: `
50+
query GetPost($slug: String!) {
51+
post(slug: $slug) {
52+
name
53+
tagline
54+
votesCount
55+
url
56+
slug
57+
}
58+
}
59+
`,
60+
variables: { slug },
61+
}),
62+
next: { revalidate: 3600 },
63+
})
64+
65+
if (!response.ok) return null
66+
67+
const json = await response.json()
68+
const post = json?.data?.post
69+
if (!post || typeof post.name !== "string") return null
70+
71+
return {
72+
name: post.name,
73+
tagline: post.tagline ?? "",
74+
upvotes: post.votesCount ?? 0,
75+
url: post.url ?? `https://www.producthunt.com/posts/${slug}`,
76+
slug: post.slug ?? slug,
77+
}
78+
} catch {
79+
return null
80+
}
81+
}
82+
83+
/**
84+
* Format a number for compact display.
85+
*
86+
* - `1500` → `"1.5k"`
87+
* - `236000` → `"236k"`
88+
* - `842` → `"842"`
89+
*/
90+
export function formatCount(count: number): string {
91+
if (count >= 1_000_000) {
92+
const value = count / 1_000_000
93+
return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}m`
94+
}
95+
if (count >= 1_000) {
96+
const value = count / 1_000
97+
return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}k`
98+
}
99+
return count.toLocaleString("en-US")
100+
}

0 commit comments

Comments
 (0)