@stampui/blocks 1.0.0 → 1.1.1
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.
- package/dist/manifests.js +267 -0
- package/package.json +1 -1
- package/src/components/blocks/ai-chat-shell.tsx +1 -1
- package/src/components/blocks/announcement-bar.tsx +103 -0
- package/src/components/blocks/changelog-feed.tsx +115 -0
- package/src/components/blocks/cookie-consent.tsx +73 -0
- package/src/components/blocks/cta-banner.tsx +77 -0
- package/src/components/blocks/dashboard-shell.tsx +135 -0
- package/src/components/blocks/empty-state.tsx +64 -0
- package/src/components/blocks/error-page.tsx +69 -0
- package/src/components/blocks/faq-accordion.tsx +94 -0
- package/src/components/blocks/feature-comparison.tsx +110 -0
- package/src/components/blocks/metrics-grid.tsx +138 -0
- package/src/components/blocks/notification-center.tsx +5 -5
- package/src/components/blocks/registry-card.tsx +3 -3
- package/src/components/blocks/site-footer.tsx +196 -0
- package/src/components/blocks/social-proof-bar.tsx +83 -0
- package/src/components/blocks/team-grid.tsx +167 -0
- package/src/components/blocks/testimonials-wall.tsx +110 -0
- package/src/components/blocks/waitlist-section.tsx +102 -0
- package/src/manifests.ts +283 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import type { LucideIcon } from "lucide-react"
|
|
5
|
+
import { Menu, X } from "lucide-react"
|
|
6
|
+
import { cx } from "@/lib/cx"
|
|
7
|
+
|
|
8
|
+
export interface NavItem {
|
|
9
|
+
label: string
|
|
10
|
+
href: string
|
|
11
|
+
icon?: LucideIcon
|
|
12
|
+
active?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SidebarConfig {
|
|
16
|
+
logo?: React.ReactNode
|
|
17
|
+
nav: NavItem[]
|
|
18
|
+
footer?: React.ReactNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DashboardShellProps {
|
|
22
|
+
sidebar: SidebarConfig
|
|
23
|
+
header?: {
|
|
24
|
+
title?: string
|
|
25
|
+
actions?: React.ReactNode
|
|
26
|
+
}
|
|
27
|
+
children: React.ReactNode
|
|
28
|
+
className?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function DashboardShell({
|
|
32
|
+
sidebar,
|
|
33
|
+
header,
|
|
34
|
+
children,
|
|
35
|
+
className,
|
|
36
|
+
}: DashboardShellProps) {
|
|
37
|
+
const [mobileOpen, setMobileOpen] = React.useState(false)
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className={cx("flex min-h-screen bg-[#070708]", className)}>
|
|
41
|
+
{mobileOpen && (
|
|
42
|
+
<div
|
|
43
|
+
className="fixed inset-0 z-20 bg-[#070708]/80 lg:hidden"
|
|
44
|
+
onClick={() => setMobileOpen(false)}
|
|
45
|
+
aria-hidden="true"
|
|
46
|
+
/>
|
|
47
|
+
)}
|
|
48
|
+
|
|
49
|
+
<aside
|
|
50
|
+
className={cx(
|
|
51
|
+
"fixed inset-y-0 left-0 z-30 flex w-[240px] flex-col border-r border-[#23252A] bg-[#09090B]",
|
|
52
|
+
"transition-transform duration-[200ms] ease-out",
|
|
53
|
+
"lg:static lg:translate-x-0",
|
|
54
|
+
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{sidebar.logo && (
|
|
58
|
+
<div className="flex h-14 shrink-0 items-center border-b border-[#23252A] px-4">
|
|
59
|
+
{sidebar.logo}
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
|
|
63
|
+
<nav className="flex-1 overflow-y-auto px-2 py-3">
|
|
64
|
+
<ul className="flex flex-col gap-0.5">
|
|
65
|
+
{sidebar.nav.map((item, i) => {
|
|
66
|
+
const Icon = item.icon
|
|
67
|
+
return (
|
|
68
|
+
<li key={i}>
|
|
69
|
+
<a
|
|
70
|
+
href={item.href}
|
|
71
|
+
className={cx(
|
|
72
|
+
"flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm transition-colors duration-[150ms] ease-out",
|
|
73
|
+
item.active
|
|
74
|
+
? "bg-[#101114] text-[#FAFAFA] font-medium"
|
|
75
|
+
: "text-muted-foreground hover:bg-[#101114] hover:text-[#FAFAFA]"
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{Icon && <Icon size={15} strokeWidth={1.75} className="shrink-0" />}
|
|
79
|
+
<span className="truncate">{item.label}</span>
|
|
80
|
+
</a>
|
|
81
|
+
</li>
|
|
82
|
+
)
|
|
83
|
+
})}
|
|
84
|
+
</ul>
|
|
85
|
+
</nav>
|
|
86
|
+
|
|
87
|
+
{sidebar.footer && (
|
|
88
|
+
<div className="shrink-0 border-t border-[#23252A] px-4 py-3">
|
|
89
|
+
{sidebar.footer}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</aside>
|
|
93
|
+
|
|
94
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
95
|
+
{(header || true) && (
|
|
96
|
+
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-[#23252A] px-4 lg:px-6">
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => setMobileOpen(true)}
|
|
100
|
+
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-[150ms] ease-out hover:bg-[#101114] hover:text-[#FAFAFA] lg:hidden"
|
|
101
|
+
aria-label="Open navigation"
|
|
102
|
+
>
|
|
103
|
+
<Menu size={16} />
|
|
104
|
+
</button>
|
|
105
|
+
|
|
106
|
+
{header?.title && (
|
|
107
|
+
<h1 className="text-sm font-semibold text-[#FAFAFA] truncate">
|
|
108
|
+
{header.title}
|
|
109
|
+
</h1>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{header?.actions && (
|
|
113
|
+
<div className="ml-auto flex items-center gap-2">
|
|
114
|
+
{header.actions}
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</header>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
<main className="flex-1 overflow-y-auto p-4 lg:p-6">{children}</main>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{mobileOpen && (
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={() => setMobileOpen(false)}
|
|
127
|
+
className="fixed right-4 top-4 z-40 flex h-8 w-8 items-center justify-center rounded-lg border border-[#23252A] bg-[#09090B] text-muted-foreground transition-colors duration-[150ms] ease-out hover:text-[#FAFAFA] lg:hidden"
|
|
128
|
+
aria-label="Close navigation"
|
|
129
|
+
>
|
|
130
|
+
<X size={14} />
|
|
131
|
+
</button>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import type { LucideIcon } from "lucide-react"
|
|
5
|
+
import { Inbox } from "lucide-react"
|
|
6
|
+
import { Button } from "@/components/core/button"
|
|
7
|
+
import { cx } from "@/lib/cx"
|
|
8
|
+
|
|
9
|
+
export interface EmptyStateAction {
|
|
10
|
+
label: string
|
|
11
|
+
onClick?: () => void
|
|
12
|
+
href?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface EmptyStateProps {
|
|
16
|
+
icon?: LucideIcon
|
|
17
|
+
title: string
|
|
18
|
+
description?: string
|
|
19
|
+
action?: EmptyStateAction
|
|
20
|
+
className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function EmptyState({
|
|
24
|
+
icon: Icon = Inbox,
|
|
25
|
+
title,
|
|
26
|
+
description,
|
|
27
|
+
action,
|
|
28
|
+
className,
|
|
29
|
+
}: EmptyStateProps) {
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
className={cx(
|
|
33
|
+
"flex flex-col items-center justify-center text-center px-6 py-16",
|
|
34
|
+
className
|
|
35
|
+
)}
|
|
36
|
+
>
|
|
37
|
+
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-xl border border-[#23252A] bg-[#09090B]">
|
|
38
|
+
<Icon size={24} className="text-muted-foreground" strokeWidth={1.5} />
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<h3 className="text-sm font-semibold text-[#FAFAFA] mb-1.5">{title}</h3>
|
|
42
|
+
|
|
43
|
+
{description && (
|
|
44
|
+
<p className="text-sm text-muted-foreground max-w-xs leading-relaxed mb-6">
|
|
45
|
+
{description}
|
|
46
|
+
</p>
|
|
47
|
+
)}
|
|
48
|
+
|
|
49
|
+
{action && (
|
|
50
|
+
<div className={cx(!description && "mt-6")}>
|
|
51
|
+
{action.href ? (
|
|
52
|
+
<Button variant="outline" size="sm" asChild>
|
|
53
|
+
<a href={action.href}>{action.label}</a>
|
|
54
|
+
</Button>
|
|
55
|
+
) : (
|
|
56
|
+
<Button variant="outline" size="sm" onClick={action.onClick}>
|
|
57
|
+
{action.label}
|
|
58
|
+
</Button>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Button } from "@/components/core/button"
|
|
5
|
+
import { cx } from "@/lib/cx"
|
|
6
|
+
|
|
7
|
+
export interface ErrorPageAction {
|
|
8
|
+
label: string
|
|
9
|
+
href?: string
|
|
10
|
+
onClick?: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ErrorPageProps {
|
|
14
|
+
code?: string | number
|
|
15
|
+
title?: string
|
|
16
|
+
description?: string
|
|
17
|
+
action?: ErrorPageAction
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ErrorPage({
|
|
22
|
+
code = "404",
|
|
23
|
+
title = "Page not found",
|
|
24
|
+
description = "The page you're looking for doesn't exist or has been moved.",
|
|
25
|
+
action = { label: "Back to home", href: "/" },
|
|
26
|
+
className,
|
|
27
|
+
}: ErrorPageProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
className={cx(
|
|
31
|
+
"min-h-screen w-full bg-[#070708] flex flex-col items-center justify-center px-6",
|
|
32
|
+
className
|
|
33
|
+
)}
|
|
34
|
+
>
|
|
35
|
+
<div className="flex flex-col items-center text-center max-w-sm">
|
|
36
|
+
<span
|
|
37
|
+
className="text-[96px] font-bold leading-none tracking-tighter text-muted-foreground/30 select-none tabular-nums"
|
|
38
|
+
aria-hidden="true"
|
|
39
|
+
>
|
|
40
|
+
{code}
|
|
41
|
+
</span>
|
|
42
|
+
|
|
43
|
+
<h1 className="mt-4 text-xl font-semibold text-[#FAFAFA] tracking-tight">
|
|
44
|
+
{title}
|
|
45
|
+
</h1>
|
|
46
|
+
|
|
47
|
+
{description && (
|
|
48
|
+
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
|
|
49
|
+
{description}
|
|
50
|
+
</p>
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
{action && (
|
|
54
|
+
<div className="mt-8">
|
|
55
|
+
{action.href ? (
|
|
56
|
+
<Button variant="outline" size="md" asChild>
|
|
57
|
+
<a href={action.href}>{action.label}</a>
|
|
58
|
+
</Button>
|
|
59
|
+
) : (
|
|
60
|
+
<Button variant="outline" size="md" onClick={action.onClick}>
|
|
61
|
+
{action.label}
|
|
62
|
+
</Button>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Plus, Minus } from "lucide-react"
|
|
5
|
+
import { cx } from "@/lib/cx"
|
|
6
|
+
|
|
7
|
+
export interface FaqItem {
|
|
8
|
+
question: string
|
|
9
|
+
answer: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FaqAccordionProps {
|
|
13
|
+
items?: FaqItem[]
|
|
14
|
+
title?: string
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_ITEMS: FaqItem[] = [
|
|
19
|
+
{
|
|
20
|
+
question: "Do I need to install a runtime?",
|
|
21
|
+
answer: "No. StampUI copies source files directly into your project. There is no runtime dependency — you own every line of code after stamping.",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
question: "What frameworks are supported?",
|
|
25
|
+
answer: "Next.js 13+ (App Router), Remix, and any React project using Tailwind CSS. Vite-based setups work with minor config adjustments.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
question: "Can I customize the components after stamping?",
|
|
29
|
+
answer: "Yes, and that's the point. Once stamped, components are plain TypeScript/React files in your codebase. Modify them freely without worrying about upstream conflicts.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
question: "Is there a free tier?",
|
|
33
|
+
answer: "All blocks are free to stamp and use in personal or commercial projects. A pro plan unlocks private block collections and team sharing.",
|
|
34
|
+
},
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
function AccordionItem({ question, answer }: FaqItem) {
|
|
38
|
+
const [open, setOpen] = React.useState(false)
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="border-b border-[#23252A] last:border-b-0">
|
|
42
|
+
<button
|
|
43
|
+
onClick={() => setOpen((v) => !v)}
|
|
44
|
+
className={cx(
|
|
45
|
+
"w-full flex items-center justify-between gap-4 py-5 text-left",
|
|
46
|
+
"text-sm font-medium text-[#FAFAFA] leading-snug",
|
|
47
|
+
"transition-colors duration-[170ms] ease-out",
|
|
48
|
+
"hover:text-[#FAFAFA]/80"
|
|
49
|
+
)}
|
|
50
|
+
aria-expanded={open}
|
|
51
|
+
>
|
|
52
|
+
<span>{question}</span>
|
|
53
|
+
<span className="shrink-0 text-muted-foreground">
|
|
54
|
+
{open ? <Minus size={14} /> : <Plus size={14} />}
|
|
55
|
+
</span>
|
|
56
|
+
</button>
|
|
57
|
+
|
|
58
|
+
<div
|
|
59
|
+
className={cx(
|
|
60
|
+
"overflow-hidden transition-all duration-[200ms] ease-out",
|
|
61
|
+
open ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<p className="pb-5 text-sm text-muted-foreground leading-relaxed">
|
|
65
|
+
{answer}
|
|
66
|
+
</p>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function FaqAccordion({
|
|
73
|
+
items = DEFAULT_ITEMS,
|
|
74
|
+
title = "Frequently asked questions",
|
|
75
|
+
className,
|
|
76
|
+
}: FaqAccordionProps) {
|
|
77
|
+
return (
|
|
78
|
+
<section className={cx("w-full py-16 px-6", className)}>
|
|
79
|
+
<div className="max-w-2xl mx-auto">
|
|
80
|
+
{title && (
|
|
81
|
+
<h2 className="text-2xl font-bold tracking-tight text-[#FAFAFA] mb-8">
|
|
82
|
+
{title}
|
|
83
|
+
</h2>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
<div className="rounded-xl border border-[#23252A] bg-[#09090B] px-6">
|
|
87
|
+
{items.map((item, i) => (
|
|
88
|
+
<AccordionItem key={i} question={item.question} answer={item.answer} />
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</section>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cx } from "@/lib/cx"
|
|
5
|
+
|
|
6
|
+
export interface ComparisonPlan {
|
|
7
|
+
name: string
|
|
8
|
+
highlighted?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ComparisonFeature {
|
|
12
|
+
label: string
|
|
13
|
+
values: (boolean | string)[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FeatureComparisonProps {
|
|
17
|
+
plans?: ComparisonPlan[]
|
|
18
|
+
features?: ComparisonFeature[]
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const defaultPlans: ComparisonPlan[] = [
|
|
23
|
+
{ name: "Free" },
|
|
24
|
+
{ name: "Pro", highlighted: true },
|
|
25
|
+
{ name: "Team" },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const defaultFeatures: ComparisonFeature[] = [
|
|
29
|
+
{ label: "Components", values: ["50", "200+", "200+"] },
|
|
30
|
+
{ label: "CLI access", values: [true, true, true] },
|
|
31
|
+
{ label: "Custom themes", values: [false, true, true] },
|
|
32
|
+
{ label: "Team seats", values: ["1", "1", "Unlimited"] },
|
|
33
|
+
{ label: "Priority support", values: [false, false, true] },
|
|
34
|
+
{ label: "Private blocks", values: [false, true, true] },
|
|
35
|
+
{ label: "Analytics", values: [false, false, true] },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
function CellValue({ value }: { value: boolean | string }) {
|
|
39
|
+
if (typeof value === "boolean") {
|
|
40
|
+
return value ? (
|
|
41
|
+
<span className="text-[#FAFAFA] text-sm font-medium">✓</span>
|
|
42
|
+
) : (
|
|
43
|
+
<span className="text-muted-foreground text-sm opacity-40">—</span>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return <span className="text-sm text-[#FAFAFA]">{value}</span>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function FeatureComparison({
|
|
50
|
+
plans = defaultPlans,
|
|
51
|
+
features = defaultFeatures,
|
|
52
|
+
className,
|
|
53
|
+
}: FeatureComparisonProps) {
|
|
54
|
+
return (
|
|
55
|
+
<div className={cx("w-full overflow-x-auto", className)}>
|
|
56
|
+
<table className="w-full min-w-[480px] border-collapse">
|
|
57
|
+
<thead>
|
|
58
|
+
<tr>
|
|
59
|
+
<th className="w-[40%] pb-4 pr-4 text-left">
|
|
60
|
+
<span className="sr-only">Feature</span>
|
|
61
|
+
</th>
|
|
62
|
+
{plans.map((plan, i) => (
|
|
63
|
+
<th
|
|
64
|
+
key={i}
|
|
65
|
+
className={cx(
|
|
66
|
+
"pb-4 px-4 text-center text-sm font-semibold text-[#FAFAFA]",
|
|
67
|
+
plan.highlighted && "rounded-t-xl bg-[#101114]"
|
|
68
|
+
)}
|
|
69
|
+
>
|
|
70
|
+
{plan.name}
|
|
71
|
+
</th>
|
|
72
|
+
))}
|
|
73
|
+
</tr>
|
|
74
|
+
</thead>
|
|
75
|
+
<tbody>
|
|
76
|
+
{features.map((feature, fi) => {
|
|
77
|
+
const isLast = fi === features.length - 1
|
|
78
|
+
return (
|
|
79
|
+
<tr
|
|
80
|
+
key={fi}
|
|
81
|
+
className="border-t border-[#23252A] transition-colors duration-150 ease-out hover:bg-[#09090B]"
|
|
82
|
+
>
|
|
83
|
+
<td
|
|
84
|
+
className={cx(
|
|
85
|
+
"py-3 pr-4 text-sm text-muted-foreground",
|
|
86
|
+
isLast && "pb-4"
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
{feature.label}
|
|
90
|
+
</td>
|
|
91
|
+
{plans.map((plan, pi) => (
|
|
92
|
+
<td
|
|
93
|
+
key={pi}
|
|
94
|
+
className={cx(
|
|
95
|
+
"px-4 py-3 text-center",
|
|
96
|
+
plan.highlighted && "bg-[#101114]",
|
|
97
|
+
isLast && plan.highlighted && "rounded-b-xl pb-4"
|
|
98
|
+
)}
|
|
99
|
+
>
|
|
100
|
+
<CellValue value={feature.values[pi] ?? false} />
|
|
101
|
+
</td>
|
|
102
|
+
))}
|
|
103
|
+
</tr>
|
|
104
|
+
)
|
|
105
|
+
})}
|
|
106
|
+
</tbody>
|
|
107
|
+
</table>
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { TrendingUp, TrendingDown, Minus, type LucideIcon } from "lucide-react"
|
|
5
|
+
import { cx } from "@/lib/cx"
|
|
6
|
+
|
|
7
|
+
export interface MetricChange {
|
|
8
|
+
value: string
|
|
9
|
+
direction: "up" | "down" | "neutral"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface MetricItem {
|
|
13
|
+
label: string
|
|
14
|
+
value: string
|
|
15
|
+
change?: MetricChange
|
|
16
|
+
icon?: LucideIcon
|
|
17
|
+
description?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MetricsGridProps {
|
|
21
|
+
metrics?: MetricItem[]
|
|
22
|
+
columns?: 2 | 3 | 4
|
|
23
|
+
className?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const defaultMetrics: MetricItem[] = [
|
|
27
|
+
{
|
|
28
|
+
label: "Monthly Revenue",
|
|
29
|
+
value: "$48,290",
|
|
30
|
+
change: { value: "+12.4%", direction: "up" },
|
|
31
|
+
description: "vs. last month",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
label: "Active Users",
|
|
35
|
+
value: "9,841",
|
|
36
|
+
change: { value: "+3.1%", direction: "up" },
|
|
37
|
+
description: "vs. last month",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: "Churn Rate",
|
|
41
|
+
value: "2.3%",
|
|
42
|
+
change: { value: "+0.4%", direction: "down" },
|
|
43
|
+
description: "vs. last month",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
label: "Avg. Session",
|
|
47
|
+
value: "4m 12s",
|
|
48
|
+
change: { value: "0%", direction: "neutral" },
|
|
49
|
+
description: "vs. last month",
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const colClass: Record<2 | 3 | 4, string> = {
|
|
54
|
+
2: "sm:grid-cols-2",
|
|
55
|
+
3: "sm:grid-cols-2 lg:grid-cols-3",
|
|
56
|
+
4: "sm:grid-cols-2 lg:grid-cols-4",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const changeStyles: Record<MetricChange["direction"], string> = {
|
|
60
|
+
up: "text-emerald-400",
|
|
61
|
+
down: "text-red-400",
|
|
62
|
+
neutral: "text-muted-foreground",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ChangeIcon: Record<MetricChange["direction"], LucideIcon> = {
|
|
66
|
+
up: TrendingUp,
|
|
67
|
+
down: TrendingDown,
|
|
68
|
+
neutral: Minus,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function MetricsGrid({
|
|
72
|
+
metrics = defaultMetrics,
|
|
73
|
+
columns = 4,
|
|
74
|
+
className,
|
|
75
|
+
}: MetricsGridProps) {
|
|
76
|
+
return (
|
|
77
|
+
<div className={cx("w-full", className)}>
|
|
78
|
+
<div className={cx("grid grid-cols-1 gap-4", colClass[columns])}>
|
|
79
|
+
{metrics.map((metric, i) => {
|
|
80
|
+
const Icon = metric.icon
|
|
81
|
+
const change = metric.change
|
|
82
|
+
const ChangeIconComp = change ? ChangeIcon[change.direction] : null
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
key={i}
|
|
87
|
+
className="flex flex-col gap-3 rounded-xl border border-[#23252A] bg-[#09090B] p-5 transition-colors duration-150 ease-out hover:border-[#2e3035] hover:bg-[#0c0c0f]"
|
|
88
|
+
>
|
|
89
|
+
<div className="flex items-center justify-between">
|
|
90
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
91
|
+
{metric.label}
|
|
92
|
+
</span>
|
|
93
|
+
{Icon && (
|
|
94
|
+
<Icon
|
|
95
|
+
size={15}
|
|
96
|
+
className="text-muted-foreground opacity-60"
|
|
97
|
+
strokeWidth={1.5}
|
|
98
|
+
/>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<p className="text-2xl font-semibold tracking-tight text-[#FAFAFA]">
|
|
103
|
+
{metric.value}
|
|
104
|
+
</p>
|
|
105
|
+
|
|
106
|
+
{(change || metric.description) && (
|
|
107
|
+
<div className="flex items-center gap-1.5">
|
|
108
|
+
{change && ChangeIconComp && (
|
|
109
|
+
<ChangeIconComp
|
|
110
|
+
size={13}
|
|
111
|
+
className={changeStyles[change.direction]}
|
|
112
|
+
strokeWidth={2}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
{change && (
|
|
116
|
+
<span
|
|
117
|
+
className={cx(
|
|
118
|
+
"text-xs font-medium",
|
|
119
|
+
changeStyles[change.direction]
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
{change.value}
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
{metric.description && (
|
|
126
|
+
<span className="text-xs text-muted-foreground">
|
|
127
|
+
{metric.description}
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
})}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -30,11 +30,11 @@ export interface NotificationCenterProps {
|
|
|
30
30
|
// ── Icons ──────────────────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
32
|
const typeConfig: Record<NotificationType, { icon: React.ReactNode; color: string }> = {
|
|
33
|
-
message: { icon: <MessageSquare className="h-3.5 w-3.5" />, color: "bg-
|
|
34
|
-
alert: { icon: <AlertCircle className="h-3.5 w-3.5" />, color: "bg-
|
|
35
|
-
billing: { icon: <CreditCard className="h-3.5 w-3.5" />, color: "bg-
|
|
36
|
-
invite: { icon: <UserPlus className="h-3.5 w-3.5" />, color: "bg-
|
|
37
|
-
system: { icon: <AlertCircle className="h-3.5 w-3.5" />, color: "bg-
|
|
33
|
+
message: { icon: <MessageSquare className="h-3.5 w-3.5" />, color: "bg-[#101114] text-[#FAFAFA] border-[#23252A]" },
|
|
34
|
+
alert: { icon: <AlertCircle className="h-3.5 w-3.5" />, color: "bg-[#101114] text-[#FAFAFA] border-[#23252A]" },
|
|
35
|
+
billing: { icon: <CreditCard className="h-3.5 w-3.5" />, color: "bg-[#101114] text-[#FAFAFA] border-[#23252A]" },
|
|
36
|
+
invite: { icon: <UserPlus className="h-3.5 w-3.5" />, color: "bg-[#101114] text-[#FAFAFA] border-[#23252A]" },
|
|
37
|
+
system: { icon: <AlertCircle className="h-3.5 w-3.5" />, color: "bg-[#101114] text-muted-foreground border-[#23252A]" },
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const defaultNotifications: Notification[] = [
|
|
@@ -53,17 +53,17 @@ export function RegistryCard({
|
|
|
53
53
|
|
|
54
54
|
<div className="absolute top-3 right-3 flex gap-1.5">
|
|
55
55
|
{isGated && (
|
|
56
|
-
<Badge variant="pro" className="bg-
|
|
56
|
+
<Badge variant="pro" className="bg-[#09090B] text-[10px] tracking-widest font-mono">
|
|
57
57
|
PRO
|
|
58
58
|
</Badge>
|
|
59
59
|
)}
|
|
60
60
|
{status === "new" && !isGated && (
|
|
61
|
-
<Badge variant="new" className="bg-
|
|
61
|
+
<Badge variant="new" className="bg-[#09090B] text-[10px] tracking-widest font-mono">
|
|
62
62
|
NEW
|
|
63
63
|
</Badge>
|
|
64
64
|
)}
|
|
65
65
|
{promptReady && (
|
|
66
|
-
<Badge variant="neutral" className="bg-
|
|
66
|
+
<Badge variant="neutral" className="bg-[#09090B] text-[10px] font-mono hidden group-hover:inline-flex">
|
|
67
67
|
Prompt Ready
|
|
68
68
|
</Badge>
|
|
69
69
|
)}
|