retail-design-system 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.github/workflows/release.yml +46 -0
  2. package/.oxfmtrc.json +17 -0
  3. package/.oxlintrc.json +132 -0
  4. package/.vscode/extensions.json +3 -0
  5. package/.vscode/settings.json +13 -0
  6. package/README.md +56 -0
  7. package/apps/storybook/.storybook/main.ts +8 -0
  8. package/apps/storybook/.storybook/preview.css +9 -0
  9. package/apps/storybook/.storybook/preview.ts +6 -0
  10. package/apps/storybook/package.json +24 -0
  11. package/apps/storybook/stories/button.stories.ts +118 -0
  12. package/apps/storybook/stories/input.stories.ts +127 -0
  13. package/apps/storybook/stories/label.stories.ts +98 -0
  14. package/apps/storybook/tsconfig.app.json +24 -0
  15. package/apps/storybook/tsconfig.json +4 -0
  16. package/apps/storybook/tsconfig.node.json +22 -0
  17. package/apps/storybook/vite.config.ts +15 -0
  18. package/apps/web/app/(sidebar)/components/[...slugs]/get-child-block.ts +17 -0
  19. package/apps/web/app/(sidebar)/components/[...slugs]/get-component-page-match.ts +56 -0
  20. package/apps/web/app/(sidebar)/components/[...slugs]/get-direct-child-block.ts +22 -0
  21. package/apps/web/app/(sidebar)/components/[...slugs]/layout.tsx +25 -0
  22. package/apps/web/app/(sidebar)/components/[...slugs]/page.tsx +32 -0
  23. package/apps/web/app/(sidebar)/components/[...slugs]/pascal-to-kebab-case.ts +9 -0
  24. package/apps/web/app/(sidebar)/components/button2/page.tsx +154 -0
  25. package/apps/web/app/(sidebar)/components/input/page.tsx +98 -0
  26. package/apps/web/app/(sidebar)/experiments/2025-10-22/mayhem-mode-card-badge.tsx +9 -0
  27. package/apps/web/app/(sidebar)/experiments/2025-10-22/mayhem-mode-coin-active-badge.tsx +14 -0
  28. package/apps/web/app/(sidebar)/experiments/2025-10-22/mayhem-mode-coin-inactive-badge.tsx +12 -0
  29. package/apps/web/app/(sidebar)/experiments/2025-10-22/mayhem-mode-create-coin.tsx +44 -0
  30. package/apps/web/app/(sidebar)/experiments/2025-10-22/mayhem-mode-dialog-icon.tsx +47 -0
  31. package/apps/web/app/(sidebar)/experiments/2025-10-22/page.tsx +167 -0
  32. package/apps/web/app/(sidebar)/experiments/2025-11-04/filters.tsx +90 -0
  33. package/apps/web/app/(sidebar)/experiments/2025-11-04/page.tsx +18 -0
  34. package/apps/web/app/(sidebar)/layout.tsx +17 -0
  35. package/apps/web/app/(sidebar)/primitives/colors/page.tsx +49 -0
  36. package/apps/web/app/favicon.ico +0 -0
  37. package/apps/web/app/layout.tsx +39 -0
  38. package/apps/web/app/page.tsx +14 -0
  39. package/apps/web/app/providers.tsx +15 -0
  40. package/apps/web/components/dialog.tsx +21 -0
  41. package/apps/web/components/logo.tsx +11 -0
  42. package/apps/web/components/logomark.tsx +21 -0
  43. package/apps/web/components/logotype.tsx +25 -0
  44. package/apps/web/components/notion/notion-block-content.tsx +401 -0
  45. package/apps/web/components/notion/notion-docs-blocks.tsx +18 -0
  46. package/apps/web/components/notion/notion-docs-code-page.tsx +20 -0
  47. package/apps/web/components/notion/notion-docs-layout.tsx +52 -0
  48. package/apps/web/components/notion/notion-revalidate-button-client.tsx +14 -0
  49. package/apps/web/components/notion/notion-revalidate-button.tsx +20 -0
  50. package/apps/web/components/notion/notion-rich-text-segments.tsx +55 -0
  51. package/apps/web/components/notion/notion-tabs.tsx +38 -0
  52. package/apps/web/components/notion/notion.ts +223 -0
  53. package/apps/web/components/sidebar-client.tsx +60 -0
  54. package/apps/web/components/sidebar-server.tsx +185 -0
  55. package/apps/web/components/tooltip.tsx +53 -0
  56. package/apps/web/components/topbar.tsx +14 -0
  57. package/apps/web/next.config.ts +10 -0
  58. package/apps/web/package.json +42 -0
  59. package/apps/web/postcss.config.mjs +5 -0
  60. package/apps/web/public/2025-10-22-dialog-banner.png +0 -0
  61. package/apps/web/public/pump-logomark.svg +7 -0
  62. package/apps/web/styles/custom.css +31 -0
  63. package/apps/web/styles/font.css +8 -0
  64. package/apps/web/styles/global.css +5 -0
  65. package/apps/web/styles/tailwind-reset.css +102 -0
  66. package/apps/web/styles/tailwind.css +140 -0
  67. package/apps/web/tsconfig.json +34 -0
  68. package/bun.lock +1249 -0
  69. package/bunfig.toml +2 -0
  70. package/package.json +41 -0
  71. package/packages/ui/global.d.ts +4 -0
  72. package/packages/ui/package.json +49 -0
  73. package/packages/ui/src/components/button/button-spinner.module.css +95 -0
  74. package/packages/ui/src/components/button/button-spinner.tsx +18 -0
  75. package/packages/ui/src/components/button/button.module.css +144 -0
  76. package/packages/ui/src/components/button/button.tsx +102 -0
  77. package/packages/ui/src/components/button-link/button-link.tsx +46 -0
  78. package/packages/ui/src/components/column/column.module.css +4 -0
  79. package/packages/ui/src/components/column/column.tsx +65 -0
  80. package/packages/ui/src/components/row/row.module.css +4 -0
  81. package/packages/ui/src/components/row/row.tsx +65 -0
  82. package/packages/ui/src/components/spacer/spacer.module.css +3 -0
  83. package/packages/ui/src/components/spacer/spacer.tsx +30 -0
  84. package/packages/ui/src/components/switch/switch.module.css +62 -0
  85. package/packages/ui/src/components/switch/switch.tsx +58 -0
  86. package/packages/ui/src/components/tabs/tabs-panel.module.css +4 -0
  87. package/packages/ui/src/components/tabs/tabs-panel.tsx +21 -0
  88. package/packages/ui/src/components/tabs/tabs.module.css +5 -0
  89. package/packages/ui/src/components/tabs/tabs.tsx +21 -0
  90. package/packages/ui/src/components/tabs-underline/tabs-underline-indicator.module.css +10 -0
  91. package/packages/ui/src/components/tabs-underline/tabs-underline-indicator.tsx +33 -0
  92. package/packages/ui/src/components/tabs-underline/tabs-underline-list.module.css +8 -0
  93. package/packages/ui/src/components/tabs-underline/tabs-underline-list.tsx +27 -0
  94. package/packages/ui/src/components/tabs-underline/tabs-underline-tab.module.css +24 -0
  95. package/packages/ui/src/components/tabs-underline/tabs-underline-tab.tsx +30 -0
  96. package/packages/ui/src/foundations/colors/colors.ts +475 -0
  97. package/packages/ui/src/foundations/colors/generate-css.ts +34 -0
  98. package/packages/ui/src/foundations/colors/retail-design-system.css +116 -0
  99. package/packages/ui/src/foundations/colors/tailwind-v3.ts +18 -0
  100. package/packages/ui/src/foundations/colors/tailwind-v4.css +116 -0
  101. package/packages/ui/src/index.ts +34 -0
  102. package/packages/ui/src/input.module.css +57 -0
  103. package/packages/ui/src/input.tsx +49 -0
  104. package/packages/ui/src/label.module.css +8 -0
  105. package/packages/ui/src/label.tsx +23 -0
  106. package/packages/ui/tsconfig.json +14 -0
  107. package/packages/ui/tsup.config.ts +31 -0
  108. package/scripts/clean.sh +69 -0
  109. package/scripts/sort-package-json.sh +30 -0
  110. package/turbo.json +15 -0
@@ -0,0 +1,401 @@
1
+ import { evaluate } from "@mdx-js/mdx"
2
+ import { IconPlusLarge } from "@pump-fun/icons-filled"
3
+ import {
4
+ Button,
5
+ ButtonLink,
6
+ Column,
7
+ Input,
8
+ Label,
9
+ Row,
10
+ Spacer,
11
+ Tabs,
12
+ TabsUnderlineList,
13
+ TabsUnderlineTab,
14
+ TabsPanel,
15
+ Switch,
16
+ } from "@pump-fun/retail-design-system"
17
+ import Link from "next/link"
18
+ import type { ElementType, JSX } from "react"
19
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime"
20
+ import { highlight } from "sugar-high"
21
+ import type { NotionBlock, NotionRichTextSegment } from "@/components/notion/notion"
22
+ import { NotionRichTextSegments } from "@/components/notion/notion-rich-text-segments"
23
+
24
+ interface NotionBlockContentProps {
25
+ block: NotionBlock
26
+ blockIndex: number
27
+ }
28
+
29
+ interface RenderHeadingProps {
30
+ blockIndex: number
31
+ className: string
32
+ level: "h1" | "h2" | "h3"
33
+ segments: NotionRichTextSegment[]
34
+ }
35
+
36
+ export async function NotionBlockContent(props: NotionBlockContentProps): Promise<JSX.Element> {
37
+ const { block, blockIndex } = props
38
+
39
+ if (block.type === "h2") {
40
+ return renderHeading({
41
+ blockIndex,
42
+ className: "text-text-primary text-24 font-500 leading-[1.3]",
43
+ level: "h2",
44
+ segments: block.segments,
45
+ })
46
+ }
47
+
48
+ if (block.type === "h3") {
49
+ return renderHeading({
50
+ blockIndex,
51
+ className: "text-text-primary text-20 font-500 leading-[1.4]",
52
+ level: "h3",
53
+ segments: block.segments,
54
+ })
55
+ }
56
+
57
+ if (block.type === "paragraph") {
58
+ return (
59
+ <>
60
+ <p className="text-text-primary text-16 leading-1-625">
61
+ <NotionRichTextSegments blockIndex={blockIndex} segments={block.segments} />
62
+ </p>
63
+ <Spacer className="h-24" />
64
+ </>
65
+ )
66
+ }
67
+
68
+ if (block.type === "image") {
69
+ return (
70
+ <>
71
+ {/* Notion serves image URLs from varying hosts, so render a plain image tag. */}
72
+ {/* oxlint-disable-next-line @next/next/no-img-element */}
73
+ <img alt={block.alt} className="rounded-12 h-auto w-full" loading="lazy" src={block.url} />
74
+ <Spacer className="h-24" />
75
+ </>
76
+ )
77
+ }
78
+
79
+ if (block.type === "code") {
80
+ const linkCards = renderLinkCards(block.code)
81
+
82
+ if (linkCards) {
83
+ return (
84
+ <>
85
+ <div className="flex flex-wrap gap-8">{linkCards}</div>
86
+ <Spacer className="h-8" />
87
+ </>
88
+ )
89
+ }
90
+
91
+ const mappedComponentElements = await renderMappedComponents(block.code)
92
+
93
+ if (mappedComponentElements) {
94
+ return (
95
+ <>
96
+ {/* Showcase component */}
97
+ <div className="rounded-12 bg-bg-secondary border-border-secondary flex flex-wrap items-center justify-center gap-16 border px-16 py-48">
98
+ {mappedComponentElements}
99
+ </div>
100
+ <Spacer className="h-24" />
101
+ </>
102
+ )
103
+ }
104
+
105
+ const codeHTML = highlight(block.code)
106
+
107
+ return (
108
+ <>
109
+ <pre className="rounded-12 bg-bg-secondary border-border-secondary text-13 overflow-x-auto border p-16 whitespace-break-spaces">
110
+ {/* oxlint-disable-next-line react/no-danger */}
111
+ <code aria-label={block.language} dangerouslySetInnerHTML={{ __html: codeHTML }} />
112
+ </pre>
113
+ <Spacer className="h-24" />
114
+ </>
115
+ )
116
+ }
117
+
118
+ // oxlint-disable-next-line react/jsx-no-useless-fragment returns empty fragment for typescript
119
+ return <></>
120
+ }
121
+
122
+ function getHeadingId(segments: NotionRichTextSegment[], blockIndex: number): string {
123
+ const headingText = getHeadingText(segments)
124
+
125
+ const headingSlug = headingText
126
+ .toLowerCase()
127
+ .normalize("NFKD")
128
+ .replaceAll(/[\u0300-\u036F]/g, "")
129
+ .replaceAll(/[^a-z0-9]+/g, "-")
130
+ .replaceAll(/^-+|-+$/g, "")
131
+
132
+ return headingSlug || `heading-${blockIndex}`
133
+ }
134
+
135
+ function getHeadingText(segments: NotionRichTextSegment[]): string {
136
+ return segments
137
+ .map((segment) => segment.text)
138
+ .join("")
139
+ .trim()
140
+ }
141
+
142
+ function renderHeading(props: RenderHeadingProps): JSX.Element {
143
+ const { blockIndex, className, level, segments } = props
144
+
145
+ const Heading = level
146
+ const headingText = getHeadingText(segments)
147
+ const headingId = getHeadingId(segments, blockIndex)
148
+
149
+ return (
150
+ <>
151
+ <Spacer className="h-24" />
152
+ <Heading className={`${className} group scroll-mt-[80px]`} id={headingId}>
153
+ <a
154
+ aria-label={headingText ? `Link to ${headingText}` : "Link to heading"}
155
+ className="relative inline-block max-w-full no-underline"
156
+ href={`#${headingId}`}
157
+ >
158
+ <span
159
+ aria-hidden
160
+ className="text-text-muted pointer-events-none absolute top-0 right-full pr-8 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
161
+ >
162
+ #
163
+ </span>
164
+ {renderHeadingSegments(blockIndex, segments)}
165
+ </a>
166
+ </Heading>
167
+ <Spacer className="h-12" />
168
+ </>
169
+ )
170
+ }
171
+
172
+ function renderHeadingSegments(
173
+ blockIndex: number,
174
+ segments: NotionRichTextSegment[],
175
+ ): JSX.Element[] {
176
+ return segments.map((segment, segmentIndex) => {
177
+ // The surrounding heading link owns hash navigation, so avoid nested anchors here.
178
+ let segmentContent: JSX.Element | string = segment.text
179
+
180
+ if (segment.code) {
181
+ segmentContent = (
182
+ <code className="rounded-4 bg-bg-secondary text-14 px-4 py-2 font-mono">
183
+ {segmentContent}
184
+ </code>
185
+ )
186
+ }
187
+
188
+ if (segment.bold) {
189
+ segmentContent = <strong>{segmentContent}</strong>
190
+ }
191
+
192
+ if (segment.italic) {
193
+ segmentContent = <em>{segmentContent}</em>
194
+ }
195
+
196
+ if (segment.strikethrough) {
197
+ segmentContent = <s>{segmentContent}</s>
198
+ }
199
+
200
+ if (segment.underline) {
201
+ segmentContent = <u>{segmentContent}</u>
202
+ }
203
+
204
+ return <span key={`${blockIndex}-${segmentIndex}`}>{segmentContent}</span>
205
+ })
206
+ }
207
+
208
+ const LINKS_MARKER = "// links"
209
+ const COMPONENT_MARKER = "// component"
210
+
211
+ const components: Record<string, ElementType> = {
212
+ Button,
213
+ ButtonLink,
214
+ Column,
215
+ IconPlus: IconPlusLarge,
216
+ Input,
217
+ Label,
218
+ Link,
219
+ Row,
220
+ Spacer,
221
+ Switch,
222
+ Tabs,
223
+ TabsPanel,
224
+ TabsUnderlineList,
225
+ TabsUnderlineTab,
226
+ }
227
+
228
+ interface LinkCard {
229
+ badge: string
230
+ href: string
231
+ label: string
232
+ meta: string
233
+ }
234
+
235
+ function formatLinkMeta(href: string): string {
236
+ if (href.startsWith("/")) {
237
+ return href
238
+ }
239
+ try {
240
+ const url = new URL(href)
241
+ const hostname = url.hostname.replace(/^www\./, "")
242
+ const pathname = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "")
243
+ return `${hostname}${pathname}`
244
+ } catch {
245
+ return href
246
+ }
247
+ }
248
+
249
+ function inferLinkBadge(label: string, href: string): string {
250
+ const normalizedText = `${label} ${href}`.toLowerCase()
251
+ if (normalizedText.includes("github")) {
252
+ return "GH"
253
+ }
254
+ if (normalizedText.includes("figma")) {
255
+ return "FG"
256
+ }
257
+ if (normalizedText.includes("story")) {
258
+ return "SB"
259
+ }
260
+ if (normalizedText.includes("download")) {
261
+ return "DL"
262
+ }
263
+ const compactLabel = label.replaceAll(/[^a-z0-9]/gi, "").toUpperCase()
264
+ return compactLabel.length >= 2 ? compactLabel.slice(0, 2) : "LK"
265
+ }
266
+
267
+ function inferLinkLabel(href: string): string {
268
+ const normalizedHref = href.toLowerCase()
269
+ if (normalizedHref.includes("github")) {
270
+ return "GitHub"
271
+ }
272
+ if (normalizedHref.includes("figma")) {
273
+ return "Figma"
274
+ }
275
+ if (normalizedHref.includes("story")) {
276
+ return "Storybook"
277
+ }
278
+ return "Link"
279
+ }
280
+
281
+ function isExternalHref(href: string): boolean {
282
+ return /^(?:https?:\/\/|mailto:)/.test(href)
283
+ }
284
+
285
+ function isLinkHref(segment: string): boolean {
286
+ return /^(?:https?:\/\/|mailto:|\/)/.test(segment)
287
+ }
288
+
289
+ function parseLinkCard(line: string): LinkCard | undefined {
290
+ const trimmedLine = line.trim()
291
+ if (trimmedLine.length === 0 || trimmedLine.startsWith("//")) {
292
+ return
293
+ }
294
+ const segments = trimmedLine
295
+ .split("::")
296
+ .map((segment) => segment.trim())
297
+ .filter(Boolean)
298
+ if (segments.length === 0) {
299
+ return
300
+ }
301
+ const hrefIndex = segments.findIndex((segment) => isLinkHref(segment))
302
+ if (hrefIndex === -1) {
303
+ return
304
+ }
305
+ const href = segments[hrefIndex]
306
+ const textSegments = segments.filter((_segment, index) => index !== hrefIndex)
307
+ const label = textSegments[0] ?? inferLinkLabel(href)
308
+ return {
309
+ badge: inferLinkBadge(label, href),
310
+ href,
311
+ label,
312
+ meta: textSegments[1] ?? formatLinkMeta(href),
313
+ }
314
+ }
315
+
316
+ function parseLinkCards(code: string): LinkCard[] | undefined {
317
+ const trimmedCode = code.trim()
318
+ if (!trimmedCode.startsWith(LINKS_MARKER)) {
319
+ return
320
+ }
321
+ const linkCards = trimmedCode
322
+ .slice(LINKS_MARKER.length)
323
+ .split("\n")
324
+ .flatMap((line) => {
325
+ const linkCard = parseLinkCard(line)
326
+ return linkCard ? [linkCard] : []
327
+ })
328
+ if (linkCards.length === 0) {
329
+ return
330
+ }
331
+ return linkCards
332
+ }
333
+
334
+ function renderLinkCards(code: string): JSX.Element[] | undefined {
335
+ const linkCards = parseLinkCards(code)
336
+ if (!linkCards) {
337
+ return
338
+ }
339
+ return linkCards.map((linkCard) => {
340
+ const isExternal = isExternalHref(linkCard.href)
341
+ return (
342
+ <a
343
+ className="bg-bg-secondary border-border-secondary hover:bg-gray-3 group inline-flex max-w-full items-center gap-6 rounded-full border px-6 py-4 transition-colors"
344
+ href={linkCard.href}
345
+ key={`${linkCard.label}-${linkCard.href}`}
346
+ rel={isExternal ? "noreferrer" : undefined}
347
+ target={isExternal ? "_blank" : undefined}
348
+ >
349
+ <div className="bg-gray-1 border-border-secondary text-gray-11 group-hover:text-gray-12 text-13 font-500 flex size-24 shrink-0 items-center justify-center rounded-full border font-mono uppercase transition-colors">
350
+ {linkCard.badge}
351
+ </div>
352
+ <p className="text-gray-12 text-13 font-500 min-w-0 truncate pr-2 leading-none whitespace-nowrap">
353
+ {linkCard.label}
354
+ </p>
355
+ </a>
356
+ )
357
+ })
358
+ }
359
+
360
+ async function renderMappedComponents(code: string): Promise<JSX.Element | undefined> {
361
+ const trimmedCode = code.trim()
362
+
363
+ if (!trimmedCode.startsWith(COMPONENT_MARKER)) {
364
+ return
365
+ }
366
+
367
+ const mdxSource = trimmedCode.slice(COMPONENT_MARKER.length).trim()
368
+ if (!mdxSource) {
369
+ return
370
+ }
371
+
372
+ const scopedMdxSource = scopeMappedComponentReferences(mdxSource)
373
+ try {
374
+ const evaluated = await evaluate(scopedMdxSource, {
375
+ Fragment,
376
+ development: false,
377
+ jsx,
378
+ jsxs,
379
+ })
380
+ const MdxContent = evaluated.default
381
+ return <MdxContent components={components} />
382
+ } catch {
383
+ // Invalid component markup falls back to the highlighted code block renderer.
384
+ }
385
+ }
386
+
387
+ function scopeMappedComponentReferences(mdxSource: string): string {
388
+ // MDX expressions like `as={Link}` resolve identifiers lexically, not from `components`.
389
+ // Rewrite mapped references to `props.components.*` so evaluated snippets can resolve them.
390
+ const componentReferencePattern = new RegExp(
391
+ `=\\{\\s*(${Object.keys(components)
392
+ .map((componentName) => componentName.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
393
+ .join("|")})\\s*\\}`,
394
+ "g",
395
+ )
396
+
397
+ return mdxSource.replaceAll(
398
+ componentReferencePattern,
399
+ (_match, componentName) => `={props.components.${componentName}}`,
400
+ )
401
+ }
@@ -0,0 +1,18 @@
1
+ import type { NotionBlock } from "@/components/notion/notion"
2
+ import { NotionBlockContent } from "@/components/notion/notion-block-content"
3
+
4
+ interface NotionDocsBlocksProps {
5
+ blocks: NotionBlock[]
6
+ }
7
+
8
+ export function NotionDocsBlocks(props: NotionDocsBlocksProps) {
9
+ const { blocks } = props
10
+
11
+ return (
12
+ <>
13
+ {blocks.map((block, index) => (
14
+ <NotionBlockContent block={block} blockIndex={index} key={index} />
15
+ ))}
16
+ </>
17
+ )
18
+ }
@@ -0,0 +1,20 @@
1
+ import { cacheTag } from "next/cache"
2
+ import { getNotionPage } from "@/components/notion/notion"
3
+ import { NotionDocsBlocks } from "@/components/notion/notion-docs-blocks"
4
+
5
+ interface NotionDocsCodePageProps {
6
+ pageId: string
7
+ }
8
+
9
+ export async function NotionDocsCodePage(props: NotionDocsCodePageProps) {
10
+ "use cache"
11
+
12
+ const { pageId } = props
13
+
14
+ cacheTag("notion")
15
+ cacheTag(`notion-page:${pageId}`)
16
+
17
+ const { blocks } = await getNotionPage(pageId)
18
+
19
+ return <NotionDocsBlocks blocks={blocks} />
20
+ }
@@ -0,0 +1,52 @@
1
+ import { Column, Spacer } from "@pump-fun/retail-design-system"
2
+ import { cacheTag } from "next/cache"
3
+ import type { PropsWithChildren } from "react"
4
+ import { getNotionPage } from "@/components/notion/notion"
5
+ import { NotionDocsBlocks } from "@/components/notion/notion-docs-blocks"
6
+ import { NotionTabs } from "@/components/notion/notion-tabs"
7
+
8
+ type NotionDocsLayoutProps = PropsWithChildren<{
9
+ childPageIds?: string[]
10
+ pageId: string
11
+ }>
12
+
13
+ export async function NotionDocsLayout(props: NotionDocsLayoutProps) {
14
+ "use cache"
15
+
16
+ const { children, pageId } = props
17
+
18
+ cacheTag("notion")
19
+ cacheTag(`notion-page:${pageId}`)
20
+
21
+ const { blocks, lastEditedTime, tabs, title } = await getNotionPage(pageId)
22
+
23
+ const formattedUpdatedTime = new Intl.DateTimeFormat("en-US", {
24
+ dateStyle: "long",
25
+ timeStyle: "short",
26
+ }).format(new Date(lastEditedTime))
27
+
28
+ return (
29
+ <Column>
30
+ <Column className="mx-auto w-full max-w-[620px]">
31
+ {/* Title */}
32
+ <h1 className="text-36 font-500 leading-[1.2]">{title}</h1>
33
+ <Spacer height={8} />
34
+
35
+ {/* Last Updated Time */}
36
+ <p className="text-gray-11 text-14 flex items-center gap-x-8">
37
+ <span>Last updated on {formattedUpdatedTime}</span>
38
+ </p>
39
+ <Spacer height={16} />
40
+
41
+ {/* Description and links */}
42
+ <NotionDocsBlocks blocks={blocks} />
43
+ <Spacer height={24} />
44
+
45
+ {/* Tabs */}
46
+ {tabs.length > 0 && <NotionTabs tabs={tabs} />}
47
+
48
+ {children}
49
+ </Column>
50
+ </Column>
51
+ )
52
+ }
@@ -0,0 +1,14 @@
1
+ "use client"
2
+
3
+ import { Button } from "@pump-fun/retail-design-system"
4
+ import { useFormStatus } from "react-dom"
5
+
6
+ export function NotionRevalidateButtonClient() {
7
+ const { pending } = useFormStatus()
8
+
9
+ return (
10
+ <Button isLoading={pending} type="submit" variant="secondary">
11
+ {pending ? "Refreshing..." : "Refresh Notion content cache"}
12
+ </Button>
13
+ )
14
+ }
@@ -0,0 +1,20 @@
1
+ import { revalidatePath, revalidateTag } from "next/cache"
2
+ import { NotionRevalidateButtonClient } from "@/components/notion/notion-revalidate-button-client"
3
+
4
+ export function NotionRevalidateButton() {
5
+ return (
6
+ <div className="mx-auto w-full max-w-[620px]">
7
+ <form action={revalidateNotionPageAction}>
8
+ <NotionRevalidateButtonClient />
9
+ </form>
10
+ </div>
11
+ )
12
+ }
13
+
14
+ // oxlint-disable-next-line require-await
15
+ async function revalidateNotionPageAction() {
16
+ "use server"
17
+
18
+ revalidateTag("notion", "max")
19
+ revalidatePath("/")
20
+ }
@@ -0,0 +1,55 @@
1
+ import type { ReactNode } from "react"
2
+ import type { NotionRichTextSegment } from "@/components/notion/notion"
3
+
4
+ interface NotionRichTextSegmentsProps {
5
+ blockIndex: number
6
+ segments: NotionRichTextSegment[]
7
+ }
8
+
9
+ export function NotionRichTextSegments(props: NotionRichTextSegmentsProps) {
10
+ const { blockIndex, segments } = props
11
+
12
+ return segments.map((segment, segmentIndex) => {
13
+ let segmentContent: ReactNode = segment.text
14
+
15
+ if (segment.code) {
16
+ segmentContent = (
17
+ <code className="rounded-4 bg-bg-secondary text-14 px-4 py-2 font-mono">
18
+ {segmentContent}
19
+ </code>
20
+ )
21
+ }
22
+
23
+ if (segment.bold) {
24
+ segmentContent = <strong>{segmentContent}</strong>
25
+ }
26
+
27
+ if (segment.italic) {
28
+ segmentContent = <em>{segmentContent}</em>
29
+ }
30
+
31
+ if (segment.strikethrough) {
32
+ segmentContent = <s>{segmentContent}</s>
33
+ }
34
+
35
+ if (segment.underline) {
36
+ segmentContent = <u>{segmentContent}</u>
37
+ }
38
+
39
+ if (segment.href) {
40
+ return (
41
+ <a
42
+ className="decoration-gray-9 underline decoration-1 underline-offset-2 hover:no-underline"
43
+ href={segment.href}
44
+ key={`${blockIndex}-${segmentIndex}`}
45
+ rel="noreferrer"
46
+ target="_blank"
47
+ >
48
+ {segmentContent}
49
+ </a>
50
+ )
51
+ }
52
+
53
+ return <span key={`${blockIndex}-${segmentIndex}`}>{segmentContent}</span>
54
+ })
55
+ }
@@ -0,0 +1,38 @@
1
+ "use client"
2
+
3
+ import { Tabs, TabsUnderlineList, TabsUnderlineTab } from "@pump-fun/retail-design-system"
4
+ import Link, { type LinkProps } from "next/link"
5
+ import { usePathname } from "next/navigation"
6
+ import type { NotionTab } from "@/components/notion/notion"
7
+
8
+ interface NotionTabsProps {
9
+ tabs: NotionTab[]
10
+ }
11
+
12
+ export function NotionTabs(props: NotionTabsProps) {
13
+ const { tabs } = props
14
+ const pathname = usePathname()
15
+
16
+ if (tabs.length === 0) {
17
+ return
18
+ }
19
+
20
+ const selectedValue = tabs.some((tab) => tab.href === pathname) ? pathname : tabs[0].href
21
+
22
+ return (
23
+ <Tabs value={selectedValue}>
24
+ <TabsUnderlineList>
25
+ {tabs.map((tab, index) => (
26
+ <TabsUnderlineTab
27
+ isNativeButton={false}
28
+ key={index}
29
+ render={<Link href={tab.href as LinkProps<string>["href"]} />}
30
+ value={tab.href}
31
+ >
32
+ {tab.label}
33
+ </TabsUnderlineTab>
34
+ ))}
35
+ </TabsUnderlineList>
36
+ </Tabs>
37
+ )
38
+ }