create-spine 1.0.0 → 1.0.2
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/index.js +22 -1
- package/package.json +1 -1
- package/template/AGENTS.md +5 -0
- package/template/CLAUDE.md +1 -0
- package/template/README.md +37 -0
- package/template/app/Refund/page.tsx +340 -0
- package/template/app/api/auth/[...nextauth]/route.ts +6 -0
- package/template/app/api/generate/route.ts +57 -0
- package/template/app/dashboard/page.tsx +713 -0
- package/template/app/faq/page.tsx +343 -0
- package/template/app/globals.css +130 -0
- package/template/app/layout.tsx +33 -0
- package/template/app/page.tsx +650 -0
- package/template/app/pricing/page.tsx +23 -0
- package/template/app/privacy/page.tsx +328 -0
- package/template/app/terms/page.tsx +12 -0
- package/template/components/ui/badge.tsx +52 -0
- package/template/components/ui/button.tsx +58 -0
- package/template/components/ui/card.tsx +103 -0
- package/template/components/ui/separator.tsx +25 -0
- package/template/components/ui/textarea.tsx +18 -0
- package/template/components/ui/ui/footer/page.tsx +103 -0
- package/template/components/ui/ui/navbar/navbar.tsx +0 -0
- package/template/components.json +25 -0
- package/template/desktop.ini +6 -0
- package/template/eslint.config.mjs +18 -0
- package/template/lib/auth.ts +19 -0
- package/template/lib/prisma.ts +7 -0
- package/template/lib/utils.ts +6 -0
- package/template/next.config.ts +7 -0
- package/template/package-lock.json +9794 -0
- package/template/package.json +35 -0
- package/template/postcss.config.mjs +7 -0
- package/template/public/X.svg +1 -0
- package/template/public/github.svg +2 -0
- package/template/public/google.svg +2 -0
- package/template/src/components/ui/badge.tsx +58 -0
- package/template/src/components/ui/button.tsx +90 -0
- package/template/src/components/ui/card.tsx +103 -0
- package/template/src/components/ui/textarea.tsx +29 -0
- package/template/src/lib/utils.ts +6 -0
- package/template/tsconfig.json +34 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react"
|
|
4
|
+
import {
|
|
5
|
+
Copy, Check, Download, ArrowRight, ChevronRight, ChevronDown,
|
|
6
|
+
Route, Database, KeyRound, ShieldCheck, FileText, Terminal, Loader2,
|
|
7
|
+
Folder, FileCode,
|
|
8
|
+
} from "lucide-react"
|
|
9
|
+
import { Button } from "@/components/ui/button"
|
|
10
|
+
import { Badge } from "@/components/ui/badge"
|
|
11
|
+
import { Textarea } from "@/components/ui/textarea"
|
|
12
|
+
import { Card, CardContent } from "@/components/ui/card"
|
|
13
|
+
import { cn } from "@/lib/utils"
|
|
14
|
+
|
|
15
|
+
/* ════════════════════════════════════════════════════════════════
|
|
16
|
+
NATIVE ICONS
|
|
17
|
+
════════════════════════════════════════════════════════════════ */
|
|
18
|
+
const GithubIcon = ({ className }: { className?: string }) => (
|
|
19
|
+
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
20
|
+
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/>
|
|
21
|
+
</svg>
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
/* ════════════════════════════════════════════════════════════════
|
|
25
|
+
TYPES
|
|
26
|
+
════════════════════════════════════════════════════════════════ */
|
|
27
|
+
type GenState = "idle" | "generating" | "done"
|
|
28
|
+
type LogLine = { t: string; c: string }
|
|
29
|
+
type TreeNode = { name: string; type: "file" | "dir"; id?: string; children?: TreeNode[] }
|
|
30
|
+
type StackId = "nextjs" | "mern" | "micro" | "hono"
|
|
31
|
+
|
|
32
|
+
/* ════════════════════════════════════════════════════════════════
|
|
33
|
+
STATIC CONFIG
|
|
34
|
+
════════════════════════════════════════════════════════════════ */
|
|
35
|
+
const STACKS: { id: StackId; label: string; sub: string; cmd: string }[] = [
|
|
36
|
+
{ id: "nextjs", label: "Next.js Fullstack", sub: "Prisma + NextAuth", cmd: "npx create-spine@latest --stack nextjs" },
|
|
37
|
+
{ id: "mern", label: "MERN Stack", sub: "Express + MongoDB", cmd: "npx create-spine@latest --stack mern" },
|
|
38
|
+
{ id: "micro", label: "Microservice", sub: "Express + Mongo", cmd: "npx create-spine@latest --stack micro" },
|
|
39
|
+
{ id: "hono", label: "Hono Edge API", sub: "Cloudflare Workers", cmd: "npx create-spine@latest --stack hono" },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
const TREES: Record<StackId, TreeNode> = {
|
|
43
|
+
nextjs: { name: "spine-app", type: "dir", children: [
|
|
44
|
+
{ name: "src", type: "dir", children: [
|
|
45
|
+
{ name: "app", type: "dir", children: [
|
|
46
|
+
{ name: "api", type: "dir", children: [
|
|
47
|
+
{ name: "auth", type: "dir", children: [{ name: "[...nextauth]", type: "dir", children: [{ name: "route.ts", type: "file", id: "nextauth" }] }] },
|
|
48
|
+
{ name: "tasks", type: "dir", children: [{ name: "route.ts", type: "file", id: "tasks" }] },
|
|
49
|
+
] },
|
|
50
|
+
{ name: "layout.tsx", type: "file", id: "layout" },
|
|
51
|
+
] },
|
|
52
|
+
{ name: "lib", type: "dir", children: [
|
|
53
|
+
{ name: "auth.ts", type: "file", id: "auth" },
|
|
54
|
+
{ name: "prisma.ts", type: "file", id: "prisma" },
|
|
55
|
+
] },
|
|
56
|
+
] },
|
|
57
|
+
{ name: "prisma", type: "dir", children: [{ name: "schema.prisma", type: "file", id: "schema" }] },
|
|
58
|
+
{ name: ".env.example", type: "file", id: "env" },
|
|
59
|
+
{ name: "README.md", type: "file", id: "readme" },
|
|
60
|
+
] },
|
|
61
|
+
mern: { name: "spine-mern", type: "dir", children: [
|
|
62
|
+
{ name: "src", type: "dir", children: [
|
|
63
|
+
{ name: "models", type: "dir", children: [{ name: "User.model.ts", type: "file", id: "user-model" }, { name: "Task.model.ts", type: "file", id: "dyn-model" }] },
|
|
64
|
+
{ name: "routes", type: "dir", children: [{ name: "auth.router.ts", type: "file", id: "auth-route" }, { name: "tasks.router.ts", type: "file", id: "dyn-route" }] },
|
|
65
|
+
{ name: "index.ts", type: "file", id: "index" },
|
|
66
|
+
] },
|
|
67
|
+
{ name: ".env.example", type: "file", id: "env" },
|
|
68
|
+
{ name: "README.md", type: "file", id: "readme" },
|
|
69
|
+
] },
|
|
70
|
+
micro: { name: "spine-micro", type: "dir", children: [
|
|
71
|
+
{ name: "src", type: "dir", children: [
|
|
72
|
+
{ name: "controllers", type: "dir", children: [{ name: "resource.ctrl.ts", type: "file", id: "ctrl" }] },
|
|
73
|
+
{ name: "models", type: "dir", children: [{ name: "resource.model.ts", type: "file", id: "model" }] },
|
|
74
|
+
{ name: "index.ts", type: "file", id: "index" },
|
|
75
|
+
] },
|
|
76
|
+
{ name: ".env.example", type: "file", id: "env" },
|
|
77
|
+
{ name: "README.md", type: "file", id: "readme" },
|
|
78
|
+
] },
|
|
79
|
+
hono: { name: "spine-hono", type: "dir", children: [
|
|
80
|
+
{ name: "src", type: "dir", children: [
|
|
81
|
+
{ name: "routes", type: "dir", children: [{ name: "index.ts", type: "file", id: "routes" }] },
|
|
82
|
+
{ name: "index.ts", type: "file", id: "hono-index" },
|
|
83
|
+
] },
|
|
84
|
+
{ name: "wrangler.toml", type: "file", id: "wrangler" },
|
|
85
|
+
{ name: "README.md", type: "file", id: "readme" },
|
|
86
|
+
] },
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const CODE: Record<string, string> = {
|
|
90
|
+
schema: `// prisma/schema.prisma\nmodel User {\n id String @id @default(cuid())\n email String @unique\n}\n\nmodel Task {\n id String @id @default(cuid())\n title String\n done Boolean @default(false)\n userId String\n user User @relation(fields: [userId], references: [id])\n}`,
|
|
91
|
+
readme: `# Spine App\n\n> Generated by Spine\n\nRun:\n npx prisma db push\n npm run dev`,
|
|
92
|
+
env: `DATABASE_URL=""\nNEXTAUTH_SECRET=""`,
|
|
93
|
+
}
|
|
94
|
+
const DEFAULT_CODE = (name: string) => `// ${name}\n// Production-ready placeholder.\n`
|
|
95
|
+
|
|
96
|
+
/* ════════════════════════════════════════════════════════════════
|
|
97
|
+
MOCK AI ENGINE
|
|
98
|
+
════════════════════════════════════════════════════════════════ */
|
|
99
|
+
function parsePrompt(text: string) {
|
|
100
|
+
const t = text.toLowerCase()
|
|
101
|
+
const hasPayment = /stripe|payment|billing|subscription/.test(t)
|
|
102
|
+
const hasBlog = /\bpost\b|blog|feed|article|cms/.test(t)
|
|
103
|
+
const hasEcommerce = /product|shop|ecommerce|e-commerce|\bcart\b/.test(t)
|
|
104
|
+
|
|
105
|
+
let entity = "Task", route = "tasks"
|
|
106
|
+
if (hasEcommerce) { entity = "Product"; route = "products" }
|
|
107
|
+
else if (hasBlog) { entity = "Post"; route = "posts" }
|
|
108
|
+
|
|
109
|
+
const allEntities = ["User", entity]
|
|
110
|
+
if (hasPayment || hasEcommerce) allEntities.push("Subscription")
|
|
111
|
+
return { hasPayment, hasBlog, hasEcommerce, entity, route, allEntities }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function genSchema(entity: string, hasPayment: boolean, hasEcommerce: boolean): string {
|
|
115
|
+
const fields: Record<string, string> = {
|
|
116
|
+
Task: " title String\n body String?\n done Boolean @default(false)",
|
|
117
|
+
Post: " title String\n content String\n slug String @unique\n published Boolean @default(false)",
|
|
118
|
+
Product: " name String\n description String?\n price Float\n stock Int @default(0)",
|
|
119
|
+
}
|
|
120
|
+
const modelFields = fields[entity] || " name String"
|
|
121
|
+
const relName: Record<string, string> = { Task: "tasks Task[]", Post: "posts Post[]", Product: "products Product[]" }
|
|
122
|
+
const userRel = relName[entity] || "items Item[]"
|
|
123
|
+
const subField = (hasPayment || hasEcommerce) ? "\n subscription Subscription?" : ""
|
|
124
|
+
const subModel = (hasPayment || hasEcommerce) ? `\n\nmodel Subscription {\n id String @id @default(cuid())\n userId String @unique\n stripeCustomerId String @unique\n stripePriceId String\n currentPeriodEnd DateTime\n user User @relation(fields: [userId], references: [id])\n}` : ""
|
|
125
|
+
|
|
126
|
+
return `// prisma/schema.prisma\n// Detected entities: User, ${entity}${(hasPayment || hasEcommerce) ? ", Subscription" : ""}\n\nmodel User {\n id String @id @default(cuid())\n email String @unique\n ${userRel}${subField}\n}\n\nmodel ${entity} {\n id String @id @default(cuid())\n${modelFields}\n userId String\n user User @relation(fields: [userId], references: [id])\n\n @@index([userId])\n}${subModel}`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function genRoute(entity: string, route: string): string {
|
|
130
|
+
const ent = entity.toLowerCase()
|
|
131
|
+
const destructure: Record<string, string> = {
|
|
132
|
+
task: "const { title, body } = await req.json()",
|
|
133
|
+
post: "const { title, content, slug } = await req.json()",
|
|
134
|
+
product: "const { name, price, description } = await req.json()",
|
|
135
|
+
}
|
|
136
|
+
const fields = destructure[ent] || "const body = await req.json()"
|
|
137
|
+
return `// src/app/api/${route}/route.ts\nimport { getServerSession } from "next-auth"\nimport { NextResponse } from "next/server"\nimport { prisma } from "@/lib/prisma"\nimport { authOptions } from "@/lib/auth"\n\nexport async function GET() {\n const session = await getServerSession(authOptions)\n if (!session?.user?.email)\n return NextResponse.json({ error: "Unauthorized" }, { status: 401 })\n\n const items = await prisma.${ent}.findMany({\n where: { user: { email: session.user.email } },\n orderBy: { createdAt: "desc" },\n })\n return NextResponse.json(items)\n}\n\nexport async function POST(req: Request) {\n const session = await getServerSession(authOptions)\n if (!session?.user?.email)\n return NextResponse.json({ error: "Unauthorized" }, { status: 401 })\n\n ${fields}\n const item = await prisma.${ent}.create({ data: { ...req, userId: session.user.id } })\n return NextResponse.json(item, { status: 201 })\n}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function genStripeWebhook(): string {
|
|
141
|
+
return `// src/app/api/stripe/webhook/route.ts\nimport Stripe from "stripe"\nimport { headers } from "next/headers"\nimport { NextResponse } from "next/server"\nimport { prisma } from "@/lib/prisma"\n\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-06-20" })\n\nexport async function POST(req: Request) {\n const body = await req.text()\n const sig = headers().get("stripe-signature")!\n const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)\n\n if (event.type === "checkout.session.completed") {\n const sess = event.data.object as Stripe.Checkout.Session\n await prisma.subscription.upsert({\n where: { userId: sess.metadata!.userId },\n create: { userId: sess.metadata!.userId, stripeCustomerId: sess.customer as string, stripePriceId: sess.metadata!.priceId, currentPeriodEnd: new Date() },\n update: { currentPeriodEnd: new Date() },\n })\n }\n return NextResponse.json({ received: true })\n}`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function genCheckout(): string {
|
|
145
|
+
return `// src/app/api/checkout/route.ts\nimport Stripe from "stripe"\nimport { getServerSession } from "next-auth"\nimport { NextResponse } from "next/server"\nimport { authOptions } from "@/lib/auth"\n\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-06-20" })\n\nexport async function POST(req: Request) {\n const session = await getServerSession(authOptions)\n if (!session?.user?.email)\n return NextResponse.json({ error: "Unauthorized" }, { status: 401 })\n\n const { priceId } = await req.json()\n const checkout = await stripe.checkout.sessions.create({\n mode: "payment",\n line_items: [{ price: priceId, quantity: 1 }],\n success_url: process.env.NEXTAUTH_URL + "/success",\n cancel_url: process.env.NEXTAUTH_URL + "/shop",\n metadata: { userId: session.user.id },\n })\n return NextResponse.json({ url: checkout.url })\n}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function genMERNModel(entity: string): string {
|
|
149
|
+
const fieldDefs: Record<string, string> = {
|
|
150
|
+
Task: " title: { type: String, required: true },\n body: String,\n done: { type: Boolean, default: false },",
|
|
151
|
+
Post: " title: { type: String, required: true },\n content: { type: String, required: true },\n slug: { type: String, required: true, unique: true },",
|
|
152
|
+
Product: " name: { type: String, required: true },\n price: { type: Number, required: true },\n stock: { type: Number, default: 0 },",
|
|
153
|
+
}
|
|
154
|
+
const fields = fieldDefs[entity] || " name: { type: String, required: true },"
|
|
155
|
+
return `// src/models/${entity}.model.ts\nimport mongoose, { Schema, Document } from "mongoose"\n\nexport interface I${entity} extends Document {\n userId: mongoose.Types.ObjectId\n}\n\nconst ${entity}Schema = new Schema<I${entity}>(\n {\n${fields}\n userId: { type: Schema.Types.ObjectId, ref: "User", required: true },\n },\n { timestamps: true }\n)\n\nexport const ${entity} = mongoose.model<I${entity}>("${entity}", ${entity}Schema)`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function genEnv(hasPayment: boolean, hasEcommerce: boolean): string {
|
|
159
|
+
const stripeBlock = (hasPayment || hasEcommerce)
|
|
160
|
+
? `\n\n# Stripe\nSTRIPE_SECRET_KEY=""\nSTRIPE_WEBHOOK_SECRET=""\nNEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=""`
|
|
161
|
+
: ""
|
|
162
|
+
return `# Database\nDATABASE_URL="postgresql://user:pass@localhost:5432/spine_app"\n\n# NextAuth\nNEXTAUTH_URL="http://localhost:3000"\nNEXTAUTH_SECRET=""\n\n# OAuth\nGITHUB_CLIENT_ID=""\nGITHUB_CLIENT_SECRET=""${stripeBlock}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function genReadme(entity: string, route: string, hasPayment: boolean, hasEcommerce: boolean): string {
|
|
166
|
+
const pay = (hasPayment || hasEcommerce) ? "- **Payments** — Stripe integration\n" : ""
|
|
167
|
+
return `# Spine App\n\n> Generated by Spine\n\n## What was built\n\nA ${entity.toLowerCase()} management backend with:\n- **Auth** — NextAuth v5\n- **Database** — Prisma (User, ${entity}${(hasPayment || hasEcommerce) ? ", Subscription" : ""})\n${pay}- **API** — /api/${route}\n\n## Quick start\n\n cp .env.example .env.local\n npx prisma db push\n npm run dev`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function buildDynamicTree(stack: StackId, entity: string, route: string, hasPayment: boolean, hasEcommerce: boolean): TreeNode {
|
|
171
|
+
if (stack === "mern" || stack === "micro" || stack === "hono") {
|
|
172
|
+
return TREES[stack]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const apiChildren: TreeNode[] = [
|
|
176
|
+
{ name: "auth", type: "dir", children: [{ name: "[...nextauth]", type: "dir", children: [{ name: "route.ts", type: "file", id: "auth" }] }] },
|
|
177
|
+
{ name: route, type: "dir", children: [{ name: "route.ts", type: "file", id: "dyn-route" }] },
|
|
178
|
+
]
|
|
179
|
+
if (hasPayment || hasEcommerce) apiChildren.push({ name: "stripe", type: "dir", children: [{ name: "webhook", type: "dir", children: [{ name: "route.ts", type: "file", id: "stripe" }] }] })
|
|
180
|
+
if (hasEcommerce) apiChildren.push({ name: "checkout", type: "dir", children: [{ name: "route.ts", type: "file", id: "checkout" }] })
|
|
181
|
+
|
|
182
|
+
const libChildren: TreeNode[] = [{ name: "auth.ts", type: "file", id: "auth" }, { name: "prisma.ts", type: "file", id: "prisma" }]
|
|
183
|
+
if (hasPayment || hasEcommerce) libChildren.push({ name: "stripe.ts", type: "file", id: "stripelib" })
|
|
184
|
+
|
|
185
|
+
return { name: "spine-app", type: "dir", children: [
|
|
186
|
+
{ name: "src", type: "dir", children: [
|
|
187
|
+
{ name: "app", type: "dir", children: [{ name: "api", type: "dir", children: apiChildren }, { name: "layout.tsx", type: "file", id: "layout" }] },
|
|
188
|
+
{ name: "lib", type: "dir", children: libChildren },
|
|
189
|
+
] },
|
|
190
|
+
{ name: "prisma", type: "dir", children: [{ name: "schema.prisma", type: "file", id: "schema" }] },
|
|
191
|
+
{ name: ".env.example", type: "file", id: "env" },
|
|
192
|
+
{ name: "README.md", type: "file", id: "readme" },
|
|
193
|
+
] }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildDynamicLogs(stack: StackId, route: string, hasPayment: boolean, hasEcommerce: boolean, allEntities: string[]): LogLine[] {
|
|
197
|
+
const EMERALD = "#34d399", GRAY = "#71717a", LIGHT = "#a1a1aa"
|
|
198
|
+
const fileCount = (stack === "nextjs" ? 8 : 6) + (hasPayment || hasEcommerce ? 2 : 0) + (hasEcommerce ? 1 : 0)
|
|
199
|
+
const logs: LogLine[] = [
|
|
200
|
+
{ t: "$ spine generate --stack " + stack, c: EMERALD },
|
|
201
|
+
{ t: "▸ Parsing natural language prompt...", c: GRAY },
|
|
202
|
+
{ t: "▸ Entities detected: " + allEntities.join(", "), c: LIGHT },
|
|
203
|
+
{ t: "▸ Resolving stack: " + (STACKS.find(s => s.id === stack)?.label ?? stack), c: GRAY },
|
|
204
|
+
{ t: "▸ Generating database schema...", c: GRAY },
|
|
205
|
+
{ t: "▸ Writing src/app/api/" + route + "/route.ts...", c: GRAY },
|
|
206
|
+
]
|
|
207
|
+
if (hasPayment || hasEcommerce) {
|
|
208
|
+
logs.push({ t: "▸ Third-party gateway identified: Injecting Stripe infrastructure hooks...", c: EMERALD })
|
|
209
|
+
logs.push({ t: "▸ Writing stripe/webhook/route.ts...", c: GRAY })
|
|
210
|
+
}
|
|
211
|
+
if (hasEcommerce) logs.push({ t: "▸ Writing checkout/route.ts...", c: GRAY })
|
|
212
|
+
logs.push({ t: "▸ Generating README.md...", c: GRAY })
|
|
213
|
+
logs.push({ t: "✓ Scaffold complete. " + fileCount + " files · 0 errors.", c: EMERALD })
|
|
214
|
+
return logs
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* ════════════════════════════════════════════════════════════════
|
|
218
|
+
SYNTAX HIGHLIGHTER
|
|
219
|
+
════════════════════════════════════════════════════════════════ */
|
|
220
|
+
function highlight(code: string) {
|
|
221
|
+
return code.split("\n").map((line: string, i: number) => {
|
|
222
|
+
const t = line.trim()
|
|
223
|
+
let color = "#d4d4d8"
|
|
224
|
+
if (t.startsWith("//") || t.startsWith("#")) color = "#52525b"
|
|
225
|
+
else if (/^(import|export) /.test(t)) color = "#7dd3fc"
|
|
226
|
+
else if (/^model /.test(t)) color = "#34d399"
|
|
227
|
+
else if (/^(const|async|function|interface|export const) /.test(t)) color = "#c4b5fd"
|
|
228
|
+
else if (/^(return|if|else) /.test(t)) color = "#f9a8d4"
|
|
229
|
+
else if (t.includes("@id") || t.includes("@default") || t.includes("@unique") || t.includes("@relation")) color = "#fda4af"
|
|
230
|
+
else if (/^[A-Z_]+=/.test(t)) color = "#fde047"
|
|
231
|
+
else if (t.startsWith('"') || t.includes('"')) color = "#86efac"
|
|
232
|
+
return <div key={i} style={{ color, minHeight: "1.4em", whiteSpace: "pre" }}>{line || "\u00A0"}</div>
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ════════════════════════════════════════════════════════════════
|
|
237
|
+
SUB-COMPONENTS
|
|
238
|
+
════════════════════════════════════════════════════════════════ */
|
|
239
|
+
const EXT_COLOR: Record<string, string> = { ts: "text-blue-400", tsx: "text-blue-400", prisma: "text-emerald-400", json: "text-yellow-400", md: "text-zinc-300", toml: "text-orange-400" }
|
|
240
|
+
|
|
241
|
+
function FileNode({ node, depth, activeFile, onSelect }: { node: TreeNode; depth: number; activeFile: string; onSelect: (id: string, name: string) => void }) {
|
|
242
|
+
const [open, setOpen] = useState(depth < 3)
|
|
243
|
+
const isFile = node.type === "file"
|
|
244
|
+
const isActive = isFile && activeFile === node.id
|
|
245
|
+
const ext = node.name.includes(".") ? node.name.split(".").pop() ?? "" : ""
|
|
246
|
+
const color = isFile ? (EXT_COLOR[ext] ?? "text-zinc-300") : "text-zinc-400"
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<div>
|
|
250
|
+
<div
|
|
251
|
+
onClick={() => (isFile ? onSelect(node.id ?? "", node.name) : setOpen(!open))}
|
|
252
|
+
className={cn(
|
|
253
|
+
"flex items-center gap-1.5 py-1 px-2 cursor-pointer select-none border-l-2 transition-colors",
|
|
254
|
+
isActive ? "bg-emerald-950/40 border-emerald-500" : "border-transparent hover:bg-zinc-800/50"
|
|
255
|
+
)}
|
|
256
|
+
style={{ paddingLeft: 8 + depth * 14 }}
|
|
257
|
+
>
|
|
258
|
+
{!isFile && (open ? <ChevronDown className="w-3 h-3 text-zinc-500 shrink-0" /> : <ChevronRight className="w-3 h-3 text-zinc-500 shrink-0" />)}
|
|
259
|
+
{!isFile && <Folder className="w-3.5 h-3.5 text-zinc-500 shrink-0" />}
|
|
260
|
+
{isFile && <FileCode className={cn("w-3.5 h-3.5 shrink-0", color)} />}
|
|
261
|
+
<span className={cn("text-xs font-mono", color)}>{node.name}</span>
|
|
262
|
+
</div>
|
|
263
|
+
{!isFile && open && node.children?.map((c, i) => <FileNode key={i} node={c} depth={depth + 1} activeFile={activeFile} onSelect={onSelect} />)}
|
|
264
|
+
</div>
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function CopyButton({ text, label = "Copy", className }: { text: string; label?: string; className?: string }) {
|
|
269
|
+
const [copied, setCopied] = useState(false)
|
|
270
|
+
const copy = () => {
|
|
271
|
+
navigator.clipboard?.writeText(text).catch(() => {})
|
|
272
|
+
setCopied(true)
|
|
273
|
+
setTimeout(() => setCopied(false), 1800)
|
|
274
|
+
}
|
|
275
|
+
return (
|
|
276
|
+
<Button
|
|
277
|
+
onClick={copy}
|
|
278
|
+
variant="outline"
|
|
279
|
+
size="sm"
|
|
280
|
+
className={cn(
|
|
281
|
+
"font-mono text-xs h-8 gap-1.5 bg-zinc-900 border-zinc-700 hover:bg-zinc-800 hover:text-zinc-100",
|
|
282
|
+
copied && "border-emerald-700 text-emerald-400 bg-emerald-950 hover:bg-emerald-950 hover:text-emerald-400",
|
|
283
|
+
className
|
|
284
|
+
)}
|
|
285
|
+
>
|
|
286
|
+
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
|
287
|
+
{copied ? "Copied!" : label}
|
|
288
|
+
</Button>
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* ════════════════════════════════════════════════════════════════
|
|
293
|
+
MAIN PAGE
|
|
294
|
+
════════════════════════════════════════════════════════════════ */
|
|
295
|
+
export default function Page() {
|
|
296
|
+
const [activeStack, setActiveStack] = useState<StackId>("nextjs")
|
|
297
|
+
const [prompt, setPrompt] = useState("")
|
|
298
|
+
const [genState, setGenState] = useState<GenState>("idle")
|
|
299
|
+
const [logs, setLogs] = useState<LogLine[]>([])
|
|
300
|
+
const [activeFile, setActiveFile] = useState("schema")
|
|
301
|
+
const [activeFileName, setActiveFileName] = useState("schema.prisma")
|
|
302
|
+
const [dynamicCode, setDynamicCode] = useState<Record<string, string>>({})
|
|
303
|
+
const [dynamicTree, setDynamicTree] = useState<TreeNode | null>(null)
|
|
304
|
+
|
|
305
|
+
const generate = async () => {
|
|
306
|
+
if (genState === "generating") return
|
|
307
|
+
if (!prompt.trim()) {
|
|
308
|
+
alert("Please describe your project first!");
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
setGenState("generating")
|
|
313
|
+
setLogs([{ t: "$ Sending prompt to Spine AI Engine...", c: "#34d399" }])
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
// 1. Call your real Gemini backend
|
|
317
|
+
const res = await fetch("/api/generate", {
|
|
318
|
+
method: "POST",
|
|
319
|
+
headers: { "Content-Type": "application/json" },
|
|
320
|
+
body: JSON.stringify({ prompt, stack: activeStack }),
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
if (!res.ok) throw new Error("Failed to generate");
|
|
324
|
+
|
|
325
|
+
const data = await res.json();
|
|
326
|
+
const blueprint = data.blueprint;
|
|
327
|
+
|
|
328
|
+
// 2. Add success logs
|
|
329
|
+
setLogs(prev => [
|
|
330
|
+
...prev,
|
|
331
|
+
{ t: "▸ Architecture received from Gemini...", c: "#71717a" },
|
|
332
|
+
{ t: "▸ Injecting custom Prisma schema...", c: "#71717a" },
|
|
333
|
+
{ t: "✓ Scaffold complete. Ready for download.", c: "#34d399" }
|
|
334
|
+
]);
|
|
335
|
+
|
|
336
|
+
// 3. Update the UI with real Gemini code
|
|
337
|
+
const newCode: Record<string, string> = { ...CODE };
|
|
338
|
+
|
|
339
|
+
if (blueprint.schemaCode) {
|
|
340
|
+
newCode.schema = blueprint.schemaCode;
|
|
341
|
+
}
|
|
342
|
+
if (blueprint.readme) {
|
|
343
|
+
newCode.readme = blueprint.readme;
|
|
344
|
+
}
|
|
345
|
+
// Inject the custom route Gemini made
|
|
346
|
+
if (blueprint.apiRoutes && blueprint.apiRoutes.length > 0) {
|
|
347
|
+
newCode["tasks"] = blueprint.apiRoutes[0].code;
|
|
348
|
+
setActiveFileName(`${blueprint.apiRoutes[0].folderPath}/route.ts`);
|
|
349
|
+
setActiveFile("tasks");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
setDynamicCode(newCode);
|
|
353
|
+
setTimeout(() => setGenState("done"), 600);
|
|
354
|
+
|
|
355
|
+
} catch (error) {
|
|
356
|
+
setLogs(prev => [...prev, { t: "✖ Error: Failed to connect to AI Engine.", c: "#ef4444" }]);
|
|
357
|
+
setTimeout(() => setGenState("idle"), 2000);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const selectFile = (id: string, name: string) => { setActiveFile(id); setActiveFileName(name) }
|
|
362
|
+
const switchStack = (id: StackId) => { setActiveStack(id); setGenState("idle"); setLogs([]); setDynamicCode({}); setDynamicTree(null) }
|
|
363
|
+
|
|
364
|
+
const activeCode = (genState === "done" ? dynamicCode[activeFile] : CODE[activeFile]) ?? DEFAULT_CODE(activeFileName)
|
|
365
|
+
const activeTree = (genState === "done" && dynamicTree) ? dynamicTree : TREES[activeStack]
|
|
366
|
+
const activeCmd = STACKS.find(s => s.id === activeStack)?.cmd ?? ""
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<div className="bg-black min-h-screen text-zinc-50 font-sans antialiased">
|
|
370
|
+
|
|
371
|
+
{/* ── NAV ─────────────────────────────────────────────── */}
|
|
372
|
+
<nav className="sticky top-0 z-50 h-14 flex items-center justify-between px-5 md:px-12 bg-black/85 backdrop-blur-md border-b border-zinc-800">
|
|
373
|
+
<div className="flex items-center gap-2.5">
|
|
374
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
375
|
+
<line x1="12" y1="1" x2="12" y2="23" stroke="#34d399" strokeWidth="2" strokeLinecap="round" />
|
|
376
|
+
<line x1="12" y1="5" x2="18" y2="3" stroke="#34d399" strokeWidth="1.4" strokeLinecap="round" />
|
|
377
|
+
<line x1="12" y1="5" x2="6" y2="3" stroke="#34d399" strokeWidth="1.4" strokeLinecap="round" opacity="0.45" />
|
|
378
|
+
<line x1="12" y1="11" x2="18" y2="9" stroke="#34d399" strokeWidth="1.4" strokeLinecap="round" />
|
|
379
|
+
<line x1="12" y1="11" x2="6" y2="9" stroke="#34d399" strokeWidth="1.4" strokeLinecap="round" opacity="0.45" />
|
|
380
|
+
<line x1="12" y1="17" x2="18" y2="15" stroke="#34d399" strokeWidth="1.4" strokeLinecap="round" />
|
|
381
|
+
<line x1="12" y1="17" x2="6" y2="15" stroke="#34d399" strokeWidth="1.4" strokeLinecap="round" opacity="0.45" />
|
|
382
|
+
</svg>
|
|
383
|
+
<span className="font-bold text-base tracking-tight">Spine</span>
|
|
384
|
+
</div>
|
|
385
|
+
<div className="hidden md:flex items-center gap-7">
|
|
386
|
+
<a href="#templates" className="text-sm text-zinc-500 hover:text-zinc-100 transition-colors">Templates</a>
|
|
387
|
+
<a href="#pricing" className="text-sm text-zinc-500 hover:text-zinc-100 transition-colors">Pricing</a>
|
|
388
|
+
</div>
|
|
389
|
+
<div className="flex items-center gap-2">
|
|
390
|
+
<a href="https://github.com" className="text-zinc-500 hover:text-zinc-200 transition-colors">
|
|
391
|
+
<GithubIcon className="w-[18px] h-[18px]" />
|
|
392
|
+
</a>
|
|
393
|
+
<Button size="sm" className="bg-emerald-500 hover:bg-emerald-400 text-black font-semibold text-xs h-8">Get started</Button>
|
|
394
|
+
</div>
|
|
395
|
+
</nav>
|
|
396
|
+
|
|
397
|
+
{/* ── HERO ────────────────────────────────────────────── */}
|
|
398
|
+
<section className="relative overflow-hidden text-center px-5 md:px-12 py-20 md:py-28 border-b border-zinc-800">
|
|
399
|
+
<div className="absolute top-24 left-1/2 -translate-x-1/2 w-[600px] h-80 rounded-full pointer-events-none" style={{ background: "radial-gradient(ellipse at center, rgba(16,185,129,0.10) 0%, transparent 70%)" }} />
|
|
400
|
+
<div className="relative max-w-2xl mx-auto">
|
|
401
|
+
<Badge variant="outline" className="mb-7 bg-emerald-950 border-emerald-800 text-emerald-400 font-mono text-xs px-3 py-1">
|
|
402
|
+
v0.1.0 — open beta
|
|
403
|
+
</Badge>
|
|
404
|
+
<h1 className="text-4xl md:text-6xl font-extrabold tracking-tighter leading-[1.05] mb-5">
|
|
405
|
+
Stop writing boilerplate.<br />
|
|
406
|
+
<span className="text-emerald-400">Describe your backend.</span>
|
|
407
|
+
</h1>
|
|
408
|
+
<p className="text-sm md:text-base text-zinc-500 leading-relaxed max-w-lg mx-auto mb-9">
|
|
409
|
+
Competitors ask "which stack?" — Spine asks "what are you building?" Type your prompt, pick a baseline, and download a production-ready scaffold in seconds.
|
|
410
|
+
</p>
|
|
411
|
+
<div className="flex justify-center items-center gap-2.5 flex-wrap">
|
|
412
|
+
<div className="flex items-center gap-2 bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-2">
|
|
413
|
+
<span className="text-emerald-400 font-mono text-sm">$</span>
|
|
414
|
+
<span className="text-zinc-400 font-mono text-sm">npx create-spine@latest</span>
|
|
415
|
+
</div>
|
|
416
|
+
<CopyButton text="npx create-spine@latest" />
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
</section>
|
|
420
|
+
|
|
421
|
+
{/* ── GENERATOR WORKSPACE ─────────────────────────────── */}
|
|
422
|
+
<section className="max-w-6xl mx-auto px-5 md:px-12 py-16 md:py-24 border-b border-zinc-800">
|
|
423
|
+
<p className="text-xs text-zinc-500 tracking-widest uppercase mb-2">Interactive generator</p>
|
|
424
|
+
<h2 className="text-2xl md:text-3xl font-bold tracking-tight mb-8">Try it — describe your backend.</h2>
|
|
425
|
+
|
|
426
|
+
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
|
|
427
|
+
|
|
428
|
+
{/* LEFT — control panel */}
|
|
429
|
+
<Card className="lg:col-span-2 bg-zinc-900 border-zinc-800">
|
|
430
|
+
<CardContent className="p-6 flex flex-col gap-5">
|
|
431
|
+
<div>
|
|
432
|
+
<label className="text-xs font-semibold text-zinc-500 tracking-widest uppercase block mb-2.5">Describe your project</label>
|
|
433
|
+
<Textarea
|
|
434
|
+
value={prompt}
|
|
435
|
+
onChange={e => setPrompt(e.target.value)}
|
|
436
|
+
placeholder="e.g., a SaaS with user auth, Stripe subscriptions, and a REST API for a task manager"
|
|
437
|
+
className="min-h-[120px] bg-zinc-950 border-zinc-700 text-zinc-200 text-sm placeholder:text-zinc-600 focus-visible:ring-emerald-600 resize-none"
|
|
438
|
+
/>
|
|
439
|
+
</div>
|
|
440
|
+
|
|
441
|
+
<div>
|
|
442
|
+
<label className="text-xs font-semibold text-zinc-500 tracking-widest uppercase block mb-2.5">Base stack</label>
|
|
443
|
+
<div className="grid grid-cols-2 gap-2">
|
|
444
|
+
{STACKS.map(s => {
|
|
445
|
+
const active = activeStack === s.id
|
|
446
|
+
return (
|
|
447
|
+
<button
|
|
448
|
+
key={s.id}
|
|
449
|
+
onClick={() => switchStack(s.id)}
|
|
450
|
+
className={cn(
|
|
451
|
+
"text-left rounded-lg p-2.5 border transition-colors",
|
|
452
|
+
active ? "bg-emerald-950 border-emerald-700" : "bg-zinc-950 border-zinc-800 hover:border-zinc-700"
|
|
453
|
+
)}
|
|
454
|
+
>
|
|
455
|
+
<div className={cn("text-xs font-semibold mb-0.5", active ? "text-emerald-400" : "text-zinc-300")}>{s.label}</div>
|
|
456
|
+
<div className={cn("text-[11px] font-mono", active ? "text-emerald-600" : "text-zinc-600")}>{s.sub}</div>
|
|
457
|
+
</button>
|
|
458
|
+
)
|
|
459
|
+
})}
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
|
|
463
|
+
<Button
|
|
464
|
+
onClick={generate}
|
|
465
|
+
disabled={genState === "generating"}
|
|
466
|
+
className={cn(
|
|
467
|
+
"w-full font-semibold gap-2",
|
|
468
|
+
genState === "generating" ? "bg-emerald-950 text-emerald-400 border border-emerald-800" : "bg-emerald-500 hover:bg-emerald-400 text-black"
|
|
469
|
+
)}
|
|
470
|
+
>
|
|
471
|
+
{genState === "generating" ? (<><Loader2 className="w-4 h-4 animate-spin" /> Generating scaffold...</>)
|
|
472
|
+
: genState === "done" ? "↺ Regenerate"
|
|
473
|
+
: (<>Generate scaffold <ArrowRight className="w-4 h-4" /></>)}
|
|
474
|
+
</Button>
|
|
475
|
+
</CardContent>
|
|
476
|
+
</Card>
|
|
477
|
+
|
|
478
|
+
{/* RIGHT — output panel */}
|
|
479
|
+
<Card className="lg:col-span-3 bg-zinc-950 border-zinc-800 overflow-hidden">
|
|
480
|
+
<div className="flex items-center justify-between px-4 py-2.5 border-b border-zinc-800 bg-zinc-900">
|
|
481
|
+
<div className="flex items-center gap-2">
|
|
482
|
+
<span className="w-2.5 h-2.5 rounded-full bg-red-500/70" />
|
|
483
|
+
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500/70" />
|
|
484
|
+
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500/70" />
|
|
485
|
+
<span className="text-xs text-zinc-500 font-mono ml-2">
|
|
486
|
+
{genState === "done" ? "spine-output" : genState === "generating" ? "generating..." : "terminal"}
|
|
487
|
+
</span>
|
|
488
|
+
</div>
|
|
489
|
+
{genState === "done" && (
|
|
490
|
+
<div className="flex gap-2">
|
|
491
|
+
<CopyButton text={activeCmd} label="Copy cmd" />
|
|
492
|
+
<Button size="sm" className="bg-emerald-500 hover:bg-emerald-400 text-black h-8 text-xs gap-1.5 font-semibold">
|
|
493
|
+
<Download className="w-3.5 h-3.5" /> Download ZIP
|
|
494
|
+
</Button>
|
|
495
|
+
</div>
|
|
496
|
+
)}
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
{genState === "idle" && (
|
|
500
|
+
<div className="flex flex-col items-center justify-center text-center py-24 px-8">
|
|
501
|
+
<div className="w-11 h-11 rounded-xl bg-emerald-950 border border-emerald-800 flex items-center justify-center mb-4">
|
|
502
|
+
<Terminal className="w-5 h-5 text-emerald-400" />
|
|
503
|
+
</div>
|
|
504
|
+
<p className="text-sm text-zinc-600 max-w-xs leading-relaxed">
|
|
505
|
+
Describe your project and click <span className="text-emerald-400">"Generate scaffold"</span> to see context-aware files appear here.
|
|
506
|
+
</p>
|
|
507
|
+
</div>
|
|
508
|
+
)}
|
|
509
|
+
|
|
510
|
+
{genState === "generating" && (
|
|
511
|
+
<div className="p-5 font-mono text-sm leading-loose">
|
|
512
|
+
{logs.map((log, i) => <div key={i} style={{ color: log.c }}>{log.t}</div>)}
|
|
513
|
+
<div className="flex items-center gap-2 mt-1 text-zinc-500">
|
|
514
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin text-emerald-400" /> working...
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
)}
|
|
518
|
+
|
|
519
|
+
{genState === "done" && (
|
|
520
|
+
<div className="flex" style={{ minHeight: 360 }}>
|
|
521
|
+
<div className="w-36 sm:w-44 shrink-0 border-r border-zinc-800 overflow-y-auto py-2">
|
|
522
|
+
<FileNode node={activeTree!} depth={0} activeFile={activeFile} onSelect={selectFile} />
|
|
523
|
+
</div>
|
|
524
|
+
<div className="flex-1 overflow-y-auto p-4 bg-[#040408]">
|
|
525
|
+
<div className="text-xs text-zinc-500 font-mono mb-3 pb-2.5 border-b border-zinc-800">{activeFileName}</div>
|
|
526
|
+
<div className="text-xs font-mono leading-relaxed">{highlight(activeCode)}</div>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
)}
|
|
530
|
+
</Card>
|
|
531
|
+
</div>
|
|
532
|
+
</section>
|
|
533
|
+
|
|
534
|
+
{/* ── FEATURES ────────────────────────────────────────── */}
|
|
535
|
+
<section className="max-w-6xl mx-auto px-5 md:px-12 py-16 md:py-24 border-b border-zinc-800">
|
|
536
|
+
<p className="text-xs text-zinc-500 tracking-widest uppercase mb-2">What Spine injects</p>
|
|
537
|
+
<h2 className="text-2xl md:text-3xl font-bold tracking-tight mb-9">Not just files. A working project.</h2>
|
|
538
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
539
|
+
{[
|
|
540
|
+
{ Icon: Route, title: "Working Route Files", body: "Controller logic in every handler — GET, POST, PATCH, DELETE — wired to your database with session guards from the start." },
|
|
541
|
+
{ Icon: Database, title: "Intelligent DB Models", body: "Prisma schemas or Mongoose models shaped to your prompt, with relationships and field types inferred automatically." },
|
|
542
|
+
{ Icon: KeyRound, title: "Named .env.example", body: "Every required environment variable pre-named and grouped by service. Not a mystery on day one." },
|
|
543
|
+
{ Icon: ShieldCheck, title: "Full Auth Setup", body: "JWT middleware or NextAuth v5 — pre-configured to the stack, providers and session strategy included." },
|
|
544
|
+
{ Icon: FileText, title: "Context-Aware README", body: "A generated markdown file explaining the exact architecture Spine built — structure, commands, route map." },
|
|
545
|
+
].map(({ Icon, title, body }) => (
|
|
546
|
+
<Card key={title} className="bg-zinc-900 border-zinc-800 hover:border-zinc-700 transition-colors">
|
|
547
|
+
<CardContent className="p-6">
|
|
548
|
+
<div className="w-10 h-10 rounded-lg bg-emerald-950 border border-emerald-800 flex items-center justify-center mb-4">
|
|
549
|
+
<Icon className="w-[18px] h-[18px] text-emerald-400" />
|
|
550
|
+
</div>
|
|
551
|
+
<h3 className="text-base font-bold tracking-tight mb-2">{title}</h3>
|
|
552
|
+
<p className="text-sm text-zinc-500 leading-relaxed">{body}</p>
|
|
553
|
+
</CardContent>
|
|
554
|
+
</Card>
|
|
555
|
+
))}
|
|
556
|
+
<Card className="bg-zinc-900 border-zinc-800">
|
|
557
|
+
<CardContent className="p-6">
|
|
558
|
+
<div className="font-mono text-xs mb-3 space-y-0.5">
|
|
559
|
+
<div className="text-emerald-400">$ npx create-spine@latest</div>
|
|
560
|
+
<div className="text-zinc-300">> Stack: Next.js Fullstack</div>
|
|
561
|
+
<div className="text-zinc-300">> Database: PostgreSQL</div>
|
|
562
|
+
<div className="text-emerald-400">✓ Scaffolding...</div>
|
|
563
|
+
</div>
|
|
564
|
+
<p className="text-sm text-zinc-500 leading-relaxed">One command. Interactive prompts. Zero boilerplate left to write.</p>
|
|
565
|
+
</CardContent>
|
|
566
|
+
</Card>
|
|
567
|
+
</div>
|
|
568
|
+
</section>
|
|
569
|
+
|
|
570
|
+
{/* ── TEMPLATES ───────────────────────────────────────── */}
|
|
571
|
+
<section id="templates" className="max-w-6xl mx-auto px-5 md:px-12 py-16 md:py-24 border-b border-zinc-800">
|
|
572
|
+
<p className="text-xs text-zinc-500 tracking-widest uppercase mb-2">Template library</p>
|
|
573
|
+
<h2 className="text-2xl md:text-3xl font-bold tracking-tight mb-2">Hand-crafted. Actually production-ready.</h2>
|
|
574
|
+
<p className="text-sm text-zinc-500 mb-9">Better than anything you'll find on a GitHub search.</p>
|
|
575
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
576
|
+
{[
|
|
577
|
+
{ id: "nextjs", badge: "Most popular", title: "Next.js Fullstack", tech: ["Next.js 14", "Prisma", "NextAuth v5", "TypeScript"], desc: "Full-stack app scaffold with App Router, database, auth, and API routes." },
|
|
578
|
+
{ id: "mern", badge: null, title: "MERN REST API", tech: ["Express", "MongoDB", "Mongoose", "JWT"], desc: "REST API with MVC architecture, JWT auth, and clean route separation." },
|
|
579
|
+
{ id: "micro", badge: null, title: "Express Microservice", tech: ["Express", "MongoDB", "Docker"], desc: "Lightweight single-responsibility microservice, clean layered architecture." },
|
|
580
|
+
{ id: "hono", badge: "Edge-first", title: "Hono Edge API", tech: ["Hono", "Cloudflare Workers", "D1"], desc: "Blazing-fast edge API deployable to Cloudflare Workers in one command." },
|
|
581
|
+
].map(t => (
|
|
582
|
+
<Card key={t.id} className="bg-zinc-900 border-zinc-800 hover:border-zinc-700 transition-colors">
|
|
583
|
+
<CardContent className="p-6">
|
|
584
|
+
<div className="flex items-start justify-between mb-3">
|
|
585
|
+
<h3 className="text-base font-bold tracking-tight">{t.title}</h3>
|
|
586
|
+
{t.badge && <Badge variant="outline" className="bg-emerald-950 border-emerald-800 text-emerald-400 text-[10px]">{t.badge}</Badge>}
|
|
587
|
+
</div>
|
|
588
|
+
<p className="text-sm text-zinc-500 leading-relaxed mb-4">{t.desc}</p>
|
|
589
|
+
<div className="flex flex-wrap gap-1.5 mb-5">
|
|
590
|
+
{t.tech.map(tag => <Badge key={tag} variant="outline" className="bg-zinc-950 border-zinc-700 text-zinc-500 font-mono text-[11px]">{tag}</Badge>)}
|
|
591
|
+
</div>
|
|
592
|
+
<CopyButton text={`npx create-spine@latest --template ${t.id}`} label={`npx create-spine --template ${t.id}`} className="w-full justify-center" />
|
|
593
|
+
</CardContent>
|
|
594
|
+
</Card>
|
|
595
|
+
))}
|
|
596
|
+
</div>
|
|
597
|
+
</section>
|
|
598
|
+
|
|
599
|
+
{/* ── PRICING ─────────────────────────────────────────── */}
|
|
600
|
+
<section id="pricing" className="max-w-6xl mx-auto px-5 md:px-12 py-16 md:py-24 border-b border-zinc-800">
|
|
601
|
+
<p className="text-xs text-zinc-500 tracking-widest uppercase mb-2">Pricing</p>
|
|
602
|
+
<h2 className="text-2xl md:text-3xl font-bold tracking-tight mb-2">Simple. Generous free tier.</h2>
|
|
603
|
+
<p className="text-sm text-zinc-500 mb-10">The core tool is free, forever. Pay when you need more.</p>
|
|
604
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-w-2xl">
|
|
605
|
+
<Card className="bg-zinc-900 border-zinc-800">
|
|
606
|
+
<CardContent className="p-7">
|
|
607
|
+
<p className="text-xs font-semibold text-zinc-500 tracking-widest uppercase mb-2.5">Free Forever</p>
|
|
608
|
+
<div className="flex items-baseline gap-1 mb-6"><span className="text-4xl font-extrabold tracking-tighter">$0</span><span className="text-sm text-zinc-500">/month</span></div>
|
|
609
|
+
<div className="flex flex-col gap-2.5 mb-7">
|
|
610
|
+
{["5 AI generations / day", "4 core stack templates", "npx CLI tool", "Fully anonymous"].map(f => (
|
|
611
|
+
<div key={f} className="flex items-center gap-2.5"><Check className="w-[14px] h-[14px] text-emerald-400 shrink-0" /><span className="text-sm text-zinc-400">{f}</span></div>
|
|
612
|
+
))}
|
|
613
|
+
</div>
|
|
614
|
+
<Button variant="outline" className="w-full border-zinc-700 text-zinc-300 hover:bg-zinc-800">Start for free</Button>
|
|
615
|
+
</CardContent>
|
|
616
|
+
</Card>
|
|
617
|
+
<Card className="bg-emerald-950 border-emerald-700">
|
|
618
|
+
<CardContent className="p-7">
|
|
619
|
+
<p className="text-xs font-semibold text-emerald-400 tracking-widest uppercase mb-2.5">Pro</p>
|
|
620
|
+
<div className="flex items-baseline gap-1 mb-6"><span className="text-4xl font-extrabold tracking-tighter">$9</span><span className="text-sm text-emerald-300">/month</span></div>
|
|
621
|
+
<div className="flex flex-col gap-2.5 mb-7">
|
|
622
|
+
{["Unlimited AI generations", "Private saved templates", "Custom stack presets", "Team sharing"].map(f => (
|
|
623
|
+
<div key={f} className="flex items-center gap-2.5"><Check className="w-[14px] h-[14px] text-emerald-400 shrink-0" /><span className="text-sm text-emerald-100">{f}</span></div>
|
|
624
|
+
))}
|
|
625
|
+
</div>
|
|
626
|
+
<Button className="w-full bg-emerald-500 hover:bg-emerald-400 text-black font-semibold">Get Pro →</Button>
|
|
627
|
+
</CardContent>
|
|
628
|
+
</Card>
|
|
629
|
+
</div>
|
|
630
|
+
</section>
|
|
631
|
+
|
|
632
|
+
{/* ── CTA ─────────────────────────────────────────────── */}
|
|
633
|
+
<section className="max-w-6xl mx-auto px-5 md:px-12 py-16 md:py-24">
|
|
634
|
+
<Card className="bg-zinc-900 border-zinc-800 relative overflow-hidden">
|
|
635
|
+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-80 pointer-events-none" style={{ background: "radial-gradient(ellipse at center, rgba(16,185,129,0.08) 0%, transparent 70%)" }} />
|
|
636
|
+
<CardContent className="p-10 md:p-16 text-center relative">
|
|
637
|
+
<h2 className="text-2xl md:text-4xl font-extrabold tracking-tighter mb-3 leading-tight">Your next project starts<br />with one prompt.</h2>
|
|
638
|
+
<p className="text-sm text-zinc-500 mb-8">No account required. Just describe and download.</p>
|
|
639
|
+
<div className="flex justify-center gap-2.5 flex-wrap">
|
|
640
|
+
<Button className="bg-emerald-500 hover:bg-emerald-400 text-black font-semibold px-6">Generate my scaffold →</Button>
|
|
641
|
+
<Button variant="outline" className="border-zinc-700 text-zinc-400 gap-2">
|
|
642
|
+
<GithubIcon className="w-4 h-4" /> Star on GitHub
|
|
643
|
+
</Button>
|
|
644
|
+
</div>
|
|
645
|
+
</CardContent>
|
|
646
|
+
</Card>
|
|
647
|
+
</section>
|
|
648
|
+
</div>
|
|
649
|
+
)
|
|
650
|
+
}
|