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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Public app URL used for auth/billing callbacks
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXTAUTH_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

# Supabase client config
NEXT_PUBLIC_SUPABASE_URL=
Expand All @@ -20,6 +23,7 @@ MISTRAL_IMAGE_MODEL=mistral-medium-latest
# Optional: pre-created Mistral agent with the image_generation tool enabled.
# If omitted, LaunchPix creates one at runtime.
MISTRAL_IMAGE_AGENT_ID=
LAUNCHPIX_API_KEY=

# Lemon Squeezy credit billing
LEMON_SQUEEZY_API_KEY=
Expand All @@ -28,6 +32,9 @@ LEMON_SQUEEZY_WEBHOOK_SECRET=
LEMON_SQUEEZY_STARTER_CREDITS_VARIANT_ID=
LEMON_SQUEEZY_CREATOR_CREDITS_VARIANT_ID=
LEMON_SQUEEZY_STUDIO_CREDITS_VARIANT_ID=
RESEND_API_KEY=
RESEND_FROM_EMAIL=
RESEND_WEBHOOK_SECRET=

# Storage buckets
STORAGE_BUCKET_ASSETS=launchpix-assets
Expand Down
158 changes: 53 additions & 105 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,126 +1,74 @@
# LaunchPix
# Talocode LaunchPix

LaunchPix is a Mistral-assisted asset generator for product launches.
It turns raw screenshots into polished listing visuals, promo tiles, and hero banners.
Talocode LaunchPix is an API-first, open-source launch visual engine.
It turns product screenshots into listing frames, promo tiles, and hero banners with deterministic fallback rendering.

## Design system
- `DESIGN.md` is the canonical design brain for the product UI.
- `docs/design-md/google-designmd-spec.md` is a local copy of the Google DESIGN.md specification.
- `docs/design-md/README.md` explains how to use both files in this repo.
## Repository
- Canonical repo: `https://github.com/talocode/launchpix`

## Tech stack
- Next.js App Router + TypeScript
- Tailwind CSS + reusable UI primitives
- Supabase (Auth, Postgres, Storage)
- Mistral structured planning and image generation
- Deterministic SVG -> PNG fallback rendering (`@resvg/resvg-js`)
- Lemon Squeezy credit-pack billing and webhook fulfillment

## Core product flow
1. Sign in
2. Create project and upload screenshots
3. Generate structured asset plan via Mistral
4. Generate image assets through a Mistral image-generation agent
5. Preview/download assets while credits remain
## Product direction
- API first: developer endpoints live under `/api/v1/*`.
- Open source core: code is public, but API usage requires `LAUNCHPIX_API_KEY`.
- Credits model: users start with free credits, then buy one-time credit packs.

## Pricing model implemented
- Every account starts with 300 credits.
- Existing accounts are raised to at least 300 credits by `0004_credit_balance_model.sql`.
- Billing is credit based, not subscription based.
- Users buy one-time Lemon Squeezy credit packs after exhausting their included credits.

## Required environment variables
## Core stack
- Next.js App Router + TypeScript
- Supabase (Postgres, Storage)
- Mistral (planning + image generation)
- Lemon Squeezy (credit-pack checkout + webhook fulfillment)
- Resend (transactional email)

## API authentication
Every `/api/v1/*` request must include:
- `x-launchpix-api-key: <LAUNCHPIX_API_KEY>`
- `x-launchpix-user-id: <owner-user-uuid>`

Supported alternatives:
- `x-api-key`
- `Authorization: Bearer <LAUNCHPIX_API_KEY>`

## Developer API endpoints
- `GET /api/v1/projects`
- `POST /api/v1/projects`
- `GET /api/v1/projects/:projectId/generate`
- `POST /api/v1/projects/:projectId/generate`

## Environment variables
See `.env.example`.
Minimum required:

Critical keys:
- `NEXT_PUBLIC_APP_URL`
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `SUPABASE_SERVICE_ROLE_KEY`
- `DATABASE_URL`
- `NEXTAUTH_SECRET`
- `GOOGLE_CLIENT_ID`
- `GOOGLE_CLIENT_SECRET`
- `MISTRAL_API_KEY`
- `MISTRAL_MODEL_VISION`
- `MISTRAL_MODEL_TEXT`
- `MISTRAL_MODEL_VISION`
- `MISTRAL_IMAGE_MODEL`
- `MISTRAL_IMAGE_AGENT_ID` (optional)
- `LEMON_SQUEEZY_API_KEY`
- `LEMON_SQUEEZY_STORE_ID`
- `LEMON_SQUEEZY_WEBHOOK_SECRET`
- `LEMON_SQUEEZY_STARTER_CREDITS_VARIANT_ID`
- `LEMON_SQUEEZY_CREATOR_CREDITS_VARIANT_ID`
- `LEMON_SQUEEZY_STUDIO_CREDITS_VARIANT_ID`

