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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions app/(root)/pricing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import {
PricingFAQ,
PricingCTA,
} from "@/components/bilalUi/pricing";
import { isLemonConfigured } from "@/config/lemon";

const lemonReady = isLemonConfigured();

const plans = [
{
Expand Down Expand Up @@ -44,7 +47,9 @@ const plans = [
cta: "Get Lifetime Access",
href: "#",
popular: true,
badge: "Coming Soon",
comingSoon: true,
checkout: false,
},
];

Expand Down Expand Up @@ -130,7 +135,7 @@ export default function PricingPage() {
<section className="px-4 pb-20">
<div className="mx-auto max-w-4xl grid grid-cols-1 md:grid-cols-2 gap-8 items-stretch">
{plans.map((plan) => (
<PricingCard key={plan.name} {...plan} />
<PricingCard key={plan.name} {...plan} checkout={plan.checkout} />
))}
</div>
</section>
Expand All @@ -148,7 +153,7 @@ export default function PricingPage() {

<PricingCTA
description="Join the community and get lifetime access to every block, template, and premium component for just $15."
primaryCta={{ label: "Get Lifetime Access — $15", href: "#" }}
primaryCta={{ label: "Get Lifetime Access — $15", href: lemonReady ? "/api/lemon/checkout" : "#" }}
secondaryCta={{ label: "Browse Free Components", href: "/docs/components/button" }}
/>
</main>
Expand Down
28 changes: 28 additions & 0 deletions app/api/debug/env/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};

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,
});
}
58 changes: 58 additions & 0 deletions app/api/lemon/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
18 changes: 18 additions & 0 deletions app/api/lemon/verify/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
39 changes: 39 additions & 0 deletions app/api/lemon/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
84 changes: 79 additions & 5 deletions app/api/source/[name]/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-zinc-200 bg-white px-6 py-12 text-center dark:border-zinc-800 dark:bg-zinc-950">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-violet-100 dark:bg-violet-900/30">
<LockKeyhole className="h-6 w-6 text-violet-600 dark:text-violet-400" />
</div>
<div className="space-y-1">
<Badge variant="pro" appearance="outline">
<Crown className="h-3 w-3" />
Pro Component
</Badge>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Purchase the Lifetime plan ($15) to unlock this component.
</p>
</div>
<Button asChild>
<Link href="/pricing">
<Sparkles className="h-3.5 w-3.5" />
Unlock with Pro
</Link>
</Button>
</div>
);
}
`;

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 })
Expand All @@ -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" },
Expand Down
Loading