diff --git a/app/(root)/pricing/page.tsx b/app/(root)/pricing/page.tsx index 5f08702..5e84a3f 100644 --- a/app/(root)/pricing/page.tsx +++ b/app/(root)/pricing/page.tsx @@ -6,6 +6,9 @@ import { PricingFAQ, PricingCTA, } from "@/components/bilalUi/pricing"; +import { isLemonConfigured } from "@/config/lemon"; + +const lemonReady = isLemonConfigured(); const plans = [ { @@ -44,7 +47,9 @@ const plans = [ cta: "Get Lifetime Access", href: "#", popular: true, + badge: "Coming Soon", comingSoon: true, + checkout: false, }, ]; @@ -130,7 +135,7 @@ export default function PricingPage() {
{plans.map((plan) => ( - + ))}
@@ -148,7 +153,7 @@ export default function PricingPage() { diff --git a/app/api/debug/env/route.ts b/app/api/debug/env/route.ts new file mode 100644 index 0000000..e7636ff --- /dev/null +++ b/app/api/debug/env/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { isLemonConfigured, getLemonConfig } from "@/config/lemon"; + +export async function GET() { + const configured = isLemonConfigured(); + let config: Record = {}; + + if (configured) { + const c = getLemonConfig(); + config = { + storeId: String(c.storeId), + productId: String(c.productId), + variantId: String(c.variantId), + apiKeyLength: String(c.apiKey.length), + }; + } + + return NextResponse.json({ + configured, + env: { + hasApiKey: !!process.env.LEMONSQUEEZY_API_KEY, + hasStoreId: !!process.env.LEMONSQUEEZY_STORE_ID, + hasProductId: !!process.env.LEMONSQUEEZY_PRODUCT_ID, + hasVariantId: !!process.env.LEMONSQUEEZY_VARIANT_ID, + }, + config, + }); +} diff --git a/app/api/lemon/checkout/route.ts b/app/api/lemon/checkout/route.ts new file mode 100644 index 0000000..0d06175 --- /dev/null +++ b/app/api/lemon/checkout/route.ts @@ -0,0 +1,58 @@ +import { NextResponse, NextRequest } from "next/server"; +import { createCheckoutUrl, setupLemon } from "@/lib/lemon"; +import { isLemonConfigured } from "@/config/lemon"; + +export async function GET() { + if (!isLemonConfigured()) { + return NextResponse.json( + { error: "Payment not configured" }, + { status: 503 }, + ); + } + + setupLemon(); + + const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + + const url = await createCheckoutUrl({ + redirectUrl: `${appUrl}/pricing?checkout=success`, + }); + + if (!url) { + return NextResponse.json( + { error: "Failed to create checkout" }, + { status: 500 }, + ); + } + + return NextResponse.redirect(url); +} + +export async function POST(req: Request) { + if (!isLemonConfigured()) { + return NextResponse.json( + { error: "Payment not configured" }, + { status: 503 }, + ); + } + + setupLemon(); + + const body = await req.json().catch(() => ({})); + const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + + const url = await createCheckoutUrl({ + redirectUrl: `${appUrl}/pricing?checkout=success`, + email: body.email, + name: body.name, + }); + + if (!url) { + return NextResponse.json( + { error: "Failed to create checkout" }, + { status: 500 }, + ); + } + + return NextResponse.json({ url }); +} diff --git a/app/api/lemon/verify/route.ts b/app/api/lemon/verify/route.ts new file mode 100644 index 0000000..7aab42c --- /dev/null +++ b/app/api/lemon/verify/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { verifyLicenseKey } from "@/lib/lemon"; + +export async function POST(req: Request) { + const body = await req.json().catch(() => ({})); + const { licenseKey } = body; + + if (!licenseKey || typeof licenseKey !== "string") { + return NextResponse.json( + { valid: false, error: "License key is required" }, + { status: 400 }, + ); + } + + const result = await verifyLicenseKey(licenseKey.trim()); + + return NextResponse.json(result); +} diff --git a/app/api/lemon/webhook/route.ts b/app/api/lemon/webhook/route.ts new file mode 100644 index 0000000..416322c --- /dev/null +++ b/app/api/lemon/webhook/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { getLemonConfig } from "@/config/lemon"; + +export async function POST(req: Request) { + const { webhookSecret } = getLemonConfig(); + + const signature = req.headers.get("x-signature"); + const body = await req.text(); + + if (!signature) { + return NextResponse.json({ error: "Missing signature" }, { status: 401 }); + } + + const expected = await crypto.subtle + .digest("SHA-256", new TextEncoder().encode(body + webhookSecret)) + .then((h) => + Array.from(new Uint8Array(h)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""), + ); + + if (signature !== expected) { + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + } + + const event = JSON.parse(body); + const eventName = event.meta?.event_name; + + if (eventName === "order_created") { + const licenseKey = event.data?.attributes?.first_order_item?.license_key; + const email = event.data?.attributes?.user_email; + + if (licenseKey && email) { + console.log(`[LemonSqueezy] Order from ${email}, license: ${licenseKey}`); + } + } + + return NextResponse.json({ received: true }); +} diff --git a/app/api/source/[name]/route.ts b/app/api/source/[name]/route.ts index a5cd274..1a292d5 100644 --- a/app/api/source/[name]/route.ts +++ b/app/api/source/[name]/route.ts @@ -1,7 +1,8 @@ -// File: app/api/source/[name]/route.ts import { NextResponse } from "next/server" import fs from "fs/promises" +import fsSync from "fs" import path from "path" +import { PRO_COMPONENTS } from "@/config/pro" async function findFilesRecursively( dir: string, @@ -36,25 +37,92 @@ function selectBestMatch(matches: string[], preferDemo: boolean): string | null return nonDemoMatches[0] || demoMatches[0] || sorted[0]; } +function getComponentGroupFromPath(filePath: string): string | null { + const parts = filePath.replace(/\\/g, "/").split("/"); + const bilalUiIndex = parts.indexOf("bilalUi"); + if (bilalUiIndex === -1 || bilalUiIndex + 1 >= parts.length) return null; + const group = parts[bilalUiIndex + 1]; + if (group === "demo" || group === "pricing" || group === "pro") return null; + return group; +} + +function isProFile(filePath: string): boolean { + const group = getComponentGroupFromPath(filePath); + if (!group) return false; + + const config = PRO_COMPONENTS[group]; + if (!config) return false; + + const filename = path.basename(filePath, path.extname(filePath)); + const groupDir = path.dirname(filePath); + const groupDirname = path.basename(groupDir); + + if (groupDirname !== group) return false; + + try { + const allFiles = fsSync.readdirSync(groupDir) + .filter(f => f.endsWith(".tsx")) + .sort(); + const index = allFiles.indexOf(`${filename}.tsx`); + if (index === -1) return false; + return index >= config.freeCount; + } catch { + return false; + } +} + +const PRO_PLACEHOLDER = `"use client"; + +import { Crown, LockKeyhole, Sparkles } from "lucide-react"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +interface ProPlaceholderProps { + children?: React.ReactNode; +} + +export function ProPlaceholder({ children }: ProPlaceholderProps) { + return ( +
+
+ +
+
+ + + Pro Component + +

+ Purchase the Lifetime plan ($15) to unlock this component. +

+
+ +
+ ); +} +`; + export async function GET( request: Request, { params }: { params: Promise<{ name: string }> } ) { const { name } = await params - // Strict Validation & Sanitization if (!name) { return new NextResponse("Component name required", { status: 400 }) } - // Sanitize input to prevent path traversal const safeName = path.basename(name) if (safeName !== name) { return new NextResponse("Invalid request path", { status: 400 }) } - // Validate component name format (alphanumeric + hyphens only) - // Allow .json extension if that's part of the route pattern (though usually stripped) const componentName = safeName.replace(".json", "") if (!/^[a-zA-Z0-9-]+$/.test(componentName)) { return new NextResponse("Invalid component name", { status: 400 }) @@ -72,6 +140,12 @@ export async function GET( return new NextResponse("Component not found", { status: 404 }) } + if (isProFile(filePath)) { + return new NextResponse(PRO_PLACEHOLDER, { + headers: { "Content-Type": "text/plain" }, + }); + } + const fileContent = await fs.readFile(filePath, "utf-8") return new NextResponse(fileContent, { headers: { "Content-Type": "text/plain" }, diff --git a/app/registry/[name]/route.ts b/app/registry/[name]/route.ts index 3d28d8c..25a3199 100644 --- a/app/registry/[name]/route.ts +++ b/app/registry/[name]/route.ts @@ -1,7 +1,8 @@ -// File: app/registry/[name]/route.ts import { NextResponse } from "next/server" import fs from "fs/promises" +import fsSync from "fs" import path from "path" +import { PRO_COMPONENTS } from "@/config/pro" const COMPONENTS_DIR_NAME = "bilalUi" const EXCLUDED_FROM_INDEX = new Set([ @@ -12,22 +13,93 @@ const EXCLUDED_FROM_INDEX = new Set([ "TestDemo.tsx", ]) -// Helper to extract component data from a file +function getComponentGroupFromPath(filePath: string): string | null { + const parts = filePath.replace(/\\/g, "/").split("/"); + const bilalUiIndex = parts.indexOf("bilalUi"); + if (bilalUiIndex === -1 || bilalUiIndex + 1 >= parts.length) return null; + const group = parts[bilalUiIndex + 1]; + if (group === "demo" || group === "pricing" || group === "pro") return null; + return group; +} + +function isProFile(filePath: string): boolean { + const group = getComponentGroupFromPath(filePath); + if (!group) return false; + + const config = PRO_COMPONENTS[group]; + if (!config) return false; + + const filename = path.basename(filePath, path.extname(filePath)); + const groupDir = path.dirname(filePath); + const groupDirname = path.basename(groupDir); + + if (groupDirname !== group) return false; + + try { + const allFiles = fsSync.readdirSync(groupDir) + .filter(f => f.endsWith(".tsx")) + .sort(); + const index = allFiles.indexOf(`${filename}.tsx`); + if (index === -1) return false; + return index >= config.freeCount; + } catch { + return false; + } +} + +const PRO_PLACEHOLDER_CONTENT = `"use client"; + +import { Crown, LockKeyhole, Sparkles } from "lucide-react"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +interface ProPlaceholderProps { + children?: React.ReactNode; +} + +export function ProPlaceholder({ children }: ProPlaceholderProps) { + return ( +
+
+ +
+
+ + + Pro Component + +

+ Purchase the Lifetime plan ($15) to unlock this component. +

+
+ +
+ ); +} +`; + async function getComponentData(filePath: string, componentsDir: string) { const content = await fs.readFile(filePath, "utf-8") const filename = path.basename(filePath, path.extname(filePath)) - - // Extract dependencies + + const isPro = isProFile(filePath); + const resolvedContent = isPro ? PRO_PLACEHOLDER_CONTENT : content; + const dependencies = new Set() const registryDependencies = new Set() - - // Regex for imports + const importRegex = /import\s+(?:[\w\s{},*]+)\s+from\s+['"]([^'"]+)['"]/g let match - - while ((match = importRegex.exec(content)) !== null) { + + while ((match = importRegex.exec(resolvedContent)) !== null) { const importPath = match[1] - + if (importPath.startsWith("@/components/ui/") || importPath.startsWith(`@/components/${COMPONENTS_DIR_NAME}/`)) { const depName = path.basename(importPath, path.extname(importPath)) registryDependencies.add(depName) @@ -46,18 +118,17 @@ async function getComponentData(filePath: string, componentsDir: string) { files: [ { path: relativePath, - content: content, + content: resolvedContent, type: "registry:component" } ] } } -// Recursively find all component files async function getAllComponentFiles(dir: string, fileList: string[] = []) { try { const entries = await fs.readdir(dir, { withFileTypes: true }) - + for (const entry of entries) { const fullPath = path.join(dir, entry.name) if (entry.isDirectory()) { @@ -82,7 +153,7 @@ async function findComponentFiles( ): Promise { try { const entries = await fs.readdir(dir, { withFileTypes: true }) - + for (const entry of entries) { if (entry.isDirectory()) { await findComponentFiles(path.join(dir, entry.name), filename, matches) @@ -115,13 +186,11 @@ export async function GET( { params }: { params: Promise<{ name: string }> } ) { const { name } = await params - - // Basic validation & Sanitization + if (!name || !name.endsWith(".json")) { return new NextResponse("Invalid request", { status: 400 }) } - // Sanitize input to prevent path traversal const safeName = path.basename(name) if (safeName !== name) { return new NextResponse("Invalid request path", { status: 400 }) @@ -130,12 +199,10 @@ export async function GET( const componentsDir = path.join(process.cwd(), "components", COMPONENTS_DIR_NAME) const componentName = safeName.replace(".json", "") - // Validate component name format (alphanumeric + hyphens only) if (!/^[a-zA-Z0-9-]+$/.test(componentName) && componentName !== "index" && componentName !== "registry") { return new NextResponse("Invalid component name", { status: 400 }) } - // Handle Index / Registry request if (componentName === "index" || componentName === "registry") { try { const allFiles = await getAllComponentFiles(componentsDir) @@ -147,7 +214,6 @@ export async function GET( } } - // Handle Individual Component request const matches = await findComponentFiles(componentsDir, `${componentName}.tsx`) const filePath = selectBestMatch(matches, componentName.endsWith("-demo")) diff --git a/components/bilalUi/component-preview.tsx b/components/bilalUi/component-preview.tsx index cee38ea..1f39ab2 100644 --- a/components/bilalUi/component-preview.tsx +++ b/components/bilalUi/component-preview.tsx @@ -17,6 +17,7 @@ import { AnimatePresence, motion } from "motion/react"; import { OpenInV0Button } from "./open-in-v0-button"; import { Spinner } from "@/components/ui/spinner"; import { CodeBlock } from "@/components/ui/code-block"; +import { ProLock } from "./pro/pro-lock"; function SuccessParticles({ buttonRef, @@ -93,6 +94,7 @@ interface ComponentPreviewProps { * npx shadcn@latest add {NEXT_PUBLIC_APP_URL}/registry/{registry} */ registry?: string; + pro?: boolean; } export function ComponentPreview({ @@ -103,6 +105,7 @@ export function ComponentPreview({ sourceName, installCommand, registry, + pro, }: ComponentPreviewProps) { const [isCopied, setIsCopied] = React.useState(false); const [isInstallCopied, setIsInstallCopied] = React.useState(false); @@ -331,10 +334,16 @@ export function ComponentPreview({
- + {pro ? ( +
+ +
+ ) : ( + + )}
diff --git a/components/bilalUi/pricing/pricing-card.tsx b/components/bilalUi/pricing/pricing-card.tsx index 0feed48..df507d5 100644 --- a/components/bilalUi/pricing/pricing-card.tsx +++ b/components/bilalUi/pricing/pricing-card.tsx @@ -1,7 +1,8 @@ "use client"; +import { useState } from "react"; import { motion } from "motion/react"; -import { Check, ArrowRight, Crown, Sparkles } from "lucide-react"; +import { Check, ArrowRight, Crown, Sparkles, Loader2 } from "lucide-react"; import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; @@ -19,6 +20,7 @@ interface PricingCardProps { popular?: boolean; badge?: string; comingSoon?: boolean; + checkout?: boolean; className?: string; } @@ -33,6 +35,7 @@ export function PricingCard({ popular, badge, comingSoon, + checkout, className, }: PricingCardProps) { if (popular) { @@ -47,6 +50,7 @@ export function PricingCard({ href={href} badge={badge} comingSoon={comingSoon} + checkout={checkout} className={className} /> ); @@ -97,15 +101,13 @@ function FreeCard({ initial={{ opacity: 0, x: -30 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.5, ease: "easeOut" }} - whileHover={{ y: -4 }} + whileHover={{ y: -2 }} className={cn( - "relative h-full overflow-hidden rounded-2xl border border-zinc-200 bg-white p-8 transition-shadow duration-300 hover:shadow-lg dark:border-zinc-800 dark:bg-zinc-950", + "relative h-full overflow-hidden rounded-2xl border border-zinc-200 bg-white p-8 dark:border-zinc-800 dark:bg-zinc-950", className, )} > -
- -
+

{name} @@ -164,6 +166,7 @@ function PremiumCard({ href, badge: badgeLabel, comingSoon, + checkout, className, }: { name: string; @@ -175,38 +178,34 @@ function PremiumCard({ href: string; badge?: string; comingSoon?: boolean; + checkout?: boolean; className?: string; }) { + const [loading, setLoading] = useState(false); + + async function handleCheckout() { + setLoading(true); + try { + const res = await fetch("/api/lemon/checkout", { method: "POST" }); + const data = await res.json(); + if (data.url) { + window.location.href = data.url; + } + } catch { + setLoading(false); + } + } return ( - {/* Animated gradient orbs */} - - - - {/* Shimmer line */} - -
@@ -214,9 +213,9 @@ function PremiumCard({
-
+
-

+

{name}

@@ -234,42 +233,66 @@ function PremiumCard({
    - {features.map((feature, i) => ( - - {isFeatureMore(feature) ? ( - - ) : ( - - )} - {getFeatureLabel(feature)} - {isFeatureSoon(feature) && ( - Soon - )} - - ))} + {features.map((feature, i) => { + const colors = [ + "text-amber-500", + "text-emerald-500", + "text-sky-500", + "text-violet-500", + "text-pink-500", + "text-cyan-500", + "text-rose-500", + "text-indigo-500", + ]; + const iconColor = isFeatureMore(feature) ? "text-amber-500" : (colors[i % colors.length]); + return ( + + {isFeatureMore(feature) ? ( + + ) : ( + + )} + {getFeatureLabel(feature)} + {isFeatureSoon(feature) && ( + Soon + )} + + ); + })}
- - {comingSoon ? "Coming Soon" : cta} - {!comingSoon && } - + {checkout && !comingSoon ? ( + + ) : ( + + {comingSoon ? "Coming Soon" : cta} + {!comingSoon && } + + )}
diff --git a/components/bilalUi/pro/index.ts b/components/bilalUi/pro/index.ts index 1fd4028..0225402 100644 --- a/components/bilalUi/pro/index.ts +++ b/components/bilalUi/pro/index.ts @@ -3,3 +3,4 @@ export { ProBadge } from "./pro-badge"; export { ProCard } from "./pro-card"; export { ProFeatureCard } from "./pro-feature-card"; export { ProComingSoon } from "./pro-coming-soon"; +export { ProLock } from "./pro-lock"; diff --git a/components/bilalUi/pro/license-dialog.tsx b/components/bilalUi/pro/license-dialog.tsx new file mode 100644 index 0000000..cffb710 --- /dev/null +++ b/components/bilalUi/pro/license-dialog.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState } from "react"; +import { Crown, Check, X, Loader2, KeyRound } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; + +interface LicenseDialogProps { + open: boolean; + onClose: () => void; + onVerify: (key: string) => Promise<{ success: boolean; error?: string }>; +} + +export function LicenseDialog({ + open, + onClose, + onVerify, +}: LicenseDialogProps) { + const [key, setKey] = useState(""); + const [status, setStatus] = useState< + "idle" | "loading" | "success" | "error" + >("idle"); + const [error, setError] = useState(""); + + if (!open) return null; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!key.trim()) return; + + setStatus("loading"); + setError(""); + + const result = await onVerify(key.trim()); + + if (result.success) { + setStatus("success"); + setTimeout(() => { + onClose(); + setKey(""); + setStatus("idle"); + }, 1200); + } else { + setStatus("error"); + setError(result.error ?? "Invalid license key"); + } + } + + return ( +
+
+
+
+ + + Pro License + + +
+ +

+ Enter your license key +

+

+ Paste the license key you received after purchase to unlock all + Pro components. +

+ +
+
+ + { + setKey(e.target.value); + if (status === "error") setStatus("idle"); + }} + placeholder="XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX" + className="w-full rounded-xl border border-zinc-800 bg-zinc-900 py-2.5 pl-10 pr-3 text-sm text-white placeholder:text-zinc-600 focus:border-zinc-600 focus:outline-none focus:ring-1 focus:ring-zinc-600" + disabled={status === "loading" || status === "success"} + autoFocus + /> +
+ + {status === "error" && ( +

+ + {error} +

+ )} + + {status === "success" && ( +

+ + License verified! Unlocking Pro... +

+ )} + + +
+ +

+ Didn't get a key? Check your email receipt or{" "} + + visit LemonSqueezy orders + +

+
+
+
+ ); +} diff --git a/components/bilalUi/pro/pro-lock.tsx b/components/bilalUi/pro/pro-lock.tsx new file mode 100644 index 0000000..46b9d01 --- /dev/null +++ b/components/bilalUi/pro/pro-lock.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { Crown, LockKeyhole, Sparkles, KeyRound } from "lucide-react"; +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { useProAccess } from "@/hooks/use-pro-access"; +import { LicenseDialog } from "./license-dialog"; + +interface ProLockProps { + label?: string; + className?: string; +} + +export function ProLock({ + label = "This is a Pro component", + className, +}: ProLockProps) { + const { hasAccess, verifyAndStore } = useProAccess(); + const [showDialog, setShowDialog] = useState(false); + + if (hasAccess) { + return null; + } + + return ( + <> +
+
+
+ +
+ + + + Pro + + +
+

{label}

+

+ Unlock all premium component variants, page blocks, and full + templates with a single $15 payment. +

+
+ +
+ + + Unlock with Pro + + +
+
+
+ + setShowDialog(false)} + onVerify={verifyAndStore} + /> + + ); +} diff --git a/config/lemon.ts b/config/lemon.ts new file mode 100644 index 0000000..5bc8007 --- /dev/null +++ b/config/lemon.ts @@ -0,0 +1,18 @@ +export function getLemonConfig() { + return { + apiKey: process.env.LEMONSQUEEZY_API_KEY!, + storeId: Number(process.env.LEMONSQUEEZY_STORE_ID!), + productId: Number(process.env.LEMONSQUEEZY_PRODUCT_ID!), + variantId: Number(process.env.LEMONSQUEEZY_VARIANT_ID!), + webhookSecret: process.env.LEMONSQUEEZY_WEBHOOK_SECRET!, + }; +} + +export function isLemonConfigured() { + return ( + !!process.env.LEMONSQUEEZY_API_KEY && + !!process.env.LEMONSQUEEZY_STORE_ID && + !!process.env.LEMONSQUEEZY_PRODUCT_ID && + !!process.env.LEMONSQUEEZY_VARIANT_ID + ); +} diff --git a/config/navigation.ts b/config/navigation.ts index 72732b6..c33f9da 100644 --- a/config/navigation.ts +++ b/config/navigation.ts @@ -337,34 +337,52 @@ export const navigationSections: NavSection[] = [ items: [ { id: "blocks-introduction", - title: "Introduction", + title: "Overview", href: "/docs/blocks/introduction", - description: "Overview of reusable multi-component blocks", + description: "Browse all reusable multi-component blocks", icon: "Map", }, { - id: "blocks-hero-sections", - title: "Hero Sections", - href: "/docs/blocks/hero-sections", - description: "Ready-to-use hero block patterns", + id: "blocks-hero", + title: "Hero", + href: "/docs/blocks/hero", + description: "Hero section blocks for landing pages", icon: "PanelTop", - isComingSoon: true, }, { - id: "blocks-pricing-sections", - title: "Pricing Sections", - href: "/docs/blocks/pricing-sections", - description: "Composable pricing table and plan blocks", - icon: "ListFilter", - isComingSoon: true, + id: "blocks-features", + title: "Features", + href: "/docs/blocks/features", + description: "Feature grid and showcase blocks", + icon: "LayoutGrid", }, { - id: "blocks-dashboard-shells", - title: "Dashboard Shells", - href: "/docs/blocks/dashboard-shells", - description: "Starter dashboard structures and app shells", - icon: "RectangleEllipsis", - isComingSoon: true, + id: "blocks-cta", + title: "CTA", + href: "/docs/blocks/cta", + description: "Call-to-action blocks", + icon: "MousePointerClick", + }, + { + id: "blocks-dashboard", + title: "Dashboard", + href: "/docs/blocks/dashboard", + description: "Dashboard layout blocks", + icon: "LayoutDashboard", + }, + { + id: "blocks-pricing", + title: "Pricing", + href: "/docs/blocks/pricing", + description: "Pricing table blocks", + icon: "CreditCard", + }, + { + id: "blocks-testimonials", + title: "Testimonials", + href: "/docs/blocks/testimonials", + description: "Testimonial and social proof blocks", + icon: "MessageSquareQuote", }, ], }, diff --git a/config/pro.ts b/config/pro.ts new file mode 100644 index 0000000..c549a21 --- /dev/null +++ b/config/pro.ts @@ -0,0 +1,56 @@ +export const PRO_FREE_MODE = true; + +export const PRO_COMPONENTS: Record = { + button: { freeCount: 5 }, + badge: { freeCount: 5 }, + accordion: { freeCount: 5 }, + avatar: { freeCount: 5 }, + breadcrumb: { freeCount: 5 }, + checkbox: { freeCount: 5 }, + dialog: { freeCount: 5 }, + drawer: { freeCount: 5 }, + "dropdown-menu": { freeCount: 5 }, + input: { freeCount: 5 }, + "input-otp": { freeCount: 5 }, + popover: { freeCount: 5 }, + progress: { freeCount: 5 }, + "radio-group": { freeCount: 5 }, + select: { freeCount: 5 }, + separator: { freeCount: 5 }, + sheet: { freeCount: 5 }, + skeleton: { freeCount: 5 }, + slider: { freeCount: 5 }, + switch: { freeCount: 5 }, + tabs: { freeCount: 5 }, + textarea: { freeCount: 5 }, + toast: { freeCount: 5 }, + "toggle-group": { freeCount: 5 }, + tooltip: { freeCount: 5 }, + card: { freeCount: 3 }, + "date-picker": { freeCount: 2 }, + alert: { freeCount: 4 }, +}; + +export function isProComponent( + componentName: string, + variantIndex?: number, +): boolean { + if (PRO_FREE_MODE) return false; + + if (!variantIndex !== undefined && variantIndex === undefined) { + return false; + } + + const config = PRO_COMPONENTS[componentName]; + if (!config) return false; + + if (variantIndex !== undefined) { + return variantIndex >= config.freeCount; + } + + return true; +} + +export function getFreeCount(componentName: string): number { + return PRO_COMPONENTS[componentName]?.freeCount ?? 5; +} diff --git a/content/docs/blocks/cta.mdx b/content/docs/blocks/cta.mdx new file mode 100644 index 0000000..26ba9aa --- /dev/null +++ b/content/docs/blocks/cta.mdx @@ -0,0 +1,6 @@ +--- +title: CTA +description: Call-to-action blocks for conversions and user engagement. +--- + +CTA blocks are coming soon. These will include centered CTAs, banner CTAs, and multi-action layouts. diff --git a/content/docs/blocks/dashboard.mdx b/content/docs/blocks/dashboard.mdx new file mode 100644 index 0000000..16a28bb --- /dev/null +++ b/content/docs/blocks/dashboard.mdx @@ -0,0 +1,6 @@ +--- +title: Dashboard +description: Dashboard shell and layout blocks for admin panels and apps. +--- + +Dashboard blocks are coming soon. These will include sidebar layouts, analytics panels, and data tables. diff --git a/content/docs/blocks/features.mdx b/content/docs/blocks/features.mdx new file mode 100644 index 0000000..b2b095f --- /dev/null +++ b/content/docs/blocks/features.mdx @@ -0,0 +1,6 @@ +--- +title: Features +description: Feature grid and showcase blocks for highlighting product capabilities. +--- + +Features blocks are coming soon. These will include icon grids, numbered lists, and feature comparison layouts. diff --git a/content/docs/blocks/hero.mdx b/content/docs/blocks/hero.mdx new file mode 100644 index 0000000..24abadc --- /dev/null +++ b/content/docs/blocks/hero.mdx @@ -0,0 +1,6 @@ +--- +title: Hero +description: Hero section blocks for landing pages and marketing sites. +--- + +Hero section blocks are coming soon. These will include centered heroes, SaaS heroes, and split layout options. diff --git a/content/docs/blocks/introduction.mdx b/content/docs/blocks/introduction.mdx index 5f41bfb..1bf7cb7 100644 --- a/content/docs/blocks/introduction.mdx +++ b/content/docs/blocks/introduction.mdx @@ -3,11 +3,7 @@ title: Blocks description: Multi-component sections you can copy and ship as complete layouts. --- -import { Callout } from "@/components/ui/callout"; - Blocks are reusable section-level UI patterns made from multiple components. They are designed to help you ship complete pages faster. - - Block templates are being prepared and will roll out soon. - +Choose a category from the sidebar to browse available blocks. diff --git a/content/docs/blocks/meta.json b/content/docs/blocks/meta.json index 5102013..57a4aa1 100644 --- a/content/docs/blocks/meta.json +++ b/content/docs/blocks/meta.json @@ -1,5 +1,11 @@ { "pages": [ - "introduction" + "introduction", + "hero", + "features", + "cta", + "dashboard", + "pricing", + "testimonials" ] } diff --git a/content/docs/blocks/pricing.mdx b/content/docs/blocks/pricing.mdx new file mode 100644 index 0000000..0ce502e --- /dev/null +++ b/content/docs/blocks/pricing.mdx @@ -0,0 +1,6 @@ +--- +title: Pricing +description: Pricing table and plan comparison blocks. +--- + +Pricing blocks are coming soon. These will include tiered pricing, comparison tables, and feature breakdowns. diff --git a/content/docs/blocks/testimonials.mdx b/content/docs/blocks/testimonials.mdx new file mode 100644 index 0000000..24b4ada --- /dev/null +++ b/content/docs/blocks/testimonials.mdx @@ -0,0 +1,6 @@ +--- +title: Testimonials +description: Testimonial and social proof blocks for building trust. +--- + +Testimonials blocks are coming soon. These will include card grids, carousels, and featured quote layouts. diff --git a/content/docs/components/badge.mdx b/content/docs/components/badge.mdx index 24cea72..42a8a88 100644 --- a/content/docs/components/badge.mdx +++ b/content/docs/components/badge.mdx @@ -70,21 +70,21 @@ Token-style badges with removable actions. ### Badge With Icon Leading icon treatment for plan and state labels. - + ### Shortcut Badge Badges paired with keyboard shortcuts for quick actions. - + ### Badge Status Row One state shown in all four appearances. - + @@ -94,6 +94,7 @@ Alpha through deprecated release lifecycle examples. @@ -101,7 +102,7 @@ Alpha through deprecated release lifecycle examples. ### Changelog Badge Release note labels for updates, fixes, docs, and perf changes. - + @@ -111,6 +112,7 @@ Flags for runtime, lab, legacy, and headless availability. @@ -121,6 +123,7 @@ Circular count badges for inbox, alerts, and review totals. @@ -128,62 +131,62 @@ Circular count badges for inbox, alerts, and review totals. ### Plan Tiers Badge Pricing and plan emphasis with larger badge sizing. - + ### Tag Cloud Badge Mixed-size tags with removable actions. - + ### Status Pills Badge Rounded status pills including a live animated dot state. - + ### Special Types Badge WIP, lab, request, and featured metadata styles. - + ### Outline Row Badge Outline treatment across many supported badge variants. - + ### Ghost Row Badge Ghost appearance examples for lightweight metadata. - + ### Inline Text Badge Inline badge usage inside prose and UI copy. - + ### Disabled Badge Disabled states across multiple variants and appearances. - + ### Version Table Badge Structured package table with version and lifecycle badges. - + diff --git a/content/docs/components/button.mdx b/content/docs/components/button.mdx index 87aaed7..b3a307b 100644 --- a/content/docs/components/button.mdx +++ b/content/docs/components/button.mdx @@ -74,91 +74,91 @@ A vibrant gradient border button with multiple color schemes and animation effec ### Pulse Button Pulse ring emphasis for a primary action. - + Pulse ### Gradient Shift Button Animated gradient surface with hover emphasis. - + Gradient ### Slide Reveal Button Reveal effect on hover for text or icon swaps. - + Slide Reveal ### Bounce Button Subtle bounce animation for repeated calls to action. - + Bounce ### Shimmer Button Animated shimmer for loading or featured actions. - + Shimmer ### Rotate Button Micro-rotation feedback on hover. - + Rotate ### Glow Button Glowing outline on hover to draw attention. - + Glow ### Flip Button Perspective tilt for playful depth. - + Flip ### Fill Button Border-to-fill transition for secondary CTAs. - + Fill ### Float Button Slow floating motion for ambient emphasis. - + Float ### Neon Button Neon-like accent for dark surfaces. - + Neon ### Ink Spread Button Ink-like hover spread using pseudo-elements. - + Ink Spread ### Spring Button Springy scale response for tactile feedback. - + Spring @@ -168,6 +168,7 @@ Underline reveal for minimal text actions. Underline @@ -175,28 +176,28 @@ Underline reveal for minimal text actions. ### Ripple Button Click ripple feedback with motion. - + Ripple ### Expand Button Larger scale expansion on hover. - + Expand ### Glass Button Glassmorphism styling for translucent surfaces. - + Glass ### Slide Up Button Subtle upward motion on hover. - + Slide Up @@ -206,6 +207,7 @@ Disabled skeleton-style loading state. Loading @@ -216,6 +218,7 @@ Rotating gradient border treatment. Rotate Border diff --git a/hooks/use-pro-access.ts b/hooks/use-pro-access.ts new file mode 100644 index 0000000..3c67b73 --- /dev/null +++ b/hooks/use-pro-access.ts @@ -0,0 +1,49 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; + +const LICENSE_KEY = "bilal-ui-license-key"; +const LICENSE_STATUS = "bilal-ui-license-status"; + +export function useProAccess() { + const [hasAccess, setHasAccess] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const stored = localStorage.getItem(LICENSE_STATUS); + setHasAccess(stored === "verified"); + setLoading(false); + }, []); + + const verifyAndStore = useCallback(async (key: string) => { + setLoading(true); + try { + const res = await fetch("/api/lemon/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ licenseKey: key }), + }); + const data = await res.json(); + + if (data.valid) { + localStorage.setItem(LICENSE_KEY, key); + localStorage.setItem(LICENSE_STATUS, "verified"); + setHasAccess(true); + return { success: true }; + } + return { success: false, error: data.error ?? "Invalid license key" }; + } catch { + return { success: false, error: "Failed to verify license key" }; + } finally { + setLoading(false); + } + }, []); + + const clearAccess = useCallback(() => { + localStorage.removeItem(LICENSE_KEY); + localStorage.removeItem(LICENSE_STATUS); + setHasAccess(false); + }, []); + + return { hasAccess, loading, verifyAndStore, clearAccess }; +} diff --git a/lib/lemon.ts b/lib/lemon.ts new file mode 100644 index 0000000..49545ce --- /dev/null +++ b/lib/lemon.ts @@ -0,0 +1,61 @@ +import { lemonSqueezySetup, createCheckout } from "@lemonsqueezy/lemonsqueezy.js"; +import { getLemonConfig } from "@/config/lemon"; + +export function setupLemon() { + const { apiKey } = getLemonConfig(); + lemonSqueezySetup({ apiKey }); +} + +export async function createCheckoutUrl(opts?: { + redirectUrl?: string; + email?: string; + name?: string; +}): Promise { + setupLemon(); + const { storeId, variantId } = getLemonConfig(); + + const response = await createCheckout(storeId, variantId, { + productOptions: opts?.redirectUrl + ? { + redirectUrl: opts.redirectUrl, + enabledVariants: [variantId], + } + : { enabledVariants: [variantId] }, + checkoutData: { + email: opts?.email, + name: opts?.name, + }, + }); + + if (response.error) { + console.error("LemonSqueezy checkout error:", response.error); + return null; + } + + return response.data.data.attributes.url; +} + +export async function verifyLicenseKey( + licenseKey: string, +): Promise<{ valid: boolean; error?: string }> { + try { + const res = await fetch( + "https://api.lemonsqueezy.com/v1/licenses/validate", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ license_key: licenseKey }), + }, + ); + + const data = await res.json(); + + if (data.valid && data.license_key?.status === "active") { + return { valid: true }; + } + + return { valid: false, error: data.error ?? "License key is not valid" }; + } catch (err) { + return { valid: false, error: "Failed to verify license key" }; + } +} diff --git a/package-lock.json b/package-lock.json index 88e6efd..af76e9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@base-ui/react": "^1.2.0", "@hookform/resolvers": "^5.2.2", + "@lemonsqueezy/lemonsqueezy.js": "^4.0.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.8", @@ -1664,6 +1665,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lemonsqueezy/lemonsqueezy.js": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@lemonsqueezy/lemonsqueezy.js/-/lemonsqueezy.js-4.0.0.tgz", + "integrity": "sha512-xcY1/lDrY7CpIF98WKiL1ElsfoVhddP7FT0fw7ssOzrFqQsr44HgolKrQZxd9SywsCPn12OTOUieqDIokI3mFg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@mdx-js/mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", diff --git a/package.json b/package.json index c1ae297..734a18e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@base-ui/react": "^1.2.0", "@hookform/resolvers": "^5.2.2", + "@lemonsqueezy/lemonsqueezy.js": "^4.0.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.8",