diff --git a/.env.example b/.env.example index 41d5a71..309eb46 100644 --- a/.env.example +++ b/.env.example @@ -3,17 +3,19 @@ DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require # GitHub OAuth App # Create at: https://github.com/settings/developers -GITHUB_CLIENT_ID=0v23li58m3t8TIbfIr8A +GITHUB_CLIENT_ID=your-github-client-id # Optional fallback for older deployments. Client-side/public only, not a secret. -NEXT_PUBLIC_GITHUB_CLIENT_ID=Ov231iS8m3t8TIbfIr8A -GITHUB_CLIENT_SECRET=8643494973801aaade8e7cd77ee89d3700 -2ce897 +NEXT_PUBLIC_GITHUB_CLIENT_ID=your-github-client-id +GITHUB_CLIENT_SECRET=your-github-client-secret # Public URL of your app (used for OAuth callback redirect) -NEXT_PUBLIC_APP_URL=https://repo-app-architect.vercel.app +NEXT_PUBLIC_APP_URL=your-app-url -# OpenAI API Key (used by Vercel AI SDK for analysis) -OPENAI_API_KEY=sk-... - -# Anthropic API Key (used for scaffold generation) +# Anthropic API Key (used for analysis and scaffold generation) ANTHROPIC_API_KEY=sk-ant-... + +# Optional Claude model override for analysis +ANTHROPIC_ANALYSIS_MODEL=claude-3-5-sonnet-20241022 + +# Stripe Checkout +STRIPE_SECRET_KEY=sk_test_... diff --git a/app/api/analyses/[id]/route.ts b/app/api/analyses/[id]/route.ts index 0cfe7e4..8f62e49 100644 --- a/app/api/analyses/[id]/route.ts +++ b/app/api/analyses/[id]/route.ts @@ -1,36 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getAnalysisById, getBlueprintsByAnalysis, getRepositoriesForAnalysis } from '@/lib/queries' -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { - try { - const { id } = await params - const analysis = await getAnalysisById(id) - if (!analysis) { - return NextResponse.json({ error: 'Analysis not found' }, { status: 404 }) - } - - const [repositories, blueprints] = await Promise.all([ - getRepositoriesForAnalysis(id), - getBlueprintsByAnalysis(id), - ]) - - return NextResponse.json({ - ...analysis, - repositories, - blueprints, - apps: blueprints, - }) - } catch (error) { - console.error('Error fetching analysis details:', error) - return NextResponse.json({ error: 'Failed to fetch analysis details' }, { status: 500 }) - } -} -import { NextRequest, NextResponse } from 'next/server' -import { getAnalysisById, getBlueprintsByAnalysis, getRepositoriesForAnalysis } from '@/lib/queries' - export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, diff --git a/app/api/analyses/[id]/run/route.ts b/app/api/analyses/[id]/run/route.ts index 6db1417..d7e6763 100644 --- a/app/api/analyses/[id]/run/route.ts +++ b/app/api/analyses/[id]/run/route.ts @@ -1,8 +1,11 @@ import { NextRequest } from 'next/server' -import { generateText, Output } from 'ai' -import { z } from 'zod' +import Anthropic from '@anthropic-ai/sdk' import { getCurrentAccessToken } from '@/lib/auth' import { getGitHubRepositoryTree } from '@/lib/github' +import { + getAnthropicAnalysisModel, + parseAnalysisBlueprintOutput, +} from '@/lib/analysis-blueprints' import { getAnalysisById, getRepositoriesForAnalysis, @@ -13,29 +16,6 @@ import { getBlueprintsByAnalysis } from '@/lib/queries' -// Schema for AI-generated app blueprints -const BlueprintSchema = z.object({ - name: z.string(), - description: z.string(), - app_type: z.string(), - complexity: z.enum(['simple', 'moderate', 'complex']), - reuse_percentage: z.number().min(0).max(100), - existing_files: z.array(z.object({ - path: z.string(), - purpose: z.string(), - })), - missing_files: z.array(z.object({ - name: z.string(), - purpose: z.string(), - })), - technologies: z.array(z.string()), - explanation: z.string(), -}) - -const AnalysisOutputSchema = z.object({ - blueprints: z.array(BlueprintSchema), -}) - export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -57,8 +37,8 @@ export async function POST( controller.close() return } - if (!process.env.OPENAI_API_KEY) { - send({ error: 'AI analysis is not configured. Missing OPENAI_API_KEY.' }) + if (!process.env.ANTHROPIC_API_KEY) { + send({ error: 'AI analysis is not configured. Missing ANTHROPIC_API_KEY.' }) controller.close() return } @@ -132,11 +112,18 @@ export async function POST( // Build file summary for AI const fileSummary = allFiles.map(f => `- ${f.repo}: ${f.path}`).join('\n') + const anthropic = new Anthropic({ + apiKey: process.env.ANTHROPIC_API_KEY, + }) + // Use AI to analyze and discover app blueprints - const { output } = await generateText({ - model: 'openai/gpt-4o-mini', - output: Output.object({ schema: AnalysisOutputSchema }), - prompt: `You are an expert software architect. Analyze these files from GitHub repositories and discover what applications can be built by combining and reusing the existing code. + const response = await anthropic.messages.create({ + model: getAnthropicAnalysisModel(), + max_tokens: 4000, + temperature: 0.2, + messages: [{ + role: 'user', + content: `You are an expert software architect. Analyze these files from GitHub repositories and discover what applications can be built by combining and reusing the existing code. REPOSITORIES AND FILES: ${fileSummary} @@ -155,8 +142,31 @@ For each app blueprint: - List technologies detected - Provide a brief explanation of why this app is possible +Return only valid JSON with this shape: +{ + "blueprints": [ + { + "name": "string", + "description": "string", + "app_type": "string", + "complexity": "simple" | "moderate" | "complex", + "reuse_percentage": 0-100, + "existing_files": [{ "path": "repo/path.ext", "purpose": "string" }], + "missing_files": [{ "name": "path-or-feature", "purpose": "string" }], + "technologies": ["string"], + "explanation": "string" + } + ] +} + Focus on practical, buildable applications based on the actual code patterns you see.`, + }], }) + const text = response.content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join('\n') + const output = parseAnalysisBlueprintOutput(text) send({ status: 'analyzing', progress: 80 }) @@ -190,10 +200,11 @@ Focus on practical, buildable applications based on the actual code patterns you } catch (error) { console.error('Analysis error:', error) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' await updateAnalysisStatus(id, 'failed', { - error_message: error instanceof Error ? error.message : 'Unknown error' + error_message: errorMessage }) - send({ status: 'failed', error: 'Analysis failed' }) + send({ status: 'failed', error: errorMessage }) controller.close() } }, diff --git a/app/api/stripe/checkout/route.ts b/app/api/stripe/checkout/route.ts new file mode 100644 index 0000000..a215b06 --- /dev/null +++ b/app/api/stripe/checkout/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server' +import Stripe from 'stripe' + +import { getPlanById } from '@/lib/billing' + +const stripeSecretKey = process.env.STRIPE_SECRET_KEY +const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null + +function getBaseUrl(request: NextRequest) { + return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin +} + +export async function POST(request: NextRequest) { + try { + const { planId } = (await request.json()) as { planId?: string } + const plan = getPlanById(planId) + + if (!plan) { + return NextResponse.json({ error: 'Unknown billing plan.' }, { status: 400 }) + } + + if (!stripe || !process.env.STRIPE_SECRET_KEY) { + return NextResponse.json( + { error: 'Stripe is not configured. Set STRIPE_SECRET_KEY to enable Checkout.' }, + { status: 503 }, + ) + } + + const baseUrl = getBaseUrl(request) + const session = await stripe.checkout.sessions.create({ + mode: 'subscription', + billing_address_collection: 'auto', + allow_promotion_codes: true, + line_items: [ + { + price_data: { + currency: plan.currency, + recurring: { interval: plan.interval }, + unit_amount: plan.unitAmount, + product_data: { + name: plan.name, + description: plan.description, + metadata: { + plan_id: plan.id, + }, + }, + }, + quantity: 1, + }, + ], + metadata: { + plan_id: plan.id, + }, + success_url: `${baseUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${baseUrl}/billing/cancel`, + }) + + return NextResponse.json({ url: session.url }) + } catch (error) { + console.error('Error creating Stripe Checkout session:', error) + return NextResponse.json({ error: 'Failed to create Checkout session.' }, { status: 500 }) + } +} diff --git a/app/billing/cancel/page.tsx b/app/billing/cancel/page.tsx new file mode 100644 index 0000000..30ddba6 --- /dev/null +++ b/app/billing/cancel/page.tsx @@ -0,0 +1,43 @@ +import Link from 'next/link' +import { ArrowLeft, CreditCard, RefreshCcw } from 'lucide-react' + +import { AppLogo } from '@/components/app-logo' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + +export default function BillingCancelPage() { + return ( +
+
+ + + +
+ +
+ Checkout canceled +
+ +

+ No payment was completed. You can return to pricing and start a new secure Stripe Checkout session whenever you are ready. +

+
+ + +
+
+
+
+
+ ) +} diff --git a/app/billing/success/page.tsx b/app/billing/success/page.tsx new file mode 100644 index 0000000..2c11495 --- /dev/null +++ b/app/billing/success/page.tsx @@ -0,0 +1,44 @@ +import Link from 'next/link' +import { CheckCircle2, ArrowRight } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' + +export default async function BillingSuccessPage({ + searchParams, +}: { + searchParams: Promise<{ session_id?: string }> +}) { + const { session_id } = await searchParams + + return ( +
+ +
+ +
+

You are on the Pro launch path.

+

+ Stripe confirmed the checkout redirect. Once webhooks are connected, this is where CodeVault can + activate billing entitlements and show plan details. +

+ {session_id ? ( +

+ Checkout session: {session_id} +

+ ) : null} +
+ + +
+
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index fde5c59..27849ec 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,10 @@ import Link from 'next/link' import { Button } from '@/components/ui/button' -import { Github, Sparkles, Code2, Layers, ArrowRight, AlertCircle } from 'lucide-react' +import { Github, Sparkles, Code2, ArrowRight, AlertCircle, ShieldCheck, Workflow, FileJson2, CheckCircle2 } from 'lucide-react' +import { AppLogo } from '@/components/app-logo' +import { ThemeToggle } from '@/components/theme-toggle' +import { CheckoutButton } from '@/components/checkout-button' +import { CODEVAULT_PRO_PLAN } from '@/lib/billing' const ERROR_MESSAGES: Record = { auth_required: 'You must sign in to access the dashboard.', @@ -27,20 +31,17 @@ export default async function HomePage({ searchParams }: { searchParams: Promise {/* Header */}
-
-
- -
- CodeVault -
-
-

+

Discover Apps Hidden in Your Code

@@ -85,27 +86,31 @@ export default async function HomePage({ searchParams }: { searchParams: Promise

- {/* Social proof strip — established product feel */}

- Built for builders who already have the hard parts + Built for teams that need confidence before generation

-
+
-

12k+

-

repos scanned

-
-
-

4.1k

-

blueprints surfaced

+
+ +
+

Read-only by design

+

Connect repositories without granting write access to source code.

-

38

-

stacks detected

+
+ +
+

Cross-repo intelligence

+

Map reusable components, APIs, hooks, and utilities across your stack.

-

New

-

cross-repo fusion engine

+
+ +
+

Portable outputs

+

Export structured blueprints that explain what exists and what to build next.

@@ -143,6 +148,60 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
+
+
+

+ Stripe-powered billing +

+

+ Upgrade when your code map becomes a product roadmap +

+

+ Start with the core analysis workflow, then move teams onto a paid workspace when you need deeper exports and repeatable planning. +

+
+ +
+
+

Starter

+
+ $0 + / month +
+

+ Explore the product and connect repositories while you validate the workflow. +

+ +
+ +
+
+ Recommended +
+

{CODEVAULT_PRO_PLAN.name}

+
+ {CODEVAULT_PRO_PLAN.priceLabel} + / {CODEVAULT_PRO_PLAN.interval} +
+

{CODEVAULT_PRO_PLAN.description}

+
    + {CODEVAULT_PRO_PLAN.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ +

+ Secure checkout is hosted by Stripe. No card data touches CodeVault servers. +

+
+
+
+ {/* How It Works */}

How It Works

@@ -190,10 +249,7 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
-
- - CodeVault -
+

Built with Next.js and Vercel AI SDK

diff --git a/components/analysis-detail.tsx b/components/analysis-detail.tsx index 5ece695..e26965e 100644 --- a/components/analysis-detail.tsx +++ b/components/analysis-detail.tsx @@ -57,6 +57,7 @@ export function AnalysisDetail({ analysis, repositories, blueprints }: AnalysisD const [scaffoldLoadingId, setScaffoldLoadingId] = useState(null) const [isRunning, setIsRunning] = useState(false) const [status, setStatus] = useState(analysis.status) + const [errorMessage, setErrorMessage] = useState(analysis.error_message) const [progress, setProgress] = useState( analysis.total_files > 0 ? Math.round((analysis.analyzed_files / analysis.total_files) * 100) @@ -109,6 +110,7 @@ export function AnalysisDetail({ analysis, repositories, blueprints }: AnalysisD const runAnalysis = async () => { setIsRunning(true) setStatus('scanning') + setErrorMessage(null) setProgress(0) try { @@ -139,6 +141,10 @@ export function AnalysisDetail({ analysis, repositories, blueprints }: AnalysisD if (data.status) setStatus(data.status) if (data.progress !== undefined) setProgress(data.progress) if (data.blueprints) setLocalBlueprints(data.blueprints) + if (data.error) { + setErrorMessage(data.error) + setStatus('failed') + } } catch { // Skip invalid JSON } @@ -147,11 +153,12 @@ export function AnalysisDetail({ analysis, repositories, blueprints }: AnalysisD } } - setStatus('complete') + setStatus((currentStatus) => currentStatus === 'failed' ? 'failed' : 'complete') router.refresh() } catch (error) { console.error('Analysis error:', error) setStatus('failed') + setErrorMessage(error instanceof Error ? error.message : 'Analysis failed') } finally { setIsRunning(false) } @@ -176,6 +183,9 @@ export function AnalysisDetail({ analysis, repositories, blueprints }: AnalysisD {statusInfo.label}
+ {status === 'failed' && errorMessage && ( +

{errorMessage}

+ )} {(status === 'pending' || status === 'failed') && ( diff --git a/components/app-logo.tsx b/components/app-logo.tsx new file mode 100644 index 0000000..cc1395c --- /dev/null +++ b/components/app-logo.tsx @@ -0,0 +1,34 @@ +import Link from 'next/link' +import { Layers } from 'lucide-react' + +import { cn } from '@/lib/utils' + +interface AppLogoProps { + href?: string + className?: string + markClassName?: string + textClassName?: string +} + +export function AppLogo({ + href = '/', + className, + markClassName, + textClassName, +}: AppLogoProps) { + return ( + +
+ +
+ + CodeVault + + + ) +} diff --git a/components/checkout-button.tsx b/components/checkout-button.tsx new file mode 100644 index 0000000..dbf3327 --- /dev/null +++ b/components/checkout-button.tsx @@ -0,0 +1,57 @@ +'use client' + +import { useState } from 'react' +import { ArrowRight, Loader2 } from 'lucide-react' + +import { Button } from '@/components/ui/button' + +interface CheckoutButtonProps { + planId: string + children?: React.ReactNode + className?: string +} + +export function CheckoutButton({ planId, children, className }: CheckoutButtonProps) { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + async function handleCheckout() { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/stripe/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ planId }), + }) + const payload = (await response.json()) as { url?: string; error?: string } + + if (!response.ok || !payload.url) { + setError(payload.error ?? 'Unable to start checkout.') + return + } + + window.location.href = payload.url + } catch { + setError('Unable to reach Stripe checkout. Please try again.') + } finally { + setIsLoading(false) + } + } + + return ( +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} diff --git a/components/dashboard-header.tsx b/components/dashboard-header.tsx index c31739d..3be3fce 100644 --- a/components/dashboard-header.tsx +++ b/components/dashboard-header.tsx @@ -2,9 +2,11 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' -import { Layers, Github, BarChart3, FolderGit2, Sparkles } from 'lucide-react' +import { Github, BarChart3, FolderGit2, Sparkles } from 'lucide-react' import { cn } from '@/lib/utils' import type { AuthUser } from '@/lib/auth' +import { AppLogo } from '@/components/app-logo' +import { ThemeToggle } from '@/components/theme-toggle' interface DashboardHeaderProps { user: AuthUser | null @@ -24,12 +26,7 @@ export function DashboardHeader({ user }: DashboardHeaderProps) {
- -
- -
- CodeVault - +
-
+
+ {user ? (
diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx new file mode 100644 index 0000000..c1a9a44 --- /dev/null +++ b/components/theme-toggle.tsx @@ -0,0 +1,51 @@ +'use client' + +import { Monitor, Moon, Sun } from 'lucide-react' +import { useTheme } from 'next-themes' + +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' + +const themes = [ + { value: 'light', label: 'Light', icon: Sun }, + { value: 'dark', label: 'Dark', icon: Moon }, + { value: 'system', label: 'System', icon: Monitor }, +] + +export function ThemeToggle() { + const { setTheme, theme } = useTheme() + + return ( + + + + + + {themes.map((item) => { + const Icon = item.icon + return ( + setTheme(item.value)} + className="justify-between" + > + + + {item.label} + + {theme === item.value ? : null} + + ) + })} + + + ) +} diff --git a/lib/analysis-blueprints.ts b/lib/analysis-blueprints.ts new file mode 100644 index 0000000..d7d0d34 --- /dev/null +++ b/lib/analysis-blueprints.ts @@ -0,0 +1,258 @@ +import { z } from 'zod' + +export const BlueprintSchema = z.object({ + name: z.string().min(1), + description: z.string(), + app_type: z.string(), + complexity: z.enum(['simple', 'moderate', 'complex']), + reuse_percentage: z.number().min(0).max(100), + existing_files: z.array(z.object({ + path: z.string().min(1), + purpose: z.string(), + })), + missing_files: z.array(z.object({ + name: z.string().min(1), + purpose: z.string(), + })), + technologies: z.array(z.string()), + explanation: z.string(), +}) + +export const AnalysisOutputSchema = z.object({ + blueprints: z.array(BlueprintSchema).min(1), +}) + +export type AnalysisBlueprint = z.infer +export type AnalysisOutput = z.infer + +export class BlueprintParseError extends Error { + constructor(message = 'AI returned blueprints in an unexpected shape. Try again or set ANTHROPIC_ANALYSIS_MODEL to a supported Claude model.') { + super(message) + this.name = 'BlueprintParseError' + } +} + +export function getAnthropicAnalysisModel(): string { + const model = process.env.ANTHROPIC_ANALYSIS_MODEL?.trim() || 'claude-3-5-sonnet-20241022' + if (!model.startsWith('claude-')) { + throw new Error('ANTHROPIC_ANALYSIS_MODEL must be a supported Claude model name.') + } + return model +} + +export function parseAnalysisBlueprintOutput(text: string): AnalysisOutput { + for (const candidate of getJsonCandidates(text)) { + const parsed = safeJsonParse(candidate) + if (parsed.ok) { + const normalized = normalizeBlueprintResponse(parsed.value) + if (normalized) return normalized + } + } + + throw new BlueprintParseError() +} + +export function normalizeBlueprintResponse(value: unknown): AnalysisOutput | null { + const rawBlueprints = getBlueprintArray(value) + if (!rawBlueprints) return null + + const blueprints = rawBlueprints + .map(normalizeBlueprint) + .filter((blueprint): blueprint is AnalysisBlueprint => blueprint !== null) + + if (blueprints.length === 0) return null + + const result = AnalysisOutputSchema.safeParse({ blueprints }) + return result.success ? result.data : null +} + +function getBlueprintArray(value: unknown): unknown[] | null { + if (Array.isArray(value)) return value + if (!isRecord(value)) return null + + for (const key of ['blueprints', 'apps', 'applications', 'suggestions', 'elements']) { + const child = value[key] + if (Array.isArray(child)) return child + } + + for (const key of ['result', 'output', 'data']) { + const nested = getBlueprintArray(value[key]) + if (nested) return nested + } + + return null +} + +function normalizeBlueprint(value: unknown): AnalysisBlueprint | null { + if (!isRecord(value)) return null + + const name = getString(value, ['name', 'app_name', 'appName', 'App name']) + if (!name) return null + + const existingFiles = normalizeExistingFiles(getArray(value, ['existing_files', 'existingFiles', 'Existing files', 'files_to_reuse', 'filesToReuse'])) + const missingFiles = normalizeMissingFiles(getArray(value, ['missing_files', 'missingFiles', 'Missing files', 'required_files', 'requiredFiles'])) + const reusePercentage = getNumber(value, ['reuse_percentage', 'reusePercentage', 'reusablePercentage', 'completionPercentage', 'reuse']) + ?? calculateReusePercentage(existingFiles.length, missingFiles.length) + + const blueprint = { + name, + description: getString(value, ['description', 'Description']) || 'No description provided.', + app_type: getString(value, ['app_type', 'appType', 'type', 'Type', 'category']) || 'Application', + complexity: normalizeComplexity(getString(value, ['complexity', 'difficulty', 'Difficulty level'])), + reuse_percentage: clampPercentage(reusePercentage), + existing_files: existingFiles, + missing_files: missingFiles, + technologies: normalizeStringArray(getArray(value, ['technologies', 'Core technologies', 'tech_stack', 'techStack'])), + explanation: getString(value, ['explanation', 'ai_explanation', 'reasoning', 'Why this is a good idea']) || '', + } + + const result = BlueprintSchema.safeParse(blueprint) + return result.success ? result.data : null +} + +function normalizeExistingFiles(files: unknown[]): AnalysisBlueprint['existing_files'] { + return files.map((file) => { + if (typeof file === 'string') { + return { path: file, purpose: 'Reusable existing file' } + } + if (isRecord(file)) { + const path = getString(file, ['path', 'name', 'file', 'filename']) + if (!path) return null + return { + path, + purpose: getString(file, ['purpose', 'description', 'reason']) || 'Reusable existing file', + } + } + return null + }).filter((file): file is AnalysisBlueprint['existing_files'][number] => file !== null) +} + +function normalizeMissingFiles(files: unknown[]): AnalysisBlueprint['missing_files'] { + return files.map((file) => { + if (typeof file === 'string') { + return { name: file, purpose: 'Implementation needed' } + } + if (isRecord(file)) { + const name = getString(file, ['name', 'path', 'file', 'filename']) + if (!name) return null + return { + name, + purpose: getString(file, ['purpose', 'description', 'reason']) || 'Implementation needed', + } + } + return null + }).filter((file): file is AnalysisBlueprint['missing_files'][number] => file !== null) +} + +function getJsonCandidates(text: string): string[] { + const trimmed = text.trim() + const candidates = [trimmed] + + for (const match of trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) { + candidates.push(match[1].trim()) + } + + candidates.push(...extractBalancedJson(trimmed)) + return [...new Set(candidates.filter(Boolean))] +} + +function extractBalancedJson(text: string): string[] { + const candidates: string[] = [] + const stack: string[] = [] + let start = -1 + let inString = false + let escaped = false + + for (let i = 0; i < text.length; i++) { + const char = text[i] + if (inString) { + if (escaped) { + escaped = false + } else if (char === '\\') { + escaped = true + } else if (char === '"') { + inString = false + } + continue + } + + if (char === '"') { + inString = true + } else if (char === '{' || char === '[') { + if (stack.length === 0) start = i + stack.push(char) + } else if (char === '}' || char === ']') { + const opener = stack.pop() + if ((char === '}' && opener !== '{') || (char === ']' && opener !== '[')) { + stack.length = 0 + start = -1 + continue + } + if (stack.length === 0 && start >= 0) { + candidates.push(text.slice(start, i + 1)) + start = -1 + } + } + } + + return candidates +} + +function safeJsonParse(text: string): { ok: true; value: unknown } | { ok: false } { + try { + return { ok: true, value: JSON.parse(text) } + } catch { + return { ok: false } + } +} + +function getString(record: Record, keys: string[]): string | null { + for (const key of keys) { + const value = record[key] + if (typeof value === 'string' && value.trim()) return value.trim() + } + return null +} + +function getNumber(record: Record, keys: string[]): number | null { + for (const key of keys) { + const value = record[key] + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value) + } + return null +} + +function getArray(record: Record, keys: string[]): unknown[] { + for (const key of keys) { + const value = record[key] + if (Array.isArray(value)) return value + } + return [] +} + +function normalizeStringArray(values: unknown[]): string[] { + return values + .map((value) => typeof value === 'string' ? value.trim() : null) + .filter((value): value is string => Boolean(value)) +} + +function normalizeComplexity(value: string | null): AnalysisBlueprint['complexity'] { + const normalized = value?.toLowerCase() + if (normalized === 'simple' || normalized === 'easy' || normalized === 'low') return 'simple' + if (normalized === 'complex' || normalized === 'hard' || normalized === 'high') return 'complex' + return 'moderate' +} + +function calculateReusePercentage(existingCount: number, missingCount: number): number { + const total = existingCount + missingCount + return total === 0 ? 0 : Math.round((existingCount / total) * 100) +} + +function clampPercentage(value: number): number { + return Math.max(0, Math.min(100, Math.round(value))) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/lib/billing.ts b/lib/billing.ts new file mode 100644 index 0000000..ad5d808 --- /dev/null +++ b/lib/billing.ts @@ -0,0 +1,24 @@ +export const CODEVAULT_PRO_PLAN = { + id: 'codevault-pro', + slug: 'codevault-pro', + name: 'CodeVault Pro', + description: 'For builders turning existing repositories into shippable product plans.', + priceLabel: '$29', + unitAmount: 2900, + currency: 'usd', + interval: 'month', + features: [ + 'Unlimited repository blueprint exports', + 'Cross-repo app discovery workflows', + 'Priority scaffold generation queue', + 'Commercial usage for generated plans', + ], +} as const + +export function getPlanById(planId: string | undefined) { + if (planId === CODEVAULT_PRO_PLAN.id) { + return CODEVAULT_PRO_PLAN + } + + return null +} diff --git a/package.json b/package.json index 34d5033..02208e7 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "react-resizable-panels": "^2.1.7", "recharts": "2.15.0", "sonner": "^1.7.1", + "stripe": "^22.1.0", "tailwind-merge": "^3.3.1", "vaul": "^1.1.2", "zod": "^3.24.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6497a4a..e87b78f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: sonner: specifier: ^1.7.1 version: 1.7.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + stripe: + specifier: ^22.1.0 + version: 22.1.0(@types/node@22.19.11) tailwind-merge: specifier: ^3.3.1 version: 3.5.0 @@ -3090,6 +3093,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@22.1.0: + resolution: {integrity: sha512-w/xHyJGxXWnLPbNHG13sz/fae0MrFGC80Oz7YbICQymbfpqfEcsoG+6yG+9BWb81PWc4rrkeSO4wmTcmefmbLw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -6313,6 +6325,10 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@22.1.0(@types/node@22.19.11): + optionalDependencies: + '@types/node': 22.19.11 + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): dependencies: client-only: 0.0.1