Optional for Supabase CLI workflows:
- `SUPABASE_ACCESS_TOKEN`
- `SUPABASE_DB_PASSWORD`
- `LAUNCHPIX_API_KEY`
- `LEMON_SQUEEZY_*`
- `RESEND_API_KEY`

## Local setup
1. Copy env:
- `cp .env.example .env.local`
2. Install dependencies:
- `npm install`
4. Apply database migrations using one of these options:
- Supabase CLI: `npx supabase db push --linked`
- or run the SQL files in `supabase/migrations/` in order
5. Start dev server:
- `npm run dev`
1. Copy env file: `cp .env.example .env.local`
2. Install: `npm install`
3. Apply DB migrations: `npx supabase db push --linked`
4. Start app: `npm run dev`

Recommended validation commands:
Validation:
- `npm run typecheck`
- `npm run build`

## Supabase notes
- Enable email auth.
- Ensure storage buckets exist:
- `project-uploads-raw`
- `project-uploads-normalized`
- `launchpix-assets`
- Apply RLS policies from migrations.
- If you use the Supabase CLI, link the project first with `npx supabase link`.

## Mistral notes
- Mistral is used for structured product/copy/layout planning.
- Final image assets are generated through a Mistral Agent with the built-in `image_generation` tool.
- Planning default model: `mistral-small-2506` (configurable via env).
- Image generation default model: `mistral-medium-latest` (configurable via `MISTRAL_IMAGE_MODEL`).
- `MISTRAL_IMAGE_AGENT_ID` can point to a pre-created image-generation agent. If it is omitted, LaunchPix creates an agent at runtime.
- If Mistral image generation fails, LaunchPix falls back to deterministic SVG -> PNG rendering so generation does not hard-fail.

## Lemon Squeezy notes
- Checkout init: `POST /api/billing/checkout`
- Verification: purchases are fulfilled by webhook after Lemon Squeezy confirms the order
- Webhook: `POST /api/billing/webhook`
- Configure Lemon Squeezy webhook URL to point to `/api/billing/webhook`.
- Select the `order_created` event for credit fulfillment.
- Create three Lemon Squeezy variants and map them to the variant ID env vars in `.env.example`.

## Commands
- `npm run dev`
- `npm run lint`
- `npm run typecheck`
- `npm run build`
- `npm run video:studio`
- `npm run video:render`
- `npm run video:render:chrome` on Windows if Remotion cannot download Chrome Headless Shell

## Demo video
- The Remotion demo composition lives in `remotion/`.
- Rendered output is written to `output/launchpix-demo.mp4`.
- The video explains the core LaunchPix story: project brief, screenshot uploads, Mistral planning, image generation, fallback rendering, exports, credits, and billing.

## Deployment notes
- Set all env vars in hosting provider.
- `NEXT_PUBLIC_APP_URL` must be set in the hosting provider's production environment to your live domain; `.env.local` is only used locally.
- Use HTTPS and production callback URLs for Lemon Squeezy.
- Auth confirmation and billing redirects are built from `NEXT_PUBLIC_APP_URL`, so production must not point this to localhost.
- Keep `package-lock.json` committed so CI and hosting builds install the same dependency tree.
- Confirm webhook signature secret matches deployment env.

## Netlify notes
- Build command: `npm run build`
- Install command: `npm install` or `npm ci`
- The app relies on `@resvg/resvg-js` during server rendering, so the current `next.config.ts` must be preserved in deployments.
## Render deployment
- Render config is in [`render.yaml`](/C:/Users/Hp/Documents/Github/LaunchPix/render.yaml).
- Build command: `npm ci && npm run build`
- Start command: `npm run start`
- Set all required env vars in Render dashboard.

## Known MVP constraints
- Rate limiting is lightweight (in-memory).
- Credit packs are one-time purchases; subscription renewal automation is intentionally not used.
- Visual templates are production-capable baseline and can be expanded further.
## Legacy note
Previous Netlify-specific deployment notes were removed in favor of Render as the primary target.
37 changes: 37 additions & 0 deletions app/api/v1/projects/[projectId]/generate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { requireLaunchPixApiKey } from "@/lib/api-key";
import { requireApiUserId } from "@/lib/api-user";
import { getProjectOverview } from "@/lib/services/projects/queries";
import { runGenerationForProject } from "@/lib/services/generations/runner";
import { getLatestGeneration } from "@/lib/services/generations/queries";
import { allowGenerationAttempt } from "@/lib/services/access/rate-limit";

export async function GET(request: Request, { params }: { params: Promise<{ projectId: string }> }) {
const unauthorized = requireLaunchPixApiKey(request);
if (unauthorized) return unauthorized;

const userResult = requireApiUserId(request);
if ("response" in userResult) return userResult.response;

const { projectId } = await params;
const { project } = await getProjectOverview(projectId, userResult.userId);
const generation = await getLatestGeneration(project.id);
return NextResponse.json({ generation });
}

export async function POST(request: Request, { params }: { params: Promise<{ projectId: string }> }) {
const unauthorized = requireLaunchPixApiKey(request);
if (unauthorized) return unauthorized;

const userResult = requireApiUserId(request);
if ("response" in userResult) return userResult.response;

if (!allowGenerationAttempt(userResult.userId)) return NextResponse.json({ error: "Too many generation attempts. Please wait and retry." }, { status: 429 });

const { projectId } = await params;
const { project, uploads } = await getProjectOverview(projectId, userResult.userId);
if (!uploads.length) return NextResponse.json({ error: "At least one screenshot is required." }, { status: 400 });

const { generationId } = await runGenerationForProject(project, uploads);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return API errors instead of leaking generation exceptions

When an API client starts generation for a valid project but runGenerationForProject rejects—e.g. consumeGenerationCredit throws No credits remaining... or quality/rendering fails—this new v1 route lets the exception escape. The existing /api/generations/[projectId] route catches the same errors and maps exhausted credits to a 402 JSON response, but /api/v1/projects/:projectId/generate will return a generic 500/Next error response, making the documented API-first endpoint hard for clients to handle and hiding the billing action they need to take.

Useful? React with 👍 / 👎.

return NextResponse.json({ generationId }, { status: 201 });
}
62 changes: 62 additions & 0 deletions app/api/v1/projects/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
import { requireLaunchPixApiKey } from "@/lib/api-key";
import { requireApiUserId } from "@/lib/api-user";
import { createProjectSchema } from "@/lib/validation/project";
import { createSupabaseServerClient } from "@/lib/supabase/server";

export async function GET(request: Request) {
const unauthorized = requireLaunchPixApiKey(request);
if (unauthorized) return unauthorized;

const userResult = requireApiUserId(request);
if ("response" in userResult) return userResult.response;

const supabase = await createSupabaseServerClient();
const { data, error } = await supabase
.from("projects")
.select("id,name,product_type,platform,status,created_at,updated_at")
.eq("user_id", userResult.userId)
.order("updated_at", { ascending: false });

if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ projects: data ?? [] });
}

export async function POST(request: Request) {
const unauthorized = requireLaunchPixApiKey(request);
if (unauthorized) return unauthorized;

const userResult = requireApiUserId(request);
if ("response" in userResult) return userResult.response;

const body = await request.json().catch(() => null);
const parsed = createProjectSchema.safeParse({ ...body, userId: userResult.userId });
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload." }, { status: 400 });
}

const supabase = await createSupabaseServerClient();
const payload = {
user_id: userResult.userId,
name: parsed.data.name,
product_type: parsed.data.productType,
platform: parsed.data.platform,
description: parsed.data.description,
audience: parsed.data.audience,
website_url: parsed.data.websiteUrl || null,
primary_color: parsed.data.primaryColor,
style_preset: parsed.data.stylePreset,
status: "ready"
};

const { data: project, error } = await supabase.from("projects").insert(payload).select("*").single();
if (error || !project) return NextResponse.json({ error: error?.message ?? "Unable to create project." }, { status: 500 });

const { data: draft } = await supabase.from("generations").select("id").eq("project_id", project.id).eq("status", "draft").maybeSingle();
if (!draft) {
const { error: generationError } = await supabase.from("generations").insert({ project_id: project.id, status: "draft" });
if (generationError) return NextResponse.json({ error: generationError.message }, { status: 500 });
}

return NextResponse.json({ project }, { status: 201 });
}
44 changes: 44 additions & 0 deletions app/docs/api/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { TopNav } from "@/components/marketing/top-nav";
import { MarketingFooter } from "@/components/marketing/footer";
import { Button } from "@/components/ui/button";

export default function ApiDocsPage() {
return (
<>
<TopNav />
<main className="app-shell py-14 sm:py-16">
<section className="surface p-6 sm:p-10">
<p className="eyebrow">API first</p>
<h1 className="mt-5 text-3xl font-semibold sm:text-4xl">Talocode LaunchPix Developer API</h1>
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600 dark:text-slate-400">
Talocode LaunchPix is open source and API-first. To use production endpoints, request `LAUNCHPIX_API_KEY` and pass it with each request.
</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2">
<div className="surface-muted p-5">
<p className="text-sm font-semibold">Auth headers</p>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-400">`x-launchpix-api-key: &lt;your-key&gt;`</p>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">`x-launchpix-user-id: &lt;owner-uuid&gt;`</p>
</div>
<div className="surface-muted p-5">
<p className="text-sm font-semibold">Endpoints</p>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-400">`GET /api/v1/projects`</p>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">`POST /api/v1/projects`</p>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">`POST /api/v1/projects/:projectId/generate`</p>
</div>
</div>
<div className="mt-8">
<Button asChild>
<Link href="/contact">
Request API key
<ArrowRight className="size-4" />
</Link>
</Button>
</div>
</section>
</main>
<MarketingFooter />
</>
);
}
Loading