From fe8adea17fbed044f5ecb1aa27d72736d8222c2a Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 07:33:04 +0100 Subject: [PATCH 01/13] Add detailed about and legal pages --- app/about/page.tsx | 194 +++++++++++++++++++++++++++++++ app/contact/page.tsx | 63 ++++++---- app/privacy/page.tsx | 70 +++++++---- app/terms/page.tsx | 76 +++++++----- components/marketing/footer.tsx | 1 + components/marketing/top-nav.tsx | 1 + 6 files changed, 332 insertions(+), 73 deletions(-) create mode 100644 app/about/page.tsx diff --git a/app/about/page.tsx b/app/about/page.tsx new file mode 100644 index 0000000..929de20 --- /dev/null +++ b/app/about/page.tsx @@ -0,0 +1,194 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowRight, CheckCircle2, CreditCard, FileImage, Layers3, Sparkles, UploadCloud, Wand2, Zap } from "lucide-react"; +import { MarketingPageShell } from "@/components/marketing/page-shell"; +import { Button } from "@/components/ui/button"; +import { FREE_SIGNUP_CREDITS } from "@/lib/services/billing/plans"; + +export const metadata: Metadata = { + title: "About | LaunchPix", + description: "Learn what LaunchPix does, who it is for, how the generation workflow works, and why it uses credits instead of subscriptions.", + openGraph: { + title: "About LaunchPix", + description: "LaunchPix turns raw screenshots into launch-ready app listing visuals, promo tiles, hero banners, and export packs.", + url: "https://launchpix.app/about" + }, + twitter: { + card: "summary_large_image", + title: "About LaunchPix", + description: "A detailed overview of LaunchPix, the product workflow, credits, exports, and launch asset generation." + } +}; + +const problems = [ + "Raw screenshots rarely explain a product quickly enough for launch traffic.", + "Founders lose time resizing, captioning, cropping, and re-exporting visuals for every channel.", + "A product can be ready to ship while the launch assets still feel unfinished or inconsistent." +]; + +const workflow = [ + { + icon: UploadCloud, + title: "Create a launch project", + text: "Describe the product, audience, platform, positioning, visual style, and goal for the asset pack." + }, + { + icon: FileImage, + title: "Upload screenshots", + text: "Add the product screenshots LaunchPix should turn into listing frames, promo tiles, and hero banners." + }, + { + icon: Wand2, + title: "Generate the asset plan", + text: "Mistral helps structure the copy and layout direction, then LaunchPix renders the actual assets through deterministic templates." + }, + { + icon: Layers3, + title: "Review and export", + text: "Inspect the generated visuals, download individual PNG files, or export the full ZIP pack for launch handoff." + } +]; + +const outputs = [ + "App listing screenshots that frame product value clearly", + "Promo tiles for announcements, launch posts, and social campaigns", + "Hero banners for landing pages, changelogs, and release pages", + "ZIP export with organized production-ready files", + "Editable copy and rerender controls for faster iteration" +]; + +const audiences = [ + "Solo founders preparing a first launch", + "SaaS teams shipping product updates often", + "App and extension builders refreshing store listings", + "Agencies producing launch visuals for multiple clients", + "Growth teams testing new campaign creative" +]; + +export default function AboutPage() { + return ( + +
+
+
+

The problem

+

Great products can still look unready at launch.

+

+ Launch week creates a practical design gap: the product exists, but the screenshots still need framing, hierarchy, captions, sizing, and a consistent visual system. +

+
+ +
+ {problems.map((item) => ( +
+ +

{item}

+
+ ))} +
+
+ +
+
+

What it does

+

One guided workflow from brief to export.

+

+ LaunchPix combines a project brief, uploaded screenshots, structured AI planning, and deterministic rendering to produce reusable launch visuals that feel connected across channels. +

+
+ +
+ {workflow.map((item) => ( +
+
+ +
+

{item.title}

+

{item.text}

+
+ ))} +
+
+ +
+
+

What you get

+

A complete launch pack, not a loose image export.

+
+ {outputs.map((item) => ( +
+ + {item} +
+ ))} +
+
+ +
+

Who it is for

+

Built for people shipping product, not managing design files.

+
+ {audiences.map((item) => ( +
+ + {item} +
+ ))} +
+
+
+ +
+
+
+

Credits and billing

+

LaunchPix uses credits, not subscriptions.

+

+ Every account starts with {FREE_SIGNUP_CREDITS} credits. A generation run consumes one credit. When the balance runs out, users buy one-time credit packs through Lemon Squeezy and continue generating. +

+
+ +
+ {[ + ["Included", `${FREE_SIGNUP_CREDITS}`, "credits at signup"], + ["Model", "One-time", "credit packs"], + ["Provider", "Lemon", "Squeezy checkout"] + ].map(([label, value, detail]) => ( +
+

{label}

+

{value}

+

{detail}

+
+ ))} +
+
+
+ +
+ +

+ The goal is simple: help products look ready when the launch traffic arrives. +

+

+ LaunchPix removes repetitive visual production from the launch process so teams can focus on positioning, shipping, and learning from the market. +

+
+ + +
+
+
+
+ ); +} diff --git a/app/contact/page.tsx b/app/contact/page.tsx index fda0c02..2b210d1 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { LifeBuoy, Mail, ShieldCheck } from "lucide-react"; +import { CreditCard, FileWarning, LifeBuoy, Mail, ShieldCheck, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { MarketingPageShell } from "@/components/marketing/page-shell"; @@ -8,6 +8,39 @@ export const metadata: Metadata = { description: "Contact LaunchPix support for product, billing, and account help." }; +const supportTypes = [ + { + icon: Mail, + title: "Primary support", + text: "support@launchpix.app" + }, + { + icon: CreditCard, + title: "Credit or billing issue", + text: "Include the account email, Lemon Squeezy checkout reference, credit pack name, and the time of payment." + }, + { + icon: Sparkles, + title: "Generation issue", + text: "Share the project name, upload count, generation step, and exact error text shown in the dashboard." + }, + { + icon: FileWarning, + title: "Export issue", + text: "Tell us whether the problem happened on individual PNG download, ZIP export, or asset preview." + }, + { + icon: ShieldCheck, + title: "Account or privacy request", + text: "Send the request from the email tied to the account so we can verify ownership." + }, + { + icon: LifeBuoy, + title: "Product feedback", + text: "Share the workflow you expected, what blocked you, and what asset format would help your launch." + } +]; + export default function ContactPage() { return ( -
-
- {[ - { - icon: Mail, - title: "Primary support", - text: "support@launchpix.app" - }, - { - icon: ShieldCheck, - title: "Billing help", - text: "Include the account email and checkout reference so we can verify the payment quickly." - }, - { - icon: LifeBuoy, - title: "Generation help", - text: "Share the project name, the action you clicked, and the exact error text if one appeared." - } - ].map((item) => ( -
+
+
+ {supportTypes.map((item) => ( +

{item.title}

{item.text}

@@ -42,9 +59,9 @@ export default function ContactPage() { ))}
-
+

The fastest support message is specific.

-

+

Send the page you were on, the action you took, and any error text you saw. For billing requests, include the payment reference and the email used at checkout.

diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index fc41516..b4c4de1 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { Database, FileImage, LockKeyhole, Mail, Receipt, Sparkles } from "lucide-react"; import { MarketingPageShell } from "@/components/marketing/page-shell"; export const metadata: Metadata = { @@ -6,6 +7,39 @@ export const metadata: Metadata = { description: "How LaunchPix stores user data, processes screenshots, and handles billing and AI services." }; +const sections = [ + { + icon: Database, + title: "Account and workspace data", + text: "We store your account email, project metadata, credit balance, billing status, and usage events needed to authenticate you, operate the dashboard, and keep your launch work organized." + }, + { + icon: FileImage, + title: "Screenshots and generated assets", + text: "Uploaded screenshots are stored so LaunchPix can render listing frames, promo tiles, and hero banners. Generated previews, full PNG files, and ZIP exports are stored in the configured Supabase storage buckets for your workspace." + }, + { + icon: Sparkles, + title: "AI planning data", + text: "LaunchPix uses Mistral for structured planning: product context, audience, style direction, and copy structure. Rendering remains deterministic and template-driven inside LaunchPix." + }, + { + icon: Receipt, + title: "Billing data", + text: "Lemon Squeezy handles checkout and payment processing for credit packs. LaunchPix stores payment references, webhook fulfillment status, and credit updates, but does not store raw card details." + }, + { + icon: LockKeyhole, + title: "Operational security", + text: "Access to dashboard data is scoped to the signed-in account. Server-side operations use configured service credentials only where needed for storage, billing, and account workflows." + }, + { + icon: Mail, + title: "Product emails", + text: "LaunchPix may send operational emails about project creation, uploads, generation status, credit balance, billing events, asset downloads, and export activity." + } +]; + export default function PrivacyPage() { return ( -
-
-

What we store

-

- We store your account email, project metadata, uploaded screenshots, generated assets, credit balance, billing status, and usage events needed to operate LaunchPix. -

-
-
-

How screenshots are processed

-

- Screenshots you upload are used to generate launch assets in deterministic layouts. Preview and export files are stored in the configured storage buckets for your workspace. -

-
-
-

Third-party providers

-

- LaunchPix uses Mistral for structured planning and Lemon Squeezy for credit-pack billing. We do not store raw card details on our own servers. -

-
-
-

Data requests

+
+
+ {sections.map((item) => ( +
+ +

{item.title}

+

{item.text}

+
+ ))} +
+ +
+

Data requests and deletion

- For privacy, access, or deletion requests, contact support@launchpix.app. + For privacy, access, correction, export, or deletion requests, contact support@launchpix.app from the email tied to your LaunchPix account. We may need to retain limited billing, fraud-prevention, or legal records where required.

diff --git a/app/terms/page.tsx b/app/terms/page.tsx index 1b3f0a4..6c9d305 100644 --- a/app/terms/page.tsx +++ b/app/terms/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { AlertTriangle, CheckCircle2, CreditCard, Download, Scale, ShieldCheck } from "lucide-react"; import { MarketingPageShell } from "@/components/marketing/page-shell"; export const metadata: Metadata = { @@ -6,6 +7,39 @@ export const metadata: Metadata = { description: "LaunchPix terms covering usage limits, billing, credits, export access, and service constraints." }; +const sections = [ + { + icon: ShieldCheck, + title: "Acceptable use", + text: "Use LaunchPix only for lawful product marketing workflows. You are responsible for the rights to any screenshots, brand assets, product copy, customer content, and other material you upload." + }, + { + icon: CreditCard, + title: "Credits and purchases", + text: "Each generation run consumes one credit. Every account starts with included credits, and you can buy one-time credit packs through Lemon Squeezy when the balance runs out. Credit packs are not recurring subscriptions." + }, + { + icon: Download, + title: "Exports and commercial use", + text: "Generated assets may be used for your product marketing, store listings, launch pages, and campaigns, provided you have the rights to the uploaded source materials and comply with applicable laws." + }, + { + icon: CheckCircle2, + title: "Generated output", + text: "LaunchPix uses structured AI planning and deterministic templates. You are responsible for reviewing generated copy, visuals, claims, and layout before publishing them publicly." + }, + { + icon: AlertTriangle, + title: "Service availability", + text: "LaunchPix is offered on an as-available basis during the MVP stage. Generation, exports, billing confirmation, storage, or third-party services may occasionally be delayed or unavailable." + }, + { + icon: Scale, + title: "No prohibited content", + text: "Do not upload unlawful, infringing, deceptive, abusive, or malicious content. We may restrict access if a workspace is used in a way that risks the product, users, payment providers, or infrastructure." + } +]; + export default function TermsPage() { return ( -
-
-

Acceptable use

-

- Use LaunchPix only for lawful product marketing workflows. You are responsible for the rights to any screenshots, copy, and content you upload. -

-
-
-

Credits

-

- Each generation run consumes one credit. Every account starts with included credits, and you can buy one-time credit packs when the balance runs out. -

-
-
-

Export access

-

- Full-resolution PNG downloads and ZIP export are available while your account has credits. You will be prompted to buy credits after exhausting your balance. -

-
-
-

Commercial use

-

- Launch assets generated through LaunchPix may be used for product marketing as long as you have the rights to the source materials you upload. -

-
-
-

Service availability

+
+
+ {sections.map((item) => ( +
+ +

{item.title}

+

{item.text}

+
+ ))} +
+ +
+

Billing support and disputes

- We aim for reliable service, but LaunchPix is currently offered on an as-available basis while the product is in MVP stage. + If a payment succeeds but credits are not added, contact support@launchpix.app with the account email and checkout reference. Webhook fulfillment usually completes automatically, but support can reconcile confirmed purchases.

diff --git a/components/marketing/footer.tsx b/components/marketing/footer.tsx index c090055..2394509 100644 --- a/components/marketing/footer.tsx +++ b/components/marketing/footer.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { LaunchPixLogo } from "@/components/brand/logo"; const links = [ + { href: "/about" as const, label: "About" }, { href: "/pricing" as const, label: "Pricing" }, { href: "/contact" as const, label: "Contact" }, { href: "/privacy" as const, label: "Privacy" }, diff --git a/components/marketing/top-nav.tsx b/components/marketing/top-nav.tsx index ff3194d..14c6be4 100644 --- a/components/marketing/top-nav.tsx +++ b/components/marketing/top-nav.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { ThemeToggle } from "@/components/ui/theme-toggle"; const navItems = [ + { href: "/about" as const, label: "About" }, { href: "/pricing" as const, label: "Pricing" }, { href: "/contact" as const, label: "Support" } ]; From 65c28b1bd12de6a1cc60e30ce3bf00699d492077 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 07:44:14 +0100 Subject: [PATCH 02/13] Improve Lemon Squeezy checkout errors --- app/api/billing/checkout/route.ts | 18 +++++++++++++++++- lib/payments/lemon-squeezy.ts | 13 ++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts index 29db9e0..ad47749 100644 --- a/app/api/billing/checkout/route.ts +++ b/app/api/billing/checkout/route.ts @@ -24,7 +24,23 @@ export async function POST(req: Request) { }); return NextResponse.json({ checkout_url: data.checkoutUrl, authorization_url: data.checkoutUrl }); - } catch { + } catch (error) { + const message = error instanceof Error ? error.message : "Checkout could not start. Please try again."; + console.error("Lemon Squeezy checkout failed:", message); + + if (message.includes("related resource does not exist") || message.includes("/data/relationships/store") || message.includes("/data/relationships/variant")) { + return NextResponse.json( + { + error: "Checkout is not configured correctly. Confirm the Lemon Squeezy store ID and variant IDs belong to the same account as the API key." + }, + { status: 500 } + ); + } + + if (message.includes("is not configured")) { + return NextResponse.json({ error: message }, { status: 500 }); + } + return NextResponse.json({ error: "Checkout could not start. Please try again." }, { status: 500 }); } } diff --git a/lib/payments/lemon-squeezy.ts b/lib/payments/lemon-squeezy.ts index 9d4ee52..6ac6cd0 100644 --- a/lib/payments/lemon-squeezy.ts +++ b/lib/payments/lemon-squeezy.ts @@ -29,7 +29,18 @@ async function lemonSqueezyRequest(path: string, init: RequestInit) { }); const json = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(json?.errors?.[0]?.detail || json?.message || "Lemon Squeezy request failed"); + if (!res.ok) { + const errors = Array.isArray(json?.errors) ? json.errors : []; + const details = errors + .map((error: { detail?: string; source?: { pointer?: string }; title?: string }) => { + const location = error.source?.pointer ? ` at ${error.source.pointer}` : ""; + return `${error.detail || error.title || "Unknown Lemon Squeezy error"}${location}`; + }) + .filter(Boolean) + .join("; "); + + throw new Error(details || json?.message || "Lemon Squeezy request failed"); + } return json; } From 687c08a3596eb25b137010d1ce4ed51ac7679ca6 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 15:09:22 +0100 Subject: [PATCH 03/13] Improve generated asset result screen --- app/dashboard/projects/[id]/assets/page.tsx | 63 ++++--- components/dashboard/assets-manager.tsx | 187 +++++++++++++++----- 2 files changed, 180 insertions(+), 70 deletions(-) diff --git a/app/dashboard/projects/[id]/assets/page.tsx b/app/dashboard/projects/[id]/assets/page.tsx index 9dd39d9..4daabd0 100644 --- a/app/dashboard/projects/[id]/assets/page.tsx +++ b/app/dashboard/projects/[id]/assets/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { Clock3, Download, Images, Sparkles } from "lucide-react"; import { AssetsManager } from "@/components/dashboard/assets-manager"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -15,7 +16,7 @@ export default async function AssetsPage({ params }: { params: Promise<{ id: str const { project } = await getProjectOverview(id, user.id); const generation = await getLatestGeneration(id); const history = await getGenerationHistory(id); - const { plan, subscription } = await getAccessContext(user.id); + const { plan } = await getAccessContext(user.id); if (!generation) { return ( @@ -33,31 +34,34 @@ export default async function AssetsPage({ params }: { params: Promise<{ id: str } const assets = await getGenerationAssets(generation.id); + const completedAt = generation.updated_at ? new Date(generation.updated_at).toLocaleString() : new Date(generation.created_at).toLocaleString(); + const listingCount = assets.filter((asset: { asset_type: string }) => asset.asset_type.includes("listing")).length; return (
-
+
-

Asset studio

-

Review, refine, and export assets for {project.name}.

+

Launch pack result

+

Your generated assets for {project.name} are ready.

- Edit copy, rerender variants, and ship the final pack from a single production-ready canvas. + Review the output as a launch pack, not a loose image dump. Check the hero preview, scan every generated file, then export the pack when the visual story is ready.

-
-
-

Account

-

{plan.label}

-
-
-

Credits

-

{subscription.credits_remaining}

-
-
-

Export mode

-

{plan.fullResolutionExport ? "Full" : "Preview"}

-
+ +
+ {[ + { label: "Status", value: generation.status.replaceAll("_", " "), icon: Sparkles }, + { label: "Generated", value: `${assets.length} assets`, icon: Images }, + { label: "Export", value: plan.fullResolutionExport ? "Full resolution" : "Preview", icon: Download }, + { label: "Completed", value: completedAt, icon: Clock3 } + ].map((item) => ( +
+ +

{item.label}

+

{item.value}

+
+ ))}
@@ -66,19 +70,24 @@ export default async function AssetsPage({ params }: { params: Promise<{ id: str -
-

History

-

Recent generation runs

+
+
+

Generation history

+

Recent runs

+
+

{listingCount} listing frames in the current pack.

{history.map((item: { id: string; created_at: string; status: string; error_message: string | null }) => ( -
-

{new Date(item.created_at).toLocaleString()}

-

- Status: {item.status} - {item.error_message ? ` · ${item.error_message}` : ""} -

+
+
+

{new Date(item.created_at).toLocaleString()}

+

+ Status: {item.status.replaceAll("_", " ")} +

+
+ {item.error_message ?

{item.error_message}

: null}
))}
diff --git a/components/dashboard/assets-manager.tsx b/components/dashboard/assets-manager.tsx index 1e7c435..e741695 100644 --- a/components/dashboard/assets-manager.tsx +++ b/components/dashboard/assets-manager.tsx @@ -1,11 +1,33 @@ "use client"; import { useState, useTransition } from "react"; +import { Download, FileArchive, ImageIcon, PencilLine, RefreshCw } from "lucide-react"; import type { AssetRecord, GenerationRecord } from "@/types/project"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -export function AssetsManager({ projectId, generation, assets, canDownloadFull }: { projectId: string; generation: GenerationRecord | null; assets: AssetRecord[]; canDownloadFull: boolean }) { +function assetLabel(type: string) { + return type.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function assetPurpose(type: string) { + if (type.includes("listing")) return "Store listing"; + if (type.includes("promo")) return "Campaign tile"; + if (type.includes("hero")) return "Landing hero"; + return "Launch visual"; +} + +export function AssetsManager({ + projectId, + generation, + assets, + canDownloadFull +}: { + projectId: string; + generation: GenerationRecord | null; + assets: AssetRecord[]; + canDownloadFull: boolean; +}) { const [editing, setEditing] = useState(null); const [headline, setHeadline] = useState(""); const [subheadline, setSubheadline] = useState(""); @@ -31,6 +53,9 @@ export function AssetsManager({ projectId, generation, assets, canDownloadFull } window.location.reload(); } + const heroAsset = assets.find((asset) => asset.asset_type.includes("hero")) ?? assets[0]; + const listingAssets = assets.filter((asset) => asset.asset_type.includes("listing")); + return (
{!canDownloadFull ? ( @@ -45,61 +70,137 @@ export function AssetsManager({ projectId, generation, assets, canDownloadFull } ) : null} - - -
- {assets.map((asset) => ( - -
- {asset.asset_type.replaceAll("_", " ")} - {asset.width}×{asset.height} +
+
+
+
+
+

Generated pack

+

Review the pack before you ship it.

+

+ This is the finished output from your latest generation: inspect the hierarchy, download files, or adjust copy and rerender individual assets. +

+
+ +
+ +
+ {[ + ["Total assets", assets.length.toString()], + ["Listing frames", listingAssets.length.toString()], + ["Export mode", canDownloadFull ? "Full resolution" : "Preview"] + ].map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))}
+
- - {asset.asset_type} +
+ {heroAsset ? ( + <> +
+ {assetPurpose(heroAsset.asset_type)} + {heroAsset.width} x {heroAsset.height} +
+ {assetLabel(heroAsset.asset_type)} + + ) : ( +
+ No preview asset found. +
+ )} +
+
+
+ +
+
+
+

Asset review

+

All generated files

+
+

+ Download the final asset directly, or edit the text layer and rerender when a headline needs more polish. +

+
+ +
+ {assets.map((asset) => ( + +
+ + + {assetPurpose(asset.asset_type)} + + {asset.width} x {asset.height} +
-
-

Template: {(asset.metadata_json as { template_family?: string } | null)?.template_family || "minimal"}

-
+ + {assetLabel(asset.asset_type)} + +
+

{assetLabel(asset.asset_type)}

+

Template: {(asset.metadata_json as { template_family?: string } | null)?.template_family || "minimal"}

+
+ +
-
- {editing === asset.id ? ( -
- setHeadline(event.target.value)} /> - setSubheadline(event.target.value)} /> - -
- - + {editing === asset.id ? ( +
+ setHeadline(event.target.value)} /> + setSubheadline(event.target.value)} /> + +
+ + +
-
- ) : null} - - - ))} -
+ ) : null} + + + ))} +
+
); } From db2d80cb1e7e7a4c5221410e6bfc6225360d7315 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 29 Apr 2026 16:44:14 +0100 Subject: [PATCH 04/13] Generate assets with Mistral image agent --- .env.example | 7 +- README.md | 18 ++-- lib/ai/mistral-image.ts | 139 +++++++++++++++++++++++++++++ lib/services/generations/runner.ts | 57 +++++++----- 4 files changed, 190 insertions(+), 31 deletions(-) create mode 100644 lib/ai/mistral-image.ts diff --git a/.env.example b/.env.example index 9702356..0d9f73a 100644 --- a/.env.example +++ b/.env.example @@ -9,12 +9,17 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= DATABASE_URL= -# Mistral AI (structured planning) +# Mistral AI (structured planning + image generation) MISTRAL_API_KEY= # Default: mistral-small-2506 MISTRAL_MODEL_VISION=mistral-small-2506 # Default: mistral-small-2506 MISTRAL_MODEL_TEXT=mistral-small-2506 +# Default: mistral-medium-latest +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= # Lemon Squeezy credit billing LEMON_SQUEEZY_API_KEY= diff --git a/README.md b/README.md index 6912d79..3f681ce 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # LaunchPix -LaunchPix is a Mistral-assisted, deterministic asset generator for product launches. +LaunchPix is a Mistral-assisted asset generator for product launches. It turns raw screenshots into polished listing visuals, promo tiles, and hero banners. ## Design system @@ -12,15 +12,15 @@ It turns raw screenshots into polished listing visuals, promo tiles, and hero ba - Next.js App Router + TypeScript - Tailwind CSS + reusable UI primitives - Supabase (Auth, Postgres, Storage) -- Mistral (structured planning only) -- Deterministic SVG -> PNG rendering (`@resvg/resvg-js`) +- 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. Render deterministic asset pack (5 listing + promo + hero) +4. Generate image assets through a Mistral image-generation agent 5. Preview/download assets while credits remain ## Pricing model implemented @@ -40,6 +40,8 @@ Minimum required: - `MISTRAL_API_KEY` - `MISTRAL_MODEL_VISION` - `MISTRAL_MODEL_TEXT` +- `MISTRAL_IMAGE_MODEL` +- `MISTRAL_IMAGE_AGENT_ID` (optional) - `LEMON_SQUEEZY_API_KEY` - `LEMON_SQUEEZY_STORE_ID` - `LEMON_SQUEEZY_WEBHOOK_SECRET` @@ -77,9 +79,11 @@ Recommended validation commands: ## Mistral notes - Mistral is used for structured product/copy/layout planning. -- Rendering remains deterministic and template-driven. -- Default model: `mistral-small-2506` (configurable via env). -- The app currently uses the text model for schema-constrained planning and does not rely on image-vision inputs during generation. +- 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` diff --git a/lib/ai/mistral-image.ts b/lib/ai/mistral-image.ts new file mode 100644 index 0000000..caf80d2 --- /dev/null +++ b/lib/ai/mistral-image.ts @@ -0,0 +1,139 @@ +import { Mistral } from "@mistralai/mistralai"; +import type { GenerationPlan } from "@/lib/ai/schemas/asset-plan"; + +const imageModel = process.env.MISTRAL_IMAGE_MODEL || "mistral-medium-latest"; + +let cachedImageAgentId: string | null = null; + +function redactError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return message.replaceAll(process.env.MISTRAL_API_KEY || "", "[redacted]"); +} + +async function streamToBuffer(stream: ReadableStream) { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(value); + } + + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); +} + +async function getImageAgentId(client: Mistral) { + if (process.env.MISTRAL_IMAGE_AGENT_ID) return process.env.MISTRAL_IMAGE_AGENT_ID; + if (cachedImageAgentId) return cachedImageAgentId; + + const agent = await client.beta.agents.create({ + model: imageModel, + name: "LaunchPix Image Generation Agent", + description: "Generates final LaunchPix product marketing assets.", + instructions: + "Use the image_generation tool whenever the user requests a LaunchPix asset. Generate polished product marketing visuals with clear hierarchy, strong composition, and no placeholder UI.", + tools: [{ type: "image_generation" }], + completionArgs: { + temperature: 0.35, + topP: 0.95 + } + }); + + cachedImageAgentId = agent.id; + return agent.id; +} + +function findGeneratedFileId(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + if (Array.isArray(value)) { + for (const item of value) { + const found = findGeneratedFileId(item); + if (found) return found; + } + return null; + } + + const record = value as Record; + if (record.type === "tool_file" && typeof record.fileId === "string") return record.fileId; + if (record.type === "tool_file" && typeof record.file_id === "string") return record.file_id; + + for (const nested of Object.values(record)) { + const found = findGeneratedFileId(nested); + if (found) return found; + } + + return null; +} + +function buildImagePrompt(input: { + plan: GenerationPlan; + asset: GenerationPlan["assets"][number] & { screenshotUrls: string[] }; + project: { + name: string; + product_type: string; + platform: string; + description: string; + audience: string; + primary_color: string | null; + }; +}) { + const { asset, plan, project } = input; + const screenshotReferences = asset.screenshotUrls.length + ? `Use these uploaded product screenshots as the source-product reference if accessible: ${asset.screenshotUrls.join(", ")}.` + : "No screenshot reference URLs are available, so create a credible product UI presentation based on the brief."; + + return [ + `Generate one finished LaunchPix marketing asset as a PNG image.`, + `Canvas: ${asset.width}x${asset.height}px.`, + `Asset type: ${asset.asset_type.replaceAll("_", " ")}.`, + `Product: ${project.name}.`, + `Product type: ${project.product_type.replaceAll("_", " ")}.`, + `Target platform: ${project.platform.replaceAll("_", " ")}.`, + `Audience: ${project.audience}.`, + `Product description: ${project.description}.`, + `Primary brand color: ${project.primary_color || "choose a polished modern accent color"}.`, + `Headline to place in the design: "${asset.headline || plan.selected_headline}".`, + `Subheadline to place in the design: "${asset.subheadline || plan.subheadline}".`, + `Callouts: ${asset.callouts.join("; ")}.`, + `CTA: ${plan.cta_line}.`, + screenshotReferences, + `Design requirements: premium SaaS/product-launch visual, clean composition, real-looking app or web UI frame, sharp readable typography, strong spacing, no generic AI artifacts, no misspelled text, no fake watermarks, no extra logos unless they fit LaunchPix/product context.`, + `Return the image file only.` + ].join("\n"); +} + +export async function generateMistralAssetPng(input: { + plan: GenerationPlan; + asset: GenerationPlan["assets"][number] & { screenshotUrls: string[] }; + project: { + name: string; + product_type: string; + platform: string; + description: string; + audience: string; + primary_color: string | null; + }; +}) { + if (!process.env.MISTRAL_API_KEY) throw new Error("MISTRAL_API_KEY is not configured."); + + const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY }); + const agentId = await getImageAgentId(client); + const response = await client.beta.conversations.start({ + agentId, + inputs: buildImagePrompt(input), + store: false + }); + + const fileId = findGeneratedFileId(response.outputs); + if (!fileId) throw new Error("Mistral image generation did not return an image file."); + + try { + const stream = await client.files.download({ fileId }); + return streamToBuffer(stream); + } catch (error) { + throw new Error(`Could not download Mistral generated image: ${redactError(error)}`); + } +} + +export const mistralImageModels = { imageModel }; diff --git a/lib/services/generations/runner.ts b/lib/services/generations/runner.ts index 5426a8b..f79b158 100644 --- a/lib/services/generations/runner.ts +++ b/lib/services/generations/runner.ts @@ -1,6 +1,7 @@ import JSZip from "jszip"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { mistralAdapter } from "@/lib/ai/generate-asset-plan"; +import { generateMistralAssetPng } from "@/lib/ai/mistral-image"; import { createDeterministicGenerationPlan } from "@/lib/ai/mistral"; import { generationPlanSchema } from "@/lib/ai/schemas/asset-plan"; import { buildDeterministicAssets, renderAssetPng } from "@/lib/render/pipeline"; @@ -60,30 +61,39 @@ export async function runGenerationForProject(project: ProjectRecord, uploads: U const zip = new JSZip(); for (const [index, asset] of deterministicAssets.entries()) { - const fullPng = await renderAssetPng({ - width: asset.width, - height: asset.height, - templateFamily: asset.template_family, - headline: asset.headline, - subheadline: asset.subheadline, - callouts: asset.callouts, - cta: safePlan.cta_line, - screenshotUrls: asset.screenshotUrls, - primaryColor: project.primary_color - }); + let renderSource: "mistral_image_generation" | "deterministic_template" = "mistral_image_generation"; + let fullPng: Buffer | Uint8Array; + + try { + fullPng = await generateMistralAssetPng({ + plan: safePlan, + asset, + project: { + name: project.name, + product_type: project.product_type, + platform: project.platform, + description: project.description, + audience: project.audience, + primary_color: project.primary_color + } + }); + } catch (error) { + renderSource = "deterministic_template"; + console.error("Mistral image generation failed; using deterministic renderer:", error instanceof Error ? error.message : error); + fullPng = await renderAssetPng({ + width: asset.width, + height: asset.height, + templateFamily: asset.template_family, + headline: asset.headline, + subheadline: asset.subheadline, + callouts: asset.callouts, + cta: safePlan.cta_line, + screenshotUrls: asset.screenshotUrls, + primaryColor: project.primary_color + }); + } - const previewPng = await renderAssetPng({ - width: asset.width, - height: asset.height, - templateFamily: asset.template_family, - headline: asset.headline, - subheadline: asset.subheadline, - callouts: asset.callouts, - cta: safePlan.cta_line, - screenshotUrls: asset.screenshotUrls, - primaryColor: project.primary_color, - watermarkText: "LaunchPix Preview" - }); + const previewPng = fullPng; const filename = `${String(index + 1).padStart(2, "0")}-${asset.asset_type}.png`; const fullPath = `${project.user_id}/${project.id}/${generation.id}/full/${filename}`; @@ -106,6 +116,7 @@ export async function runGenerationForProject(project: ProjectRecord, uploads: U preview_url: previewUrl.publicUrl, metadata_json: { template_family: asset.template_family, + render_source: renderSource, notes: asset.notes, callouts: asset.callouts, screenshot_ids: asset.screenshot_ids, From e25e595e2047b04636acac59643cc49acab3649a Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 1 May 2026 05:26:13 +0100 Subject: [PATCH 05/13] Harden billing and generation workflow --- app/api/assets/[assetId]/route.ts | 90 +++++++++++++++---- app/api/billing/checkout/route.ts | 9 +- app/dashboard/layout.tsx | 2 + app/login/page.tsx | 2 + app/settings/layout.tsx | 2 + components/brand/logo.tsx | 34 ++++--- components/dashboard/assets-manager.tsx | 24 ++++- components/marketing/landing-sections.tsx | 44 +++++++++ lib/ai/mistral-image.ts | 16 +++- lib/payments/lemon-squeezy.ts | 30 ++++++- lib/services/billing/subscription.ts | 44 ++++++--- lib/services/generations/runner.ts | 72 +++++++++++++-- public/icon.svg | 26 +++--- .../migrations/0005_credit_transactions.sql | 19 ++++ .../migrations/0006_atomic_credit_grants.sql | 33 +++++++ 15 files changed, 379 insertions(+), 68 deletions(-) create mode 100644 supabase/migrations/0005_credit_transactions.sql create mode 100644 supabase/migrations/0006_atomic_credit_grants.sql diff --git a/app/api/assets/[assetId]/route.ts b/app/api/assets/[assetId]/route.ts index 4a89778..9455215 100644 --- a/app/api/assets/[assetId]/route.ts +++ b/app/api/assets/[assetId]/route.ts @@ -3,6 +3,9 @@ import { requireUser } from "@/lib/supabase/auth"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { updateAssetMetadata } from "@/lib/services/assets/edits"; import { renderAssetPng } from "@/lib/render/pipeline"; +import { generateMistralAssetPng } from "@/lib/ai/mistral-image"; +import { generationPlanSchema, templateFamilySchema } from "@/lib/ai/schemas/asset-plan"; +import { trackEvent } from "@/lib/services/analytics/events"; const ASSET_BUCKET = process.env.STORAGE_BUCKET_ASSETS || "launchpix-assets"; @@ -10,7 +13,7 @@ async function ensureOwnership(assetId: string, userId: string) { const supabase = await createSupabaseServerClient(); const { data } = await supabase .from("assets") - .select("id, file_url, asset_type, width, height, generation_id, metadata_json, generations!inner(projects!inner(user_id))") + .select("id, file_url, asset_type, width, height, generation_id, metadata_json, generations!inner(id, copy_json, projects!inner(id, user_id, name, product_type, platform, description, audience, primary_color))") .eq("id", assetId) .eq("generations.projects.user_id", userId) .single(); @@ -18,6 +21,16 @@ async function ensureOwnership(assetId: string, userId: string) { return data; } +function editableMetadata(asset: any, body: Record) { + return { ...((asset.metadata_json as any)?.editable || {}), ...(body || {}) }; +} + +async function getGenerationUploads(projectId: string) { + const supabase = await createSupabaseServerClient(); + const { data } = await supabase.from("uploads").select("*").eq("project_id", projectId).order("position", { ascending: true }); + return data || []; +} + export async function PATCH(req: Request, { params }: { params: Promise<{ assetId: string }> }) { const { user } = await requireUser(); const { assetId } = await params; @@ -37,19 +50,50 @@ export async function POST(req: Request, { params }: { params: Promise<{ assetId if (!asset) return NextResponse.json({ error: "Asset not found" }, { status: 404 }); const body = await req.json().catch(() => ({})); - const editable = { ...((asset.metadata_json as any)?.editable || {}), ...(body || {}) }; - - const png = await renderAssetPng({ - width: asset.width, - height: asset.height, - templateFamily: editable.templateFamily || (asset.metadata_json as any)?.template_family || "minimal", - headline: editable.headline || "Launch visuals in minutes", - subheadline: editable.subheadline || "Deterministic, conversion-focused design output.", - callouts: editable.callouts || ["Premium templates", "Reliable exports", "Built for product launches"], - cta: "Try LaunchPix", - screenshotUrls: [], - primaryColor: editable.primaryColor || "#4F46E5" - }); + const editable = editableMetadata(asset, body); + const templateFamily = templateFamilySchema.catch("minimal").parse(editable.templateFamily || (asset.metadata_json as any)?.template_family || "minimal"); + const project = (asset as any).generations?.projects; + const generation = (asset as any).generations; + let renderSource: "mistral_image_generation" | "deterministic_template" = "mistral_image_generation"; + let png: Buffer | Uint8Array; + + try { + const parsedPlan = generationPlanSchema.parse(generation.copy_json); + const originalPlanAsset = parsedPlan.assets.find((item) => item.asset_type === asset.asset_type) || parsedPlan.assets[0]; + const uploads = await getGenerationUploads(project.id); + const screenshotById = new Map(uploads.map((upload: any) => [upload.id, upload.file_url])); + const screenshotUrls = originalPlanAsset.screenshot_ids.map((id) => screenshotById.get(id)).filter(Boolean) as string[]; + + png = await generateMistralAssetPng({ + plan: parsedPlan, + asset: { + ...originalPlanAsset, + asset_type: asset.asset_type, + width: asset.width, + height: asset.height, + headline: String(editable.headline || originalPlanAsset.headline), + subheadline: String(editable.subheadline || originalPlanAsset.subheadline), + callouts: Array.isArray(editable.callouts) ? editable.callouts.map(String).slice(0, 3) : originalPlanAsset.callouts, + template_family: templateFamily, + screenshotUrls + }, + project + }); + } catch (error) { + renderSource = "deterministic_template"; + console.error("Mistral asset rerender failed; using deterministic renderer:", error instanceof Error ? error.message : error); + png = await renderAssetPng({ + width: asset.width, + height: asset.height, + templateFamily, + headline: String(editable.headline || "Launch visuals in minutes"), + subheadline: String(editable.subheadline || "Deterministic, conversion-focused design output."), + callouts: Array.isArray(editable.callouts) ? editable.callouts.map(String).slice(0, 3) : ["Premium templates", "Reliable exports", "Built for product launches"], + cta: "Try LaunchPix", + screenshotUrls: [], + primaryColor: String(editable.primaryColor || project?.primary_color || "#4F46E5") + }); + } const path = `${user.id}/rerendered/${asset.generation_id}/${asset.id}.png`; const supabase = await createSupabaseServerClient(); @@ -57,7 +101,21 @@ export async function POST(req: Request, { params }: { params: Promise<{ assetId if (uploadError) return NextResponse.json({ error: uploadError.message }, { status: 500 }); const { data: pub } = supabase.storage.from(ASSET_BUCKET).getPublicUrl(path); - await supabase.from("assets").update({ file_url: pub.publicUrl, preview_url: pub.publicUrl }).eq("id", asset.id); + await supabase + .from("assets") + .update({ + file_url: pub.publicUrl, + preview_url: pub.publicUrl, + metadata_json: { + ...((asset.metadata_json as Record | null) || {}), + editable, + render_source: renderSource, + rerendered_at: new Date().toISOString() + } + }) + .eq("id", asset.id); + + await trackEvent({ userId: user.id, projectId: project?.id, eventType: "asset_rerendered", metadata: { assetId: asset.id, generationId: asset.generation_id, render_source: renderSource } }); - return NextResponse.json({ ok: true, file_url: pub.publicUrl }); + return NextResponse.json({ ok: true, file_url: pub.publicUrl, render_source: renderSource }); } diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts index ad47749..66060dc 100644 --- a/app/api/billing/checkout/route.ts +++ b/app/api/billing/checkout/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from "next/server"; import { requireUser } from "@/lib/supabase/auth"; -import { createCreditCheckout } from "@/lib/payments/lemon-squeezy"; +import { createCreditCheckout, validateCreditCheckoutConfig } from "@/lib/payments/lemon-squeezy"; import { trackEvent } from "@/lib/services/analytics/events"; import { isCreditPackId } from "@/lib/services/billing/plans"; +import { buildAppUrl } from "@/lib/app-url"; export async function POST(req: Request) { try { @@ -14,13 +15,15 @@ export async function POST(req: Request) { const email = user.email; if (!email) return NextResponse.json({ error: "No verified email found for checkout." }, { status: 400 }); + await validateCreditCheckoutConfig(packId); + await trackEvent({ userId: user.id, eventType: "checkout_started", metadata: { pack: packId, provider: "lemon_squeezy" } }); const data = await createCreditCheckout({ email, packId, userId: user.id, - callbackUrl: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing?checkout=success` + callbackUrl: buildAppUrl("/settings/billing?checkout=success", req) }); return NextResponse.json({ checkout_url: data.checkoutUrl, authorization_url: data.checkoutUrl }); @@ -37,7 +40,7 @@ export async function POST(req: Request) { ); } - if (message.includes("is not configured")) { + if (message.includes("is not configured") || message.includes("must be a numeric") || message.includes("belongs to Lemon Squeezy store")) { return NextResponse.json({ error: message }, { status: 500 }); } diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 8e28f21..3880a08 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -4,6 +4,8 @@ import { DashboardTopbar } from "@/components/dashboard/topbar"; import { getAccessContext } from "@/lib/services/access/permissions"; import { requireUser } from "@/lib/supabase/auth"; +export const dynamic = "force-dynamic"; + export default async function DashboardLayout({ children }: { children: ReactNode }) { const { user } = await requireUser(); const { subscription, plan } = await getAccessContext(user.id); diff --git a/app/login/page.tsx b/app/login/page.tsx index ecca5da..cf94af6 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -13,6 +13,8 @@ export const metadata: Metadata = { description: "Sign in to LaunchPix with Google and start generating polished launch visuals from raw screenshots." }; +export const dynamic = "force-dynamic"; + export default async function LoginPage() { const session = await auth(); if (session?.user?.email) redirect("/dashboard/projects"); diff --git a/app/settings/layout.tsx b/app/settings/layout.tsx index fc862c3..50046fe 100644 --- a/app/settings/layout.tsx +++ b/app/settings/layout.tsx @@ -4,6 +4,8 @@ import { DashboardTopbar } from "@/components/dashboard/topbar"; import { getAccessContext } from "@/lib/services/access/permissions"; import { requireUser } from "@/lib/supabase/auth"; +export const dynamic = "force-dynamic"; + export default async function SettingsLayout({ children }: { children: ReactNode }) { const { user } = await requireUser(); const { subscription, plan } = await getAccessContext(user.id); diff --git a/components/brand/logo.tsx b/components/brand/logo.tsx index 3d49c7b..40b6c88 100644 --- a/components/brand/logo.tsx +++ b/components/brand/logo.tsx @@ -2,32 +2,38 @@ import { cn } from "@/lib/utils"; export function LaunchPixLogo({ className, markClassName }: { className?: string; markClassName?: string }) { return ( - + - - LaunchPix - Launch studio + LaunchPix + Launch asset studio ); diff --git a/components/dashboard/assets-manager.tsx b/components/dashboard/assets-manager.tsx index e741695..21e0ec7 100644 --- a/components/dashboard/assets-manager.tsx +++ b/components/dashboard/assets-manager.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useTransition } from "react"; -import { Download, FileArchive, ImageIcon, PencilLine, RefreshCw } from "lucide-react"; +import { Download, FileArchive, ImageIcon, PencilLine, RefreshCw, Sparkles } from "lucide-react"; import type { AssetRecord, GenerationRecord } from "@/types/project"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -32,24 +32,37 @@ export function AssetsManager({ const [headline, setHeadline] = useState(""); const [subheadline, setSubheadline] = useState(""); const [templateFamily, setTemplateFamily] = useState("minimal"); + const [actionError, setActionError] = useState(null); const [pending, startTransition] = useTransition(); async function save(assetId: string) { - await fetch(`/api/assets/${assetId}`, { + setActionError(null); + const res = await fetch(`/api/assets/${assetId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ headline, subheadline, templateFamily }) }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + setActionError(json.error || "Could not save this asset."); + return; + } setEditing(null); window.location.reload(); } async function rerenderAsset(assetId: string) { - await fetch(`/api/assets/${assetId}`, { + setActionError(null); + const res = await fetch(`/api/assets/${assetId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ templateFamily }) }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + setActionError(json.error || "Could not rerender this asset."); + return; + } window.location.reload(); } @@ -143,6 +156,7 @@ export function AssetsManager({ Download the final asset directly, or edit the text layer and rerender when a headline needs more polish.

+ {actionError ?

{actionError}

: null}
{assets.map((asset) => ( @@ -161,6 +175,10 @@ export function AssetsManager({

{assetLabel(asset.asset_type)}

Template: {(asset.metadata_json as { template_family?: string } | null)?.template_family || "minimal"}

+

+ + {(asset.metadata_json as { render_source?: string } | null)?.render_source === "mistral_image_generation" ? "Mistral image generated" : "Template fallback"} +

diff --git a/components/marketing/landing-sections.tsx b/components/marketing/landing-sections.tsx index e4b525d..658c001 100644 --- a/components/marketing/landing-sections.tsx +++ b/components/marketing/landing-sections.tsx @@ -48,6 +48,27 @@ const trustSignals = [ { value: "1", label: "ZIP handoff when approved" } ]; +const sampleOutputs = [ + { + title: "App listing frame", + size: "1290 x 2796", + description: "Feature-led store artwork with a product screen, benefit headline, and supporting proof point.", + visual: AppListingPreview + }, + { + title: "Promo launch tile", + size: "1024 x 1024", + description: "A square campaign asset for launch posts, newsletters, communities, and paid social tests.", + visual: PromoTilePreview + }, + { + title: "Landing hero banner", + size: "1600 x 900", + description: "A wide hero visual that gives visitors a polished product impression above the fold.", + visual: HeroBannerPreview + } +]; + function PhoneMockup({ className = "" }: { className?: string }) { return (
@@ -260,6 +281,29 @@ export function LandingSections() {
+
+
+

Sample pack

+

Visitors can see the kind of visuals LaunchPix creates.

+

+ Each pack is structured around the same product story, then rendered into channel-ready formats instead of leaving users with disconnected screenshots. +

+
+ +
+ {sampleOutputs.map((item) => ( +
+
+ {item.title} + {item.size} +
+ +

{item.description}

+
+ ))} +
+
+
diff --git a/lib/ai/mistral-image.ts b/lib/ai/mistral-image.ts index caf80d2..0791b27 100644 --- a/lib/ai/mistral-image.ts +++ b/lib/ai/mistral-image.ts @@ -2,6 +2,8 @@ import { Mistral } from "@mistralai/mistralai"; import type { GenerationPlan } from "@/lib/ai/schemas/asset-plan"; const imageModel = process.env.MISTRAL_IMAGE_MODEL || "mistral-medium-latest"; +const MIN_IMAGE_BYTES = 24_000; +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); let cachedImageAgentId: string | null = null; @@ -23,6 +25,18 @@ async function streamToBuffer(stream: ReadableStream) { return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); } +function assertUsablePng(buffer: Buffer) { + if (buffer.length < MIN_IMAGE_BYTES) { + throw new Error(`Mistral image output was too small to be a usable asset (${buffer.length} bytes).`); + } + + if (!buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) { + throw new Error("Mistral image output was not a PNG file."); + } + + return buffer; +} + async function getImageAgentId(client: Mistral) { if (process.env.MISTRAL_IMAGE_AGENT_ID) return process.env.MISTRAL_IMAGE_AGENT_ID; if (cachedImageAgentId) return cachedImageAgentId; @@ -130,7 +144,7 @@ export async function generateMistralAssetPng(input: { try { const stream = await client.files.download({ fileId }); - return streamToBuffer(stream); + return assertUsablePng(await streamToBuffer(stream)); } catch (error) { throw new Error(`Could not download Mistral generated image: ${redactError(error)}`); } diff --git a/lib/payments/lemon-squeezy.ts b/lib/payments/lemon-squeezy.ts index 6ac6cd0..d15cb49 100644 --- a/lib/payments/lemon-squeezy.ts +++ b/lib/payments/lemon-squeezy.ts @@ -15,6 +15,12 @@ function getVariantId(packId: CreditPackId) { return requiredEnv(pack.variantEnvKey); } +function requireNumericEnv(name: string) { + const value = requiredEnv(name).trim(); + if (!/^\d+$/.test(value)) throw new Error(`${name} must be a numeric Lemon Squeezy ID`); + return value; +} + async function lemonSqueezyRequest(path: string, init: RequestInit) { const apiKey = requiredEnv("LEMON_SQUEEZY_API_KEY"); @@ -52,6 +58,8 @@ export async function createCreditCheckout(input: { }) { const pack = getCreditPack(input.packId); if (!pack) throw new Error("Unknown credit pack"); + const storeId = requireNumericEnv("LEMON_SQUEEZY_STORE_ID"); + const variantId = requireNumericEnv(pack.variantEnvKey); const json = await lemonSqueezyRequest("/checkouts", { method: "POST", @@ -65,7 +73,7 @@ export async function createCreditCheckout(input: { redirect_url: input.callbackUrl, receipt_button_text: "Return to LaunchPix", receipt_link_url: input.callbackUrl, - enabled_variants: [Number(getVariantId(input.packId))] + enabled_variants: [Number(variantId)] }, checkout_options: { embed: false, @@ -93,13 +101,13 @@ export async function createCreditCheckout(input: { store: { data: { type: "stores", - id: requiredEnv("LEMON_SQUEEZY_STORE_ID") + id: storeId } }, variant: { data: { type: "variants", - id: getVariantId(input.packId) + id: variantId } } } @@ -112,6 +120,22 @@ export async function createCreditCheckout(input: { return { checkoutUrl: url }; } +export async function validateCreditCheckoutConfig(packId: CreditPackId) { + const pack = getCreditPack(packId); + if (!pack) throw new Error("Unknown credit pack"); + + const storeId = requireNumericEnv("LEMON_SQUEEZY_STORE_ID"); + const variantId = requireNumericEnv(pack.variantEnvKey); + const json = await lemonSqueezyRequest(`/variants/${variantId}`, { method: "GET" }); + const variantStoreId = String(json?.data?.relationships?.store?.data?.id || ""); + + if (variantStoreId && variantStoreId !== storeId) { + throw new Error(`${pack.variantEnvKey} belongs to Lemon Squeezy store ${variantStoreId}, but LEMON_SQUEEZY_STORE_ID is ${storeId}`); + } + + return { storeId, variantId }; +} + export function verifyLemonSqueezyWebhookSignature(body: string, signature: string | null) { const secret = process.env.LEMON_SQUEEZY_WEBHOOK_SECRET; if (!secret || !signature) return false; diff --git a/lib/services/billing/subscription.ts b/lib/services/billing/subscription.ts index bae7e7d..ed1d304 100644 --- a/lib/services/billing/subscription.ts +++ b/lib/services/billing/subscription.ts @@ -71,23 +71,41 @@ export async function consumeGenerationCredit(userId: string) { return data; } -export async function grantCreditPack(userId: string, packId: CreditPackId, providerRef: string) { +export async function refundGenerationCredit(userId: string, reason: string, generationId?: string) { const supabase = await createSupabaseServerClient(); const current = await getOrCreateSubscription(userId); - const pack = PLAN_CONFIG[packId]; - const credits = current.credits_remaining + pack.creditsGranted; - const { error } = await supabase + const { data, error } = await supabase .from("subscriptions") - .update({ - plan: "credits", - status: "active", - credits_remaining: credits, - provider: "lemon_squeezy", - provider_reference: providerRef, - last_payment_at: new Date().toISOString() - }) - .eq("id", current.id); + .update({ credits_remaining: current.credits_remaining + 1 }) + .eq("id", current.id) + .select("*") + .single(); + + if (error || !data) throw new Error(error?.message || "Could not refund generation credit."); + + await supabase.from("usage_events").insert({ + user_id: userId, + project_id: null, + event_type: "generation_credit_refunded", + metadata_json: { reason, generationId } + }); + + return data; +} + +export async function grantCreditPack(userId: string, packId: CreditPackId, providerRef: string) { + const supabase = await createSupabaseServerClient(); + await getOrCreateSubscription(userId); + const pack = PLAN_CONFIG[packId]; + + const { error } = await supabase.rpc("grant_credit_pack_atomic", { + p_user_id: userId, + p_source: "lemon_squeezy", + p_provider_reference: providerRef, + p_credits: pack.creditsGranted, + p_metadata: { packId } + }); if (error) throw new Error(error.message); } diff --git a/lib/services/generations/runner.ts b/lib/services/generations/runner.ts index f79b158..9ec794e 100644 --- a/lib/services/generations/runner.ts +++ b/lib/services/generations/runner.ts @@ -6,16 +6,52 @@ import { createDeterministicGenerationPlan } from "@/lib/ai/mistral"; import { generationPlanSchema } from "@/lib/ai/schemas/asset-plan"; import { buildDeterministicAssets, renderAssetPng } from "@/lib/render/pipeline"; import type { ProjectRecord, UploadRecord } from "@/types/project"; -import { consumeGenerationCredit, getOrCreateSubscription } from "@/lib/services/billing/subscription"; +import { consumeGenerationCredit, getOrCreateSubscription, refundGenerationCredit } from "@/lib/services/billing/subscription"; import { PLAN_CONFIG } from "@/lib/services/billing/plans"; import { trackEvent } from "@/lib/services/analytics/events"; const ASSET_BUCKET = process.env.STORAGE_BUCKET_ASSETS || "launchpix-assets"; +const MISTRAL_ASSET_TIMEOUT_MS = 45_000; +const MISTRAL_RENDER_ATTEMPTS = 2; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function withTimeout(promise: Promise, timeoutMs: number, label: string) { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${Math.round(timeoutMs / 1000)}s`)), timeoutMs); + }); + + try { + return await Promise.race([promise, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function generateMistralAssetWithRetry(input: Parameters[0]) { + let lastError: unknown; + + for (let attempt = 1; attempt <= MISTRAL_RENDER_ATTEMPTS; attempt += 1) { + try { + return await withTimeout(generateMistralAssetPng(input), MISTRAL_ASSET_TIMEOUT_MS, "Mistral image generation"); + } catch (error) { + lastError = error; + if (attempt < MISTRAL_RENDER_ATTEMPTS) await sleep(850 * attempt); + } + } + + throw lastError instanceof Error ? lastError : new Error("Mistral image generation failed."); +} export async function runGenerationForProject(project: ProjectRecord, uploads: UploadRecord[]) { const supabase = await createSupabaseServerClient(); const subscription = await consumeGenerationCredit(project.user_id); const plan = PLAN_CONFIG[(subscription.plan as keyof typeof PLAN_CONFIG) || "credits"] || PLAN_CONFIG.credits; + const generationStartedAt = Date.now(); + let creditRefunded = false; const { data: generation, error: generationError } = await supabase .from("generations") @@ -59,13 +95,15 @@ export async function runGenerationForProject(project: ProjectRecord, uploads: U const deterministicAssets = buildDeterministicAssets(safePlan, uploads); const zip = new JSZip(); + const renderSources: Record = {}; for (const [index, asset] of deterministicAssets.entries()) { let renderSource: "mistral_image_generation" | "deterministic_template" = "mistral_image_generation"; let fullPng: Buffer | Uint8Array; + const assetStartedAt = Date.now(); try { - fullPng = await generateMistralAssetPng({ + fullPng = await generateMistralAssetWithRetry({ plan: safePlan, asset, project: { @@ -92,6 +130,7 @@ export async function runGenerationForProject(project: ProjectRecord, uploads: U primaryColor: project.primary_color }); } + renderSources[renderSource] = (renderSources[renderSource] || 0) + 1; const previewPng = fullPng; @@ -120,6 +159,7 @@ export async function runGenerationForProject(project: ProjectRecord, uploads: U notes: asset.notes, callouts: asset.callouts, screenshot_ids: asset.screenshot_ids, + render_duration_ms: Date.now() - assetStartedAt, watermark_required: plan.watermarkPreview } }); @@ -132,16 +172,38 @@ export async function runGenerationForProject(project: ProjectRecord, uploads: U await supabase.storage.from(ASSET_BUCKET).upload(zipPath, zipBuffer, { contentType: "application/zip", upsert: true }); const { data: zipUrl } = supabase.storage.from(ASSET_BUCKET).getPublicUrl(zipPath); - await supabase.from("generations").update({ status: "completed", style_json: { ...safePlan, zip_url: zipUrl.publicUrl } }).eq("id", generation.id); + await supabase.from("generations").update({ status: "completed", style_json: { ...safePlan, zip_url: zipUrl.publicUrl, render_sources: renderSources } }).eq("id", generation.id); await supabase.from("projects").update({ status: "completed" }).eq("id", project.id); - await trackEvent({ userId: project.user_id, projectId: project.id, eventType: "generation_completed", metadata: { generationId: generation.id, projectName: project.name } }); + await trackEvent({ + userId: project.user_id, + projectId: project.id, + eventType: "generation_completed", + metadata: { + generationId: generation.id, + projectName: project.name, + duration_ms: Date.now() - generationStartedAt, + render_sources: renderSources, + assets: deterministicAssets.length + } + }); return { generationId: generation.id }; } catch (error) { const message = error instanceof Error ? error.message : "Generation failed"; + try { + await refundGenerationCredit(project.user_id, message, generation.id); + creditRefunded = true; + } catch (refundError) { + console.error("Failed to refund generation credit:", refundError instanceof Error ? refundError.message : refundError); + } await supabase.from("generations").update({ status: "failed", error_message: message }).eq("id", generation.id); await supabase.from("projects").update({ status: "failed" }).eq("id", project.id); - await trackEvent({ userId: project.user_id, projectId: project.id, eventType: "generation_failed", metadata: { generationId: generation.id, projectName: project.name, message } }); + await trackEvent({ + userId: project.user_id, + projectId: project.id, + eventType: "generation_failed", + metadata: { generationId: generation.id, projectName: project.name, message, credit_refunded: creditRefunded, duration_ms: Date.now() - generationStartedAt } + }); throw error; } } diff --git a/public/icon.svg b/public/icon.svg index dd5d607..dff92d2 100644 --- a/public/icon.svg +++ b/public/icon.svg @@ -1,16 +1,22 @@ - - - + + + - - - + + + + + + - - - - + + + + + + + diff --git a/supabase/migrations/0005_credit_transactions.sql b/supabase/migrations/0005_credit_transactions.sql new file mode 100644 index 0000000..5e97049 --- /dev/null +++ b/supabase/migrations/0005_credit_transactions.sql @@ -0,0 +1,19 @@ +create table if not exists public.credit_transactions ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + source text not null, + provider_reference text not null, + credits integer not null, + metadata_json jsonb, + created_at timestamptz not null default now(), + unique (source, provider_reference) +); + +create index if not exists idx_credit_transactions_user_id_created_at +on public.credit_transactions(user_id, created_at desc); + +alter table public.credit_transactions enable row level security; + +drop policy if exists "Users can view own credit transactions" on public.credit_transactions; +create policy "Users can view own credit transactions" on public.credit_transactions +for select using (auth.uid() = user_id); diff --git a/supabase/migrations/0006_atomic_credit_grants.sql b/supabase/migrations/0006_atomic_credit_grants.sql new file mode 100644 index 0000000..1e392bf --- /dev/null +++ b/supabase/migrations/0006_atomic_credit_grants.sql @@ -0,0 +1,33 @@ +create or replace function public.grant_credit_pack_atomic( + p_user_id uuid, + p_source text, + p_provider_reference text, + p_credits integer, + p_metadata jsonb default '{}'::jsonb +) +returns boolean +language plpgsql +security definer +set search_path = public +as $$ +begin + insert into public.credit_transactions (user_id, source, provider_reference, credits, metadata_json) + values (p_user_id, p_source, p_provider_reference, p_credits, p_metadata); + + update public.subscriptions + set + plan = 'credits', + status = 'active', + credits_remaining = credits_remaining + p_credits, + provider = p_source, + provider_reference = p_provider_reference, + last_payment_at = now(), + updated_at = now() + where user_id = p_user_id; + + return true; +exception + when unique_violation then + return false; +end; +$$; From a1c4b13a770cb83a767ac4b2a2e61f9e7c345a93 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 6 May 2026 06:18:16 +0100 Subject: [PATCH 06/13] Stabilize mobile nav, checkout, and generation QA flows --- PLAN.md | 2 +- README.md | 2 +- app/about/page.tsx | 4 ++-- app/api/billing/checkout/route.ts | 5 +++++ app/api/generations/[projectId]/route.ts | 4 +++- app/privacy/page.tsx | 2 +- app/terms/page.tsx | 2 +- components/dashboard/billing-actions.tsx | 28 ++++++++++++++++++++---- components/dashboard/generate-panel.tsx | 14 +++++++++++- components/dashboard/sidebar.tsx | 28 +++++++++++++++++++----- lib/services/generations/runner.ts | 12 ++++++---- 11 files changed, 82 insertions(+), 21 deletions(-) diff --git a/PLAN.md b/PLAN.md index 11a5033..b9d2a9e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -42,7 +42,7 @@ Goal: reduce deployment risk and tighten operational readiness. - Verify all production env vars are documented and correctly used - Confirm Netlify build and runtime settings are stable -- Confirm Paystack webhook behavior in production +- Confirm Lemon Squeezy checkout and webhook behavior in production - Verify Supabase storage buckets, RLS, and auth settings in production - Review fallback behavior for missing external provider responses diff --git a/README.md b/README.md index 3f681ce..12c52b8 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Recommended validation commands: ## 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, deterministic rendering, exports, credits, and billing. +- 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. diff --git a/app/about/page.tsx b/app/about/page.tsx index 929de20..7ce51be 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -40,7 +40,7 @@ const workflow = [ { icon: Wand2, title: "Generate the asset plan", - text: "Mistral helps structure the copy and layout direction, then LaunchPix renders the actual assets through deterministic templates." + text: "Mistral helps structure the copy and layout direction, then LaunchPix generates polished image assets with an internal renderer as a fallback." }, { icon: Layers3, @@ -97,7 +97,7 @@ export default function AboutPage() {

What it does

One guided workflow from brief to export.

- LaunchPix combines a project brief, uploaded screenshots, structured AI planning, and deterministic rendering to produce reusable launch visuals that feel connected across channels. + LaunchPix combines a project brief, uploaded screenshots, structured AI planning, image generation, and fallback rendering to produce reusable launch visuals that feel connected across channels.

diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts index 66060dc..907afaa 100644 --- a/app/api/billing/checkout/route.ts +++ b/app/api/billing/checkout/route.ts @@ -28,6 +28,11 @@ export async function POST(req: Request) { return NextResponse.json({ checkout_url: data.checkoutUrl, authorization_url: data.checkoutUrl }); } catch (error) { + const maybeRedirectDigest = typeof error === "object" && error !== null ? String((error as { digest?: string }).digest || "") : ""; + if (maybeRedirectDigest.includes("NEXT_REDIRECT")) { + return NextResponse.json({ error: "Please sign in to start checkout." }, { status: 401 }); + } + const message = error instanceof Error ? error.message : "Checkout could not start. Please try again."; console.error("Lemon Squeezy checkout failed:", message); diff --git a/app/api/generations/[projectId]/route.ts b/app/api/generations/[projectId]/route.ts index 9c6ab15..3ab181c 100644 --- a/app/api/generations/[projectId]/route.ts +++ b/app/api/generations/[projectId]/route.ts @@ -33,6 +33,8 @@ export async function POST(_: Request, { params }: { params: Promise<{ projectId const { generationId } = await runGenerationForProject(project, uploads); return NextResponse.json({ generationId }, { status: 201 }); } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : "Generation failed" }, { status: 500 }); + const message = error instanceof Error ? error.message : "Generation failed"; + const status = message.toLowerCase().includes("no credits remaining") ? 402 : 500; + return NextResponse.json({ error: message }, { status }); } } diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index b4c4de1..301800b 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -21,7 +21,7 @@ const sections = [ { icon: Sparkles, title: "AI planning data", - text: "LaunchPix uses Mistral for structured planning: product context, audience, style direction, and copy structure. Rendering remains deterministic and template-driven inside LaunchPix." + text: "LaunchPix uses Mistral for structured planning and, when configured, image generation: product context, screenshots, audience, style direction, copy structure, and asset prompts. If image generation is unavailable, LaunchPix falls back to its internal renderer." }, { icon: Receipt, diff --git a/app/terms/page.tsx b/app/terms/page.tsx index 6c9d305..cf9991f 100644 --- a/app/terms/page.tsx +++ b/app/terms/page.tsx @@ -26,7 +26,7 @@ const sections = [ { icon: CheckCircle2, title: "Generated output", - text: "LaunchPix uses structured AI planning and deterministic templates. You are responsible for reviewing generated copy, visuals, claims, and layout before publishing them publicly." + text: "LaunchPix uses structured AI planning, Mistral image generation when configured, and internal fallback templates. You are responsible for reviewing generated copy, visuals, claims, and layout before publishing them publicly." }, { icon: AlertTriangle, diff --git a/components/dashboard/billing-actions.tsx b/components/dashboard/billing-actions.tsx index 942e702..f699cbc 100644 --- a/components/dashboard/billing-actions.tsx +++ b/components/dashboard/billing-actions.tsx @@ -18,18 +18,38 @@ export function BillingActions() { body: JSON.stringify({ packId }) }); - const json = await res.json().catch(() => ({})); + let json: Record = {}; + const contentType = res.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + json = (await res.json().catch(() => ({}))) as Record; + } + + if (res.status === 401) { + setError("Session expired. Please sign in again to continue checkout."); + setLoading(null); + return; + } + if (!res.ok) { - setError(json.error || "Checkout could not start. Please try again."); + const errorMessage = typeof json.error === "string" ? json.error : "Checkout could not start. Please try again."; + setError(errorMessage); setLoading(null); return; } - if (json.checkout_url || json.authorization_url) { - window.location.href = json.checkout_url || json.authorization_url; + const checkoutUrl = + typeof json.checkout_url === "string" + ? json.checkout_url + : typeof json.authorization_url === "string" + ? json.authorization_url + : null; + + if (checkoutUrl) { + window.location.href = checkoutUrl; return; } + setError("Checkout response was incomplete. Please retry."); setLoading(null); } diff --git a/components/dashboard/generate-panel.tsx b/components/dashboard/generate-panel.tsx index b0f63a6..2f37a67 100644 --- a/components/dashboard/generate-panel.tsx +++ b/components/dashboard/generate-panel.tsx @@ -23,6 +23,7 @@ export function GeneratePanel({ projectId, ready, missing, credits }: { projectI const [generation, setGeneration] = useState(null); const [pending, startTransition] = useTransition(); const [apiError, setApiError] = useState(null); + const [needsCredits, setNeedsCredits] = useState(false); const currentStatus = generation?.status || "idle"; const busy = ["queued", "analyzing", "generating_copy", "rendering_assets"].includes(currentStatus); @@ -48,11 +49,17 @@ export function GeneratePanel({ projectId, ready, missing, credits }: { projectI async function generate() { setApiError(null); + setNeedsCredits(false); setGeneration((current) => ({ id: current?.id || "pending", status: "queued", error_message: null })); const res = await fetch(`/api/generations/${projectId}`, { method: "POST" }); const json = await res.json().catch(() => ({})); if (!res.ok) { - setApiError(json.error || "Generation could not start. Please retry."); + const message = typeof json.error === "string" ? json.error : "Generation could not start. Please retry."; + setApiError(message); + setGeneration((current) => (current?.id ? { ...current, status: "failed", error_message: message } : null)); + if (res.status === 402 || message.toLowerCase().includes("no credits remaining")) { + setNeedsCredits(true); + } return; } await fetchState(); @@ -137,6 +144,11 @@ export function GeneratePanel({ projectId, ready, missing, credits }: { projectI {blockedByCredits ? "Generation is disabled until credits are available." : "Generation is disabled until the missing setup is complete."}

) : null} + {needsCredits ? ( + + ) : null}
); diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx index 467e862..63b3d56 100644 --- a/components/dashboard/sidebar.tsx +++ b/components/dashboard/sidebar.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { signOut } from "next-auth/react"; import { usePathname } from "next/navigation"; import { ChevronUp, CreditCard, Folder, Gem, Home, ImageIcon, LogOut, Menu, Plus, Settings, UserCircle, Wand2, X } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { LaunchPixLogo } from "@/components/brand/logo"; import { cn } from "@/lib/utils"; @@ -221,6 +221,15 @@ export function DashboardSidebar({ const pathname = usePathname() ?? ""; const [mobileOpen, setMobileOpen] = useState(false); + useEffect(() => { + if (!mobileOpen) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [mobileOpen]); + return ( <>
@@ -236,17 +245,26 @@ export function DashboardSidebar({ {mobileOpen ? : }
+
+ + {mobileOpen ? ( +
+
+ ) : null}
); From d2e7ea0d49429717ccae1a22ba7a4ac2cb272c99 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 14 May 2026 18:20:14 +0100 Subject: [PATCH 08/13] Add generation quality checks before export --- components/dashboard/generate-panel.tsx | 5 +- lib/render/quality.ts | 117 ++++++++++++++++++++++++ lib/services/generations/runner.ts | 30 +++++- 3 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 lib/render/quality.ts diff --git a/components/dashboard/generate-panel.tsx b/components/dashboard/generate-panel.tsx index 0adaad4..564b887 100644 --- a/components/dashboard/generate-panel.tsx +++ b/components/dashboard/generate-panel.tsx @@ -129,7 +129,7 @@ export function GeneratePanel({ projectId, ready, missing, credits }: { projectI

Output checklist

- {["5 app listing frames", "1 promo tile", "1 hero banner", "ZIP export package"].map((item) => ( + {["5 app listing frames", "1 promo tile", "1 hero banner", "Quality checks before export"].map((item) => (
{item} @@ -161,6 +161,9 @@ export function GeneratePanel({ projectId, ready, missing, credits }: { projectI Sign in again ) : null} + {generation?.status === "failed" && !needsCredits && !sessionExpired ? ( +

If quality checks fail, shorten copy lines, keep callouts concise, and ensure screenshots are uploaded.

+ ) : null}
); diff --git a/lib/render/quality.ts b/lib/render/quality.ts new file mode 100644 index 0000000..73b097f --- /dev/null +++ b/lib/render/quality.ts @@ -0,0 +1,117 @@ +import { getTemplatePalette, type TemplateFamily } from "@/lib/render/templates/registry"; + +export type QualitySeverity = "error" | "warning"; + +export interface QualityIssue { + code: string; + severity: QualitySeverity; + message: string; +} + +export interface QualityReport { + pass: boolean; + issues: QualityIssue[]; +} + +interface AssetQualityInput { + assetType: string; + templateFamily: TemplateFamily; + headline: string; + subheadline: string; + callouts: string[]; + cta: string; + screenshotUrls: string[]; + primaryColor?: string | null; +} + +function normalizeHexColor(value: string) { + const hex = value.trim().toLowerCase(); + if (/^#[0-9a-f]{6}$/.test(hex)) return hex; + if (/^#[0-9a-f]{3}$/.test(hex)) { + const r = hex[1]; + const g = hex[2]; + const b = hex[3]; + return `#${r}${r}${g}${g}${b}${b}`; + } + return null; +} + +function relativeLuminance(hex: string) { + const safeHex = normalizeHexColor(hex); + if (!safeHex) return null; + + const channels = [safeHex.slice(1, 3), safeHex.slice(3, 5), safeHex.slice(5, 7)].map((part) => Number.parseInt(part, 16) / 255); + const mapped = channels.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); + return 0.2126 * mapped[0] + 0.7152 * mapped[1] + 0.0722 * mapped[2]; +} + +function contrastRatio(foreground: string, background: string) { + const l1 = relativeLuminance(foreground); + const l2 = relativeLuminance(background); + if (l1 === null || l2 === null) return null; + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +function compactText(value: string) { + return value.replace(/\s+/g, " ").trim(); +} + +export function runAssetQualityChecks(input: AssetQualityInput): QualityReport { + const issues: QualityIssue[] = []; + const palette = getTemplatePalette(input.templateFamily, input.primaryColor); + + const headline = compactText(input.headline); + const subheadline = compactText(input.subheadline); + const cta = compactText(input.cta); + const callouts = input.callouts.map(compactText).filter(Boolean); + + if (!headline) issues.push({ code: "headline_missing", severity: "error", message: "Headline is missing. Add a clear value statement." }); + if (headline.length > 65) issues.push({ code: "headline_overflow", severity: "error", message: `Headline is too long (${headline.length}/65). Shorten it to avoid text clipping.` }); + + if (!subheadline) issues.push({ code: "subheadline_missing", severity: "error", message: "Subheadline is missing. Add supporting context for the headline." }); + if (subheadline.length > 95) issues.push({ code: "subheadline_overflow", severity: "error", message: `Subheadline is too long (${subheadline.length}/95). Reduce sentence length.` }); + + if (callouts.length === 0) issues.push({ code: "callouts_missing", severity: "error", message: "At least one callout is required for scannable benefits." }); + if (callouts.length > 3) issues.push({ code: "callouts_overflow", severity: "error", message: "Too many callouts. Use up to 3 short benefit lines." }); + if (callouts.some((callout) => callout.length > 48)) issues.push({ code: "callout_line_overflow", severity: "error", message: "One or more callouts exceed 48 characters and may overflow." }); + + if (!cta) issues.push({ code: "cta_missing", severity: "error", message: "CTA text is missing. Add an action-oriented label." }); + if (cta.length > 28) issues.push({ code: "cta_overflow", severity: "error", message: `CTA is too long (${cta.length}/28). Keep it concise.` }); + + if (!input.screenshotUrls.length) { + issues.push({ code: "screenshot_missing", severity: "error", message: "No screenshot is available for this frame. Upload product screenshots before generating." }); + } + + const bodyContrast = contrastRatio(palette.text, palette.panel); + if (bodyContrast !== null && bodyContrast < 4.5) { + issues.push({ + code: "body_contrast_low", + severity: "error", + message: `Text contrast is too low (${bodyContrast.toFixed(2)}:1). Adjust style preset or primary color for readability.` + }); + } + + const ctaContrast = contrastRatio("#ffffff", palette.accent); + if (ctaContrast !== null && ctaContrast < 3) { + issues.push({ + code: "cta_contrast_low", + severity: "warning", + message: `CTA contrast is weak (${ctaContrast.toFixed(2)}:1). Consider a darker primary color for better legibility.` + }); + } + + if (input.assetType.includes("hero") && headline.length < 18) { + issues.push({ + code: "hero_headline_too_short", + severity: "warning", + message: "Hero headline is very short. Consider a stronger, more specific statement." + }); + } + + return { + pass: !issues.some((issue) => issue.severity === "error"), + issues + }; +} diff --git a/lib/services/generations/runner.ts b/lib/services/generations/runner.ts index 4901e89..c0916b0 100644 --- a/lib/services/generations/runner.ts +++ b/lib/services/generations/runner.ts @@ -5,6 +5,7 @@ import { generateMistralAssetPng } from "@/lib/ai/mistral-image"; import { createDeterministicGenerationPlan } from "@/lib/ai/mistral"; import { generationPlanSchema } from "@/lib/ai/schemas/asset-plan"; import { buildDeterministicAssets, renderAssetPng } from "@/lib/render/pipeline"; +import { runAssetQualityChecks } from "@/lib/render/quality"; import type { ProjectRecord, UploadRecord } from "@/types/project"; import { consumeGenerationCredit, getOrCreateSubscription, refundGenerationCredit } from "@/lib/services/billing/subscription"; import { PLAN_CONFIG } from "@/lib/services/billing/plans"; @@ -99,11 +100,30 @@ export async function runGenerationForProject(project: ProjectRecord, uploads: U const deterministicAssets = buildDeterministicAssets(safePlan, uploads); const zip = new JSZip(); const renderSources: Record = {}; + const qualityFailures: Array<{ assetType: string; issues: string[] }> = []; for (const [index, asset] of deterministicAssets.entries()) { let renderSource: "mistral_image_generation" | "deterministic_template" = "mistral_image_generation"; let fullPng: Buffer | Uint8Array; const assetStartedAt = Date.now(); + const qualityReport = runAssetQualityChecks({ + assetType: asset.asset_type, + templateFamily: asset.template_family, + headline: asset.headline, + subheadline: asset.subheadline, + callouts: asset.callouts, + cta: safePlan.cta_line, + screenshotUrls: asset.screenshotUrls, + primaryColor: project.primary_color + }); + + if (!qualityReport.pass) { + qualityFailures.push({ + assetType: asset.asset_type, + issues: qualityReport.issues.filter((issue) => issue.severity === "error").map((issue) => issue.message) + }); + continue; + } try { fullPng = await generateMistralAssetWithRetry({ @@ -163,13 +183,21 @@ export async function runGenerationForProject(project: ProjectRecord, uploads: U callouts: asset.callouts, screenshot_ids: asset.screenshot_ids, render_duration_ms: Date.now() - assetStartedAt, - watermark_required: plan.watermarkPreview + watermark_required: plan.watermarkPreview, + quality_report: qualityReport } }); zip.file(filename, fullPng); } + if (qualityFailures.length) { + const failureMessage = qualityFailures + .map((failure) => `${failure.assetType}: ${failure.issues.join(" | ")}`) + .join(" ; "); + throw new Error(`Quality check failed. Fix the project brief and rerun generation. ${failureMessage}`); + } + const zipBuffer = await zip.generateAsync({ type: "uint8array" }); const zipPath = `${project.user_id}/${project.id}/${generation.id}/launchpix-pack.zip`; await supabase.storage.from(ASSET_BUCKET).upload(zipPath, zipBuffer, { contentType: "application/zip", upsert: true }); From cae5df8e1e4a28efc1014f049c65fb8fe7dc7c20 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 14 May 2026 21:51:38 +0100 Subject: [PATCH 09/13] Surface quality warnings and track warning analytics --- components/dashboard/assets-manager.tsx | 23 ++++++++++++++++++- lib/services/generations/runner.ts | 30 ++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/components/dashboard/assets-manager.tsx b/components/dashboard/assets-manager.tsx index 21e0ec7..4f1d653 100644 --- a/components/dashboard/assets-manager.tsx +++ b/components/dashboard/assets-manager.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useTransition } from "react"; -import { Download, FileArchive, ImageIcon, PencilLine, RefreshCw, Sparkles } from "lucide-react"; +import { AlertTriangle, Download, FileArchive, ImageIcon, PencilLine, RefreshCw, Sparkles } from "lucide-react"; import type { AssetRecord, GenerationRecord } from "@/types/project"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -69,6 +69,14 @@ export function AssetsManager({ const heroAsset = assets.find((asset) => asset.asset_type.includes("hero")) ?? assets[0]; const listingAssets = assets.filter((asset) => asset.asset_type.includes("listing")); + function warningMessages(asset: AssetRecord) { + const metadata = asset.metadata_json as { quality_report?: { issues?: Array<{ severity?: string; message?: string }> } } | null; + const issues = metadata?.quality_report?.issues || []; + return issues + .filter((issue) => issue.severity === "warning" && typeof issue.message === "string") + .map((issue) => issue.message as string); + } + return (
{!canDownloadFull ? ( @@ -179,6 +187,19 @@ export function AssetsManager({ {(asset.metadata_json as { render_source?: string } | null)?.render_source === "mistral_image_generation" ? "Mistral image generated" : "Template fallback"}

+ {warningMessages(asset).length ? ( +
+

+ + Quality warnings +

+
+ {warningMessages(asset).map((message, index) => ( +

{message}

+ ))} +
+
+ ) : null}
diff --git a/lib/services/generations/runner.ts b/lib/services/generations/runner.ts index c0916b0..aeda008 100644 --- a/lib/services/generations/runner.ts +++ b/lib/services/generations/runner.ts @@ -101,6 +101,7 @@ export async function runGenerationForProject(project: ProjectRecord, uploads: U const zip = new JSZip(); const renderSources: Record = {}; const qualityFailures: Array<{ assetType: string; issues: string[] }> = []; + const qualityWarnings: QualityFailureDetail[] = []; for (const [index, asset] of deterministicAssets.entries()) { let renderSource: "mistral_image_generation" | "deterministic_template" = "mistral_image_generation"; @@ -125,6 +126,14 @@ export async function runGenerationForProject(project: ProjectRecord, uploads: U continue; } + for (const issue of qualityReport.issues.filter((item) => item.severity === "warning")) { + qualityWarnings.push({ + asset_type: asset.asset_type, + code: issue.code, + message: issue.message + }); + } + try { fullPng = await generateMistralAssetWithRetry({ plan: safePlan, @@ -203,8 +212,27 @@ export async function runGenerationForProject(project: ProjectRecord, uploads: U await supabase.storage.from(ASSET_BUCKET).upload(zipPath, zipBuffer, { contentType: "application/zip", upsert: true }); const { data: zipUrl } = supabase.storage.from(ASSET_BUCKET).getPublicUrl(zipPath); - await supabase.from("generations").update({ status: "completed", style_json: { ...safePlan, zip_url: zipUrl.publicUrl, render_sources: renderSources } }).eq("id", generation.id); + await supabase + .from("generations") + .update({ + status: "completed", + style_json: { ...safePlan, zip_url: zipUrl.publicUrl, render_sources: renderSources, quality_warnings: qualityWarnings } + }) + .eq("id", generation.id); await supabase.from("projects").update({ status: "completed" }).eq("id", project.id); + if (qualityWarnings.length) { + await trackEvent({ + userId: project.user_id, + projectId: project.id, + eventType: "quality_warning", + metadata: { + generationId: generation.id, + projectName: project.name, + warning_codes: qualityWarnings.map((item) => item.code), + warning_assets: qualityWarnings.map((item) => item.asset_type) + } + }); + } await trackEvent({ userId: project.user_id, projectId: project.id, From 6ad4876c4dbc05ecab6b24a567575f194e8e8db3 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 15 May 2026 06:58:58 +0100 Subject: [PATCH 10/13] Fix missing quality warning type for deploy build --- lib/services/generations/runner.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/services/generations/runner.ts b/lib/services/generations/runner.ts index aeda008..b3a2fcc 100644 --- a/lib/services/generations/runner.ts +++ b/lib/services/generations/runner.ts @@ -15,6 +15,12 @@ const ASSET_BUCKET = process.env.STORAGE_BUCKET_ASSETS || "launchpix-assets"; const MISTRAL_ASSET_TIMEOUT_MS = 45_000; const MISTRAL_RENDER_ATTEMPTS = 2; +type QualityFailureDetail = { + asset_type: string; + code: string; + message: string; +}; + function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } From 14dcddaf979a2ea2543ccdfa269b7a34b2a9b5fd Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 16 May 2026 03:07:41 +0100 Subject: [PATCH 11/13] Add structured quality report to generation panel --- components/dashboard/generate-panel.tsx | 83 ++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/components/dashboard/generate-panel.tsx b/components/dashboard/generate-panel.tsx index 564b887..f61c17f 100644 --- a/components/dashboard/generate-panel.tsx +++ b/components/dashboard/generate-panel.tsx @@ -6,7 +6,13 @@ import { useRouter } from "next/navigation"; import { AlertCircle, CheckCircle2, Clock3, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; -type Gen = { id: string; status: string; error_message?: string | null } | null; +type QualityIssue = { asset_type: string; code: string; message: string }; +type Gen = { + id: string; + status: string; + error_message?: string | null; + style_json?: { quality_failures?: QualityIssue[]; quality_warnings?: QualityIssue[] } | null; +} | null; const statusLabel: Record = { queued: "Queued for processing", @@ -28,6 +34,32 @@ export function GeneratePanel({ projectId, ready, missing, credits }: { projectI const currentStatus = generation?.status || "idle"; const busy = ["queued", "analyzing", "generating_copy", "rendering_assets"].includes(currentStatus); + const qualityFailures = generation?.style_json?.quality_failures || []; + const qualityWarnings = generation?.style_json?.quality_warnings || []; + + function fixActionHref(code: string) { + if (code.includes("screenshot")) return `/dashboard/projects/new?projectId=${projectId}&step=2`; + return `/dashboard/projects/new?projectId=${projectId}&step=1`; + } + + function fixActionLabel(code: string) { + if (code.includes("screenshot")) return "Upload screenshots"; + if (code.includes("headline") || code.includes("subheadline") || code.includes("callout") || code.includes("cta")) return "Edit project brief"; + if (code.includes("contrast")) return "Adjust style/color"; + return "Review project details"; + } + + const groupedFailures = qualityFailures.reduce>((acc, item) => { + const key = `${item.asset_type}::${item.code}`; + acc[key] = acc[key] ? [...acc[key], item] : [item]; + return acc; + }, {}); + + const groupedWarnings = qualityWarnings.reduce>((acc, item) => { + const key = `${item.asset_type}::${item.code}`; + acc[key] = acc[key] ? [...acc[key], item] : [item]; + return acc; + }, {}); async function fetchState() { const res = await fetch(`/api/generations/${projectId}`); @@ -161,7 +193,54 @@ export function GeneratePanel({ projectId, ready, missing, credits }: { projectI Sign in again ) : null} - {generation?.status === "failed" && !needsCredits && !sessionExpired ? ( + {generation?.status === "failed" && qualityFailures.length > 0 ? ( +
+

Quality checks blocked export. Fix these and regenerate:

+
+ {Object.entries(groupedFailures) + .slice(0, 6) + .map(([key, failures]) => { + const [assetType, code] = key.split("::"); + const first = failures[0]; + return ( +
+
+

{assetType.replaceAll("_", " ")}

+ {code} +
+

{first.message}

+ + {fixActionLabel(code)} + +
+ ); + })} +
+
+ ) : null} + {generation?.status === "completed" && qualityWarnings.length > 0 ? ( +
+

Export ready, but quality warnings were detected:

+
+ {Object.entries(groupedWarnings) + .slice(0, 4) + .map(([key, warnings]) => { + const [assetType, code] = key.split("::"); + const first = warnings[0]; + return ( +
+
+

{assetType.replaceAll("_", " ")}

+ {code} +
+

{first.message}

+
+ ); + })} +
+
+ ) : null} + {generation?.status === "failed" && !needsCredits && !sessionExpired && qualityFailures.length === 0 ? (

If quality checks fail, shorten copy lines, keep callouts concise, and ensure screenshots are uploaded.

) : null}
From 41f9afab8437cf7bfe1408ad56f9c45bc6b8b86e Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 3 Jun 2026 18:47:15 +0100 Subject: [PATCH 12/13] Refine monochrome talocode design --- .env.example | 7 + README.md | 158 ++++++------------ app/about/page.tsx | 62 +++---- app/api/assets/[assetId]/route.ts | 2 +- .../v1/projects/[projectId]/generate/route.ts | 40 +++++ app/api/v1/projects/route.ts | 63 +++++++ app/contact/page.tsx | 12 +- app/dashboard/layout.tsx | 8 +- app/dashboard/page.tsx | 96 ++++++----- app/dashboard/projects/[id]/assets/page.tsx | 4 +- app/dashboard/projects/[id]/generate/page.tsx | 2 +- app/dashboard/projects/page.tsx | 6 +- app/docs/api/page.tsx | 81 +++++++++ app/globals.css | 65 +++---- app/layout.tsx | 16 +- app/login/page.tsx | 34 ++-- app/page.tsx | 14 +- app/pricing/page.tsx | 101 ++++++----- app/privacy/page.tsx | 18 +- app/settings/billing/page.tsx | 12 +- app/settings/layout.tsx | 2 +- app/settings/page.tsx | 14 +- app/terms/page.tsx | 18 +- components/brand/logo.tsx | 31 +--- components/dashboard/assets-manager.tsx | 44 +++-- components/dashboard/billing-actions.tsx | 6 +- components/dashboard/empty-projects.tsx | 2 +- components/dashboard/generate-panel.tsx | 60 +++---- components/dashboard/new-project-wizard.tsx | 30 ++-- components/dashboard/sidebar.tsx | 73 ++++---- components/dashboard/status-badge.tsx | 18 +- components/dashboard/topbar.tsx | 27 +-- components/generation/upload-placeholder.tsx | 4 +- components/marketing/footer.tsx | 35 ++-- components/marketing/landing-sections.tsx | 113 +++++++------ components/marketing/page-shell.tsx | 22 +-- components/marketing/top-nav.tsx | 26 ++- components/ui/badge.tsx | 2 +- components/ui/button.tsx | 12 +- components/ui/theme-toggle.tsx | 2 +- lib/api-key.ts | 27 +++ lib/api-user.ts | 26 +++ lib/email/resend.ts | 2 +- lib/email/templates.ts | 24 +-- lib/payments/lemon-squeezy.ts | 4 +- lib/services/billing/plans.ts | 18 +- public/assets/.gitkeep | 1 + public/assets/talocode-banner.svg | 34 ++++ public/assets/talocode-logo.svg | 8 + render.yaml | 61 +++++++ 50 files changed, 934 insertions(+), 613 deletions(-) create mode 100644 app/api/v1/projects/[projectId]/generate/route.ts create mode 100644 app/api/v1/projects/route.ts create mode 100644 app/docs/api/page.tsx create mode 100644 lib/api-key.ts create mode 100644 lib/api-user.ts create mode 100644 public/assets/.gitkeep create mode 100644 public/assets/talocode-banner.svg create mode 100644 public/assets/talocode-logo.svg create mode 100644 render.yaml diff --git a/.env.example b/.env.example index 0d9f73a..ba78b00 100644 --- a/.env.example +++ b/.env.example @@ -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= @@ -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= @@ -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 diff --git a/README.md b/README.md index 12c52b8..7827298 100644 --- a/README.md +++ b/README.md @@ -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: ` +- `x-launchpix-user-id: ` + +Supported alternatives: +- `x-api-key` +- `Authorization: Bearer ` + +## 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. diff --git a/app/about/page.tsx b/app/about/page.tsx index 7ce51be..5ccb515 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -6,17 +6,17 @@ import { Button } from "@/components/ui/button"; import { FREE_SIGNUP_CREDITS } from "@/lib/services/billing/plans"; export const metadata: Metadata = { - title: "About | LaunchPix", - description: "Learn what LaunchPix does, who it is for, how the generation workflow works, and why it uses credits instead of subscriptions.", + title: "About | Talocode LaunchPix", + description: "Learn how Talocode LaunchPix works as an API-first launch asset service for developers and product teams.", openGraph: { - title: "About LaunchPix", - description: "LaunchPix turns raw screenshots into launch-ready app listing visuals, promo tiles, hero banners, and export packs.", - url: "https://launchpix.app/about" + title: "About Talocode LaunchPix", + description: "Talocode LaunchPix turns raw screenshots into launch-ready app listing visuals, promo tiles, hero banners, and export packs.", + url: "https://launchpix.talocode.com/about" }, twitter: { card: "summary_large_image", - title: "About LaunchPix", - description: "A detailed overview of LaunchPix, the product workflow, credits, exports, and launch asset generation." + title: "About Talocode LaunchPix", + description: "A detailed overview of Talocode LaunchPix, the API workflow, usage credits, exports, and launch asset generation." } }; @@ -35,7 +35,7 @@ const workflow = [ { icon: FileImage, title: "Upload screenshots", - text: "Add the product screenshots LaunchPix should turn into listing frames, promo tiles, and hero banners." + text: "Add the source screenshots LaunchPix should turn into listing frames, promo tiles, and hero banners." }, { icon: Wand2, @@ -59,7 +59,7 @@ const outputs = [ const audiences = [ "Solo founders preparing a first launch", - "SaaS teams shipping product updates often", + "Product teams shipping product updates often", "App and extension builders refreshing store listings", "Agencies producing launch visuals for multiple clients", "Growth teams testing new campaign creative" @@ -68,15 +68,15 @@ const audiences = [ export default function AboutPage() { return (
-
+

The problem

-

Great products can still look unready at launch.

+

Great products can still look unready at launch.

Launch week creates a practical design gap: the product exists, but the screenshots still need framing, hierarchy, captions, sizing, and a consistent visual system.

@@ -85,7 +85,7 @@ export default function AboutPage() {
{problems.map((item) => (
- +

{item}

))} @@ -104,10 +104,10 @@ export default function AboutPage() {
{workflow.map((item) => (
-
- +
+
-

{item.title}

+

{item.title}

{item.text}

))} @@ -117,11 +117,11 @@ export default function AboutPage() {

What you get

-

A complete launch pack, not a loose image export.

+

A complete launch pack, not a loose image export.

{outputs.map((item) => (
- + {item}
))} @@ -130,11 +130,11 @@ export default function AboutPage() {

Who it is for

-

Built for people shipping product, not managing design files.

+

Built for people shipping product, not managing design files.

{audiences.map((item) => (
- + {item}
))} @@ -143,24 +143,24 @@ export default function AboutPage() {
-
+

Credits and billing

-

LaunchPix uses credits, not subscriptions.

+

Talocode LaunchPix uses usage credits, not subscriptions.

- Every account starts with {FREE_SIGNUP_CREDITS} credits. A generation run consumes one credit. When the balance runs out, users buy one-time credit packs through Lemon Squeezy and continue generating. + Every account starts with {FREE_SIGNUP_CREDITS} credits. A generation run consumes one credit. When the balance runs out, users buy one-time credit top-ups through Lemon Squeezy and continue generating.

{[ ["Included", `${FREE_SIGNUP_CREDITS}`, "credits at signup"], - ["Model", "One-time", "credit packs"], + ["Model", "One-time", "credit top-ups"], ["Provider", "Lemon", "Squeezy checkout"] ].map(([label, value, detail]) => (

{label}

-

{value}

+

{value}

{detail}

))} @@ -169,12 +169,12 @@ export default function AboutPage() {
- -

+ +

The goal is simple: help products look ready when the launch traffic arrives.

- LaunchPix removes repetitive visual production from the launch process so teams can focus on positioning, shipping, and learning from the market. + Talocode LaunchPix removes repetitive visual production from the launch process so teams can focus on positioning, shipping, and learning from the market.

diff --git a/app/api/assets/[assetId]/route.ts b/app/api/assets/[assetId]/route.ts index 9455215..7d5e2e8 100644 --- a/app/api/assets/[assetId]/route.ts +++ b/app/api/assets/[assetId]/route.ts @@ -89,7 +89,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ assetId headline: String(editable.headline || "Launch visuals in minutes"), subheadline: String(editable.subheadline || "Deterministic, conversion-focused design output."), callouts: Array.isArray(editable.callouts) ? editable.callouts.map(String).slice(0, 3) : ["Premium templates", "Reliable exports", "Built for product launches"], - cta: "Try LaunchPix", + cta: "Try Talocode LaunchPix", screenshotUrls: [], primaryColor: String(editable.primaryColor || project?.primary_color || "#4F46E5") }); diff --git a/app/api/v1/projects/[projectId]/generate/route.ts b/app/api/v1/projects/[projectId]/generate/route.ts new file mode 100644 index 0000000..7fbd929 --- /dev/null +++ b/app/api/v1/projects/[projectId]/generate/route.ts @@ -0,0 +1,40 @@ +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); + return NextResponse.json({ generationId }, { status: 201 }); +} + diff --git a/app/api/v1/projects/route.ts b/app/api/v1/projects/route.ts new file mode 100644 index 0000000..4052098 --- /dev/null +++ b/app/api/v1/projects/route.ts @@ -0,0 +1,63 @@ +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 }); +} + diff --git a/app/contact/page.tsx b/app/contact/page.tsx index 2b210d1..3098b52 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -4,20 +4,20 @@ import { Button } from "@/components/ui/button"; import { MarketingPageShell } from "@/components/marketing/page-shell"; export const metadata: Metadata = { - title: "Contact | LaunchPix", - description: "Contact LaunchPix support for product, billing, and account help." + title: "Contact | Talocode LaunchPix", + description: "Contact Talocode LaunchPix support for product, API, billing, and account help." }; const supportTypes = [ { icon: Mail, title: "Primary support", - text: "support@launchpix.app" + text: "support@talocode.com" }, { icon: CreditCard, title: "Credit or billing issue", - text: "Include the account email, Lemon Squeezy checkout reference, credit pack name, and the time of payment." + text: "Include the account email, Lemon Squeezy checkout reference, credit top-up name, and the time of payment." }, { icon: Sparkles, @@ -52,7 +52,7 @@ export default function ContactPage() {
{supportTypes.map((item) => (
- +

{item.title}

{item.text}

@@ -77,7 +77,7 @@ export default function ContactPage() {
diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 3880a08..64bdc0b 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -12,10 +12,14 @@ export default async function DashboardLayout({ children }: { children: ReactNod return (
- +
-
{children}
+
+
+ {children} +
+
); diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 7791d5d..b2e9d28 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,3 +1,4 @@ +import Image from "next/image"; import Link from "next/link"; import { ArrowRight, Clock3, Download, Folder, Package, Sparkles, Zap } from "lucide-react"; import { requireUser } from "@/lib/supabase/auth"; @@ -6,10 +7,10 @@ import { listUserProjects } from "@/lib/services/projects/queries"; function statusTone(status: unknown) { const value = typeof status === "string" ? status.toLowerCase() : "draft"; - if (value.includes("complete")) return "bg-emerald-400/12 text-emerald-300"; - if (value.includes("progress") || value.includes("generating")) return "bg-slate-300/10 text-slate-200"; - if (value.includes("failed")) return "bg-rose-400/12 text-rose-300"; - return "bg-slate-100 text-slate-700 dark:bg-white/8 dark:text-slate-300"; + if (value.includes("complete")) return "border-border/80 bg-transparent text-foreground"; + if (value.includes("progress") || value.includes("generating")) return "border-border/80 bg-transparent text-muted-foreground"; + if (value.includes("failed")) return "border-border/80 bg-transparent text-foreground"; + return "border-border/80 bg-transparent text-muted-foreground"; } function prettyStatus(status: unknown) { @@ -43,46 +44,57 @@ export default async function DashboardPage() { return (
-
+

Launch workspace

-

Your launch visuals, organized from first brief to final export.

-

+

Your launch visuals, organized from first brief to final export.

+

Keep project identity, screenshot sequencing, pack generation, and export access in one controlled workspace.

- + New project - + View projects
+ +
+ Talocode brand banner +

Credit posture

-
- Account - {plan.label} +
+ Account + {plan.label}
-
- Credits left - {subscription.credits_remaining} +
+ Credits left + {subscription.credits_remaining}
-
- Export access - {plan.fullResolutionExport ? "Full resolution" : "Preview only"} +
+ Export access + {plan.fullResolutionExport ? "Full resolution" : "Preview only"}
- Billing model - Credit packs + Billing model + Usage credits
@@ -94,13 +106,13 @@ export default async function DashboardPage() {

{item.label}

-

{item.value}

+

{item.value}

- +
-

{item.detail}

+

{item.detail}

))}
@@ -110,14 +122,14 @@ export default async function DashboardPage() {

Current project

-

{activeProject?.name ?? "No active project yet"}

-

+

{activeProject?.name ?? "No active project yet"}

+

{activeProject ? `${activeProject.product_type.replaceAll("_", " ")} - ${uploadCount} screenshots uploaded - updated ${new Date(activeProject.updated_at).toLocaleDateString()}` : "Create a project to define the brief, upload screenshots, and generate the first launch pack."}

- {activeProject ? {prettyStatus(activeProject.status)} : null} + {activeProject ? {prettyStatus(activeProject.status)} : null}
@@ -129,8 +141,8 @@ export default async function DashboardPage() { ].map((step, index) => (
- {step.label} - + {step.label} + {index + 1}
@@ -139,10 +151,10 @@ export default async function DashboardPage() {
- + {activeProject ? "Open project" : "Create project"} - + Generate pack
@@ -152,18 +164,18 @@ export default async function DashboardPage() {

Recent projects

- View all + View all
{recentProjects.length ? recentProjects.map((project) => ( - +
-

{project.name}

-

{project.product_type.replaceAll("_", " ")}

+

{project.name}

+

{project.product_type.replaceAll("_", " ")}

- {prettyStatus(project.status)} + {prettyStatus(project.status)} - )) :

No projects yet.

} + )) :

No projects yet.

}
@@ -171,17 +183,17 @@ export default async function DashboardPage() {

Activity summary

- +
-

Generation workflow stays in one place

-

Review project brief, uploads, generation status, and export access without leaving the dashboard.

+

Generation workflow stays in one place

+

Review project brief, uploads, generation status, and export access without leaving the dashboard.

- +
-

Best next step

-

{activeProject ? `Continue ${activeProject.name} or generate a fresh pack.` : "Create the first project to activate the full launch workflow."}

+

Best next step

+

{activeProject ? `Continue ${activeProject.name} or generate a fresh pack.` : "Create the first project to activate the full launch workflow."}

diff --git a/app/dashboard/projects/[id]/assets/page.tsx b/app/dashboard/projects/[id]/assets/page.tsx index 4daabd0..793f2ce 100644 --- a/app/dashboard/projects/[id]/assets/page.tsx +++ b/app/dashboard/projects/[id]/assets/page.tsx @@ -57,7 +57,7 @@ export default async function AssetsPage({ params }: { params: Promise<{ id: str { label: "Completed", value: completedAt, icon: Clock3 } ].map((item) => (
- +

{item.label}

{item.value}

@@ -87,7 +87,7 @@ export default async function AssetsPage({ params }: { params: Promise<{ id: str Status: {item.status.replaceAll("_", " ")}

- {item.error_message ?

{item.error_message}

: null} + {item.error_message ?

{item.error_message}

: null}
))}
diff --git a/app/dashboard/projects/[id]/generate/page.tsx b/app/dashboard/projects/[id]/generate/page.tsx index 9bf25b0..595cde3 100644 --- a/app/dashboard/projects/[id]/generate/page.tsx +++ b/app/dashboard/projects/[id]/generate/page.tsx @@ -28,7 +28,7 @@ export default async function GeneratePage({ params }: { params: Promise<{ id: s

Generation workspace

{project.name}

- {project.description || "Add a concise project description so LaunchPix can build a sharper asset story."} + {project.description || "Add a concise project description so Talocode LaunchPix can build a sharper asset story."}

diff --git a/app/dashboard/projects/page.tsx b/app/dashboard/projects/page.tsx index f703832..d2cdd3b 100644 --- a/app/dashboard/projects/page.tsx +++ b/app/dashboard/projects/page.tsx @@ -8,14 +8,14 @@ import { listUserProjects } from "@/lib/services/projects/queries"; const textMap: Record = { browser_extension: "Browser Extension", - saas: "SaaS", + saas: "Web App", web_app: "Web App", mobile_app: "Mobile App", other: "Other", chrome_web_store: "Chrome Web Store", firefox_addons: "Firefox Add-ons", product_launch: "Product Launch", - saas_marketing: "SaaS Marketing", + saas_marketing: "Product Marketing", general_promo: "General Promo" }; @@ -48,7 +48,7 @@ export default async function ProjectsPage() {
diff --git a/app/docs/api/page.tsx b/app/docs/api/page.tsx new file mode 100644 index 0000000..7029c39 --- /dev/null +++ b/app/docs/api/page.tsx @@ -0,0 +1,81 @@ +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 ( + <> + +
+
+
+
+

API first

+

Talocode LaunchPix Developer API

+

+ Talocode LaunchPix is open source and API-first. Use `LAUNCHPIX_API_KEY` to call production endpoints and generate launch-ready asset packs from your own product workflow. +

+ +
+ + +
+
+ +
+
+

Auth headers

+

x-launchpix-api-key

+

Pass your service key with each request.

+

x-launchpix-user-id

+

Bind the request to the owner account.

+
+ +
+

Core endpoints

+

GET /api/v1/projects

+

List your API-visible projects.

+

POST /api/v1/projects

+

Create a project workspace from code.

+

POST /api/v1/projects/:projectId/generate

+

Trigger a LaunchPix generation run.

+
+
+
+
+ +
+ {[ + { + title: "Built for developers", + text: "Integrate LaunchPix into your own product, internal tools, or build pipeline instead of relying on a consumer SaaS interface." + }, + { + title: "Usage credits", + text: "The first 300 credits are included. When they run out, top up with one-time usage credits from the billing page." + }, + { + title: "Production-ready output", + text: "Each generation yields launch visuals designed for app stores, campaigns, and landing pages." + } + ].map((item) => ( +
+

{item.title}

+

{item.text}

+
+ ))} +
+
+ + + ); +} diff --git a/app/globals.css b/app/globals.css index f7776f1..c56f3d3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4,30 +4,30 @@ :root { --background: 0 0% 100%; - --foreground: 222 45% 7%; + --foreground: 0 0% 8%; --card: 0 0% 100%; - --card-foreground: 222 45% 7%; - --muted: 220 24% 96%; - --muted-foreground: 220 10% 42%; - --border: 220 18% 88%; - --primary: 252 74% 58%; + --card-foreground: 0 0% 8%; + --muted: 0 0% 97%; + --muted-foreground: 0 0% 38%; + --border: 0 0% 88%; + --primary: 0 0% 8%; --primary-foreground: 0 0% 100%; - --accent: 214 70% 56%; - --ring: 252 74% 58%; + --accent: 0 0% 14%; + --ring: 0 0% 8%; } .dark { - --background: 222 58% 3%; - --foreground: 218 42% 97%; - --card: 222 36% 6%; - --card-foreground: 218 42% 97%; - --muted: 222 28% 10%; - --muted-foreground: 219 18% 68%; - --border: 222 20% 16%; - --primary: 252 74% 58%; - --primary-foreground: 0 0% 100%; - --accent: 214 70% 56%; - --ring: 252 74% 58%; + --background: 220 12% 13%; + --foreground: 0 0% 100%; + --card: 220 12% 15%; + --card-foreground: 0 0% 100%; + --muted: 220 10% 18%; + --muted-foreground: 0 0% 72%; + --border: 0 0% 100% / 0.1; + --primary: 0 0% 92%; + --primary-foreground: 220 12% 10%; + --accent: 0 0% 100%; + --ring: 0 0% 100%; } * { @@ -41,6 +41,7 @@ html { body { @apply bg-background text-foreground antialiased; background-color: hsl(var(--background)); + background-image: none; } ::selection { @@ -62,11 +63,11 @@ body { @layer components { .surface { - @apply rounded-[24px] border border-slate-200 bg-white shadow-[0_28px_80px_-58px_rgba(15,23,42,0.3)] dark:border-white/[0.08] dark:bg-[#070b12] dark:shadow-[0_28px_80px_-58px_rgba(0,0,0,0.9)]; + @apply rounded-[6px] border border-border/80 bg-card shadow-none; } .surface-muted { - @apply rounded-[20px] border border-slate-200 bg-slate-50 dark:border-white/[0.07] dark:bg-[#0b111c]; + @apply rounded-[6px] border border-border/80 bg-muted/60 shadow-none; } .app-shell { @@ -78,31 +79,31 @@ body { } .eyebrow { - @apply inline-flex items-center rounded-full border border-slate-200 bg-slate-100 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-600 dark:border-white/[0.1] dark:bg-white/[0.04] dark:text-slate-300; + @apply inline-flex items-center rounded-none border border-border/80 bg-transparent px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground; } .hero-title { - @apply text-4xl font-semibold leading-[0.95] sm:text-5xl lg:text-6xl; + @apply font-mono text-4xl font-light leading-[0.95] tracking-[-0.05em] sm:text-5xl lg:text-6xl; } .section-title { - @apply text-2xl font-semibold leading-tight sm:text-3xl; + @apply font-mono text-2xl font-light leading-tight tracking-[-0.04em] sm:text-3xl; } .section-copy { - @apply text-sm leading-7 text-slate-600 sm:text-base sm:leading-8 dark:text-slate-400; + @apply text-sm leading-7 text-muted-foreground sm:text-base sm:leading-8; } .field { - @apply h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm text-foreground outline-none transition placeholder:text-slate-400 focus:border-primary/60 focus:ring-4 focus:ring-primary/10 dark:border-white/[0.09] dark:bg-[#0b111c] dark:placeholder:text-slate-500; + @apply h-12 w-full rounded-[4px] border border-border/80 bg-background px-4 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:border-foreground/40 focus:ring-0; } .field-textarea { - @apply min-h-28 w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-foreground outline-none transition placeholder:text-slate-400 focus:border-primary/60 focus:ring-4 focus:ring-primary/10 dark:border-white/[0.09] dark:bg-[#0b111c] dark:placeholder:text-slate-500; + @apply min-h-28 w-full rounded-[4px] border border-border/80 bg-background px-4 py-3 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:border-foreground/40 focus:ring-0; } .field-select { - @apply h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm text-foreground outline-none transition focus:border-primary/60 focus:ring-4 focus:ring-primary/10 dark:border-white/[0.09] dark:bg-[#0b111c]; + @apply h-12 w-full rounded-[4px] border border-border/80 bg-background px-4 text-sm text-foreground outline-none transition focus:border-foreground/40 focus:ring-0; } .legal-copy { @@ -114,18 +115,18 @@ body { } .dashboard-page { - @apply mx-auto w-full max-w-[1420px] space-y-6; + @apply mx-auto w-full max-w-[1360px] space-y-6; } .dashboard-card { - @apply rounded-[22px] border border-slate-200 bg-white shadow-[0_24px_72px_-56px_rgba(15,23,42,0.35)] dark:border-white/[0.08] dark:bg-[#070b12] dark:shadow-[0_24px_72px_-56px_rgba(0,0,0,0.9)]; + @apply rounded-[6px] border border-border/80 bg-card shadow-none; } .dashboard-card-muted { - @apply rounded-[18px] border border-slate-200 bg-slate-50 dark:border-white/[0.07] dark:bg-[#0b111c]; + @apply rounded-[4px] border border-border/80 bg-muted/55 shadow-none; } .dashboard-label { - @apply text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400; + @apply text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground; } } diff --git a/app/layout.tsx b/app/layout.tsx index b389919..1861fc2 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,22 +4,22 @@ import "./globals.css"; import { ThemeProvider } from "@/components/ui/theme-provider"; export const metadata: Metadata = { - title: "LaunchPix", - description: "Turn raw screenshots into polished launch visuals in minutes.", + title: "Talocode LaunchPix", + description: "API-first, open-source launch asset generation for developer teams.", icons: { icon: "/icon.svg" }, openGraph: { - title: "LaunchPix", - description: "Deterministic AI-assisted launch visuals for product teams.", - url: "https://launchpix.app", - siteName: "LaunchPix", + title: "Talocode LaunchPix", + description: "API-first launch visual generation with deterministic fallbacks.", + url: "https://launchpix.talocode.com", + siteName: "Talocode LaunchPix", type: "website" }, twitter: { card: "summary_large_image", - title: "LaunchPix", - description: "Turn raw screenshots into polished launch visuals in minutes." + title: "Talocode LaunchPix", + description: "Open-source launch visuals API for product and growth teams." } }; diff --git a/app/login/page.tsx b/app/login/page.tsx index cf94af6..eb09943 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -9,8 +9,8 @@ import { MarketingFooter } from "@/components/marketing/footer"; import { Card, CardContent } from "@/components/ui/card"; export const metadata: Metadata = { - title: "Sign in | LaunchPix", - description: "Sign in to LaunchPix with Google and start generating polished launch visuals from raw screenshots." + title: "Sign in | Talocode LaunchPix", + description: "Sign in to Talocode LaunchPix with Google and start generating polished launch visuals from raw screenshots." }; export const dynamic = "force-dynamic"; @@ -24,46 +24,46 @@ export default async function LoginPage() {
-
+

Google sign in

Sign in once. Get straight back to your launch visuals.

- Use your Google account to enter LaunchPix. No password, no manual email entry, no separate signup form. + Use your Google account to enter Talocode LaunchPix. No password, no manual email entry, no separate signup form.

{[ { icon: LockKeyhole, title: "No password drag", text: "Google handles account access securely without another credential." }, { icon: UploadCloud, title: "Your context stays put", text: "Return to the same project brief, screenshots, and generation state." }, - { icon: Sparkles, title: "Signup is automatic", text: "First-time Google users get a LaunchPix workspace on sign in." } + { icon: Sparkles, title: "Signup is automatic", text: "First-time Google users get a Talocode LaunchPix workspace on sign in." } ].map((item) => ( -
- +
+

{item.title}

-

{item.text}

+

{item.text}

))}
-
+
- -

+ +

New users are signed up automatically with Google. Returning users continue into the same workspace tied to their Google email.

- +
-

Continue with Google

+

Continue with Google

One-click access

-

- Choose your Google account and LaunchPix will take care of sign in or signup automatically. +

+ Choose your Google account and Talocode LaunchPix will take care of sign in or signup automatically.

@@ -72,8 +72,8 @@ export default async function LoginPage() {

Need help accessing your account?{" "} - - support@launchpix.app + + support@talocode.com

diff --git a/app/page.tsx b/app/page.tsx index ec299e4..5b15b18 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,17 +4,17 @@ import { LandingSections } from "@/components/marketing/landing-sections"; import { MarketingFooter } from "@/components/marketing/footer"; export const metadata: Metadata = { - title: "LaunchPix | Turn unfinished screenshots into launch-ready visuals", - description: "Generate store-ready screenshot packs, promo tiles, and hero banners from raw product captures.", + title: "Talocode LaunchPix | API-first launch visual generation", + description: "Open-source API for launch-ready screenshot packs, promo tiles, and hero banners.", openGraph: { - title: "LaunchPix", - description: "Turn unfinished screenshots into launch-ready visual packs.", - url: "https://launchpix.app" + title: "Talocode LaunchPix", + description: "Build launch visual workflows on a developer-first API.", + url: "https://launchpix.talocode.com" }, twitter: { card: "summary_large_image", - title: "LaunchPix", - description: "Preview launch-ready screenshot packs before production export." + title: "Talocode LaunchPix", + description: "API-first launch visual generation for product teams." } }; diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index 1785004..032241f 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -6,29 +6,27 @@ import { Card, CardContent } from "@/components/ui/card"; import { MarketingPageShell } from "@/components/marketing/page-shell"; import { CREDIT_PACKS, FREE_SIGNUP_CREDITS } from "@/lib/services/billing/plans"; -const plans = [ - { - id: "included", - name: "Included credits", - price: "NGN 0", - desc: "Every new account gets enough credits to build real launch assets before paying.", - tag: "Free grant", - features: [`${FREE_SIGNUP_CREDITS} credits on signup`, "Full-resolution PNG exports", "ZIP downloads included"] - } -]; +const includedCredits = { + id: "included", + name: "Included credits", + price: "NGN 0", + desc: "Every new account gets enough credits to build real launch assets before paying.", + tag: "Free grant", + features: [`${FREE_SIGNUP_CREDITS} credits on signup`, "Full-resolution PNG exports", "ZIP downloads included"] +}; export const metadata: Metadata = { - title: "Pricing | LaunchPix", - description: "LaunchPix uses one-time credits instead of subscriptions. Start with 300 included credits, then top up when needed.", + title: "Credits | Talocode LaunchPix", + description: "Talocode LaunchPix uses one-time usage credits instead of subscriptions. Start with included credits, then top up when needed.", openGraph: { - title: "LaunchPix Pricing", - description: "Start with 300 included credits, then buy one-time credit packs when needed.", - url: "https://launchpix.app/pricing" + title: "Talocode LaunchPix Credits", + description: "Start with included credits, then buy one-time credit top-ups when needed.", + url: "https://launchpix.talocode.com/pricing" }, twitter: { card: "summary_large_image", - title: "LaunchPix Pricing", - description: "Simple credit packs for launch asset generation." + title: "Talocode LaunchPix Credits", + description: "Simple usage credits for launch asset generation." } }; @@ -37,51 +35,52 @@ export default function PricingPage() {

- {plans.map((plan) => ( - - -
-

{plan.tag}

-

{plan.name}

-

{plan.price}

-

{plan.desc}

-
+ + +
+

{includedCredits.tag}

+

{includedCredits.name}

+

{includedCredits.price}

+

{includedCredits.desc}

+
-
- {plan.features.map((feature) => ( -
- - {feature} -
- ))} -
+
+ {includedCredits.features.map((feature) => ( +
+ + {feature} +
+ ))} +
+ + +
+
- -
-
- ))} {CREDIT_PACKS.map((pack) => ( - +
-

{pack.featured ? "Popular" : "Top up"}

+

{pack.featured ? "Most used" : "Top up"}

{pack.label}

{pack.creditsGranted.toLocaleString()} credits

-

{pack.description}

+

+ {pack.priceLabel} - {pack.description} +

{["One-time purchase", "Full-resolution PNG + ZIP", "Commercial use included"].map((feature) => (
- + {feature}
))} @@ -102,11 +101,11 @@ export default function PricingPage() { {[ { title: "How credits work", - text: "One generation run uses one credit, so your spend follows actual launch work instead of seats or recurring plans." + text: "One generation run uses one credit, so your spend follows actual API usage instead of seats or recurring plans." }, { - title: "Real free runway", - text: "Every account starts with 300 credits, including existing users after the migration is applied." + title: "Developer friendly", + text: "Build against the LaunchPix API with `LAUNCHPIX_API_KEY` and keep your own product workflow in control." }, { title: "Export readiness", diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 301800b..f01ad83 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -3,8 +3,8 @@ import { Database, FileImage, LockKeyhole, Mail, Receipt, Sparkles } from "lucid import { MarketingPageShell } from "@/components/marketing/page-shell"; export const metadata: Metadata = { - title: "Privacy | LaunchPix", - description: "How LaunchPix stores user data, processes screenshots, and handles billing and AI services." + title: "Privacy | Talocode LaunchPix", + description: "How Talocode LaunchPix stores user data, processes screenshots, and handles billing and AI services." }; const sections = [ @@ -16,17 +16,17 @@ const sections = [ { icon: FileImage, title: "Screenshots and generated assets", - text: "Uploaded screenshots are stored so LaunchPix can render listing frames, promo tiles, and hero banners. Generated previews, full PNG files, and ZIP exports are stored in the configured Supabase storage buckets for your workspace." + text: "Uploaded screenshots are stored so Talocode LaunchPix can render listing frames, promo tiles, and hero banners. Generated previews, full PNG files, and ZIP exports are stored in the configured Supabase storage buckets for your workspace." }, { icon: Sparkles, title: "AI planning data", - text: "LaunchPix uses Mistral for structured planning and, when configured, image generation: product context, screenshots, audience, style direction, copy structure, and asset prompts. If image generation is unavailable, LaunchPix falls back to its internal renderer." + text: "Talocode LaunchPix uses Mistral for structured planning and, when configured, image generation: product context, screenshots, audience, style direction, copy structure, and asset prompts. If image generation is unavailable, Talocode LaunchPix falls back to its internal renderer." }, { icon: Receipt, title: "Billing data", - text: "Lemon Squeezy handles checkout and payment processing for credit packs. LaunchPix stores payment references, webhook fulfillment status, and credit updates, but does not store raw card details." + text: "Lemon Squeezy handles checkout and payment processing for credit packs. Talocode LaunchPix stores payment references, webhook fulfillment status, and credit updates, but does not store raw card details." }, { icon: LockKeyhole, @@ -36,7 +36,7 @@ const sections = [ { icon: Mail, title: "Product emails", - text: "LaunchPix may send operational emails about project creation, uploads, generation status, credit balance, billing events, asset downloads, and export activity." + text: "Talocode LaunchPix may send operational emails about project creation, uploads, generation status, credit balance, billing events, asset downloads, and export activity." } ]; @@ -44,14 +44,14 @@ export default function PrivacyPage() { return (
{sections.map((item) => (
- +

{item.title}

{item.text}

@@ -61,7 +61,7 @@ export default function PrivacyPage() {

Data requests and deletion

- For privacy, access, correction, export, or deletion requests, contact support@launchpix.app from the email tied to your LaunchPix account. We may need to retain limited billing, fraud-prevention, or legal records where required. + For privacy, access, correction, export, or deletion requests, contact support@talocode.com from the email tied to your Talocode LaunchPix account. We may need to retain limited billing, fraud-prevention, or legal records where required.

diff --git a/app/settings/billing/page.tsx b/app/settings/billing/page.tsx index eaf9a31..3668638 100644 --- a/app/settings/billing/page.tsx +++ b/app/settings/billing/page.tsx @@ -13,11 +13,11 @@ export default async function BillingPage() {

Billing

-

Buy credits only when your launch balance runs out.

+

Buy usage credits only when your launch balance runs out.

-

Account type

-

{plan.label}

+

Access type

+

API usage

Credits remaining

@@ -38,9 +38,9 @@ export default async function BillingPage() {
-

Top up credit balance

+

Top up usage credits

- Every user starts with 300 credits. After those are used, buy a one-time credit pack that matches your next launch workload. + Every user starts with 300 credits. After those are used, buy a one-time credit top-up that matches your next launch workload.

@@ -49,7 +49,7 @@ export default async function BillingPage() { - Credits unlock the full LaunchPix workflow: asset generation, full-resolution PNG downloads, ZIP exports, and commercial use. There is no monthly subscription to manage. + Credits unlock the full Talocode LaunchPix API workflow: asset generation, full-resolution PNG downloads, ZIP exports, and commercial use. There is no monthly subscription to manage.
diff --git a/app/settings/layout.tsx b/app/settings/layout.tsx index 50046fe..95489bb 100644 --- a/app/settings/layout.tsx +++ b/app/settings/layout.tsx @@ -12,7 +12,7 @@ export default async function SettingsLayout({ children }: { children: ReactNode return (
- +
{children}
diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 2eb5f4c..d875e9d 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -23,26 +23,26 @@ export default async function SettingsPage() {
-
- +
+

Workspace email

-

{user.email}

+

{user.email}

This address receives account-related updates.

- +

Credit balance

{plan.label} - {subscription.credits_remaining} credits remaining

- +

Export mode

{plan.fullResolutionExport ? "Full-resolution export is active while credits remain." : "Export is limited until credits are available."} @@ -60,11 +60,11 @@ export default async function SettingsPage() {

- + Billing and credits - + View projects diff --git a/app/terms/page.tsx b/app/terms/page.tsx index cf9991f..0024a77 100644 --- a/app/terms/page.tsx +++ b/app/terms/page.tsx @@ -3,15 +3,15 @@ import { AlertTriangle, CheckCircle2, CreditCard, Download, Scale, ShieldCheck } import { MarketingPageShell } from "@/components/marketing/page-shell"; export const metadata: Metadata = { - title: "Terms | LaunchPix", - description: "LaunchPix terms covering usage limits, billing, credits, export access, and service constraints." + title: "Terms | Talocode LaunchPix", + description: "Talocode LaunchPix terms covering usage limits, billing, credits, export access, and service constraints." }; const sections = [ { icon: ShieldCheck, title: "Acceptable use", - text: "Use LaunchPix only for lawful product marketing workflows. You are responsible for the rights to any screenshots, brand assets, product copy, customer content, and other material you upload." + text: "Use Talocode LaunchPix only for lawful product marketing workflows. You are responsible for the rights to any screenshots, brand assets, product copy, customer content, and other material you upload." }, { icon: CreditCard, @@ -26,12 +26,12 @@ const sections = [ { icon: CheckCircle2, title: "Generated output", - text: "LaunchPix uses structured AI planning, Mistral image generation when configured, and internal fallback templates. You are responsible for reviewing generated copy, visuals, claims, and layout before publishing them publicly." + text: "Talocode LaunchPix uses structured AI planning, Mistral image generation when configured, and internal fallback templates. You are responsible for reviewing generated copy, visuals, claims, and layout before publishing them publicly." }, { icon: AlertTriangle, title: "Service availability", - text: "LaunchPix is offered on an as-available basis during the MVP stage. Generation, exports, billing confirmation, storage, or third-party services may occasionally be delayed or unavailable." + text: "Talocode LaunchPix is offered on an as-available basis during the MVP stage. Generation, exports, billing confirmation, storage, or third-party services may occasionally be delayed or unavailable." }, { icon: Scale, @@ -44,14 +44,14 @@ export default function TermsPage() { return (
{sections.map((item) => (
- +

{item.title}

{item.text}

@@ -61,7 +61,7 @@ export default function TermsPage() {

Billing support and disputes

- If a payment succeeds but credits are not added, contact support@launchpix.app with the account email and checkout reference. Webhook fulfillment usually completes automatically, but support can reconcile confirmed purchases. + If a payment succeeds but credits are not added, contact support@talocode.com with the account email and checkout reference. Webhook fulfillment usually completes automatically, but support can reconcile confirmed purchases.

diff --git a/components/brand/logo.tsx b/components/brand/logo.tsx index 40b6c88..6a80481 100644 --- a/components/brand/logo.tsx +++ b/components/brand/logo.tsx @@ -1,39 +1,20 @@ import { cn } from "@/lib/utils"; +import Image from "next/image"; export function LaunchPixLogo({ className, markClassName }: { className?: string; markClassName?: string }) { return ( - + - + - LaunchPix - Launch asset studio + Talocode LaunchPix + Launch API ); diff --git a/components/dashboard/assets-manager.tsx b/components/dashboard/assets-manager.tsx index 4f1d653..fe090fc 100644 --- a/components/dashboard/assets-manager.tsx +++ b/components/dashboard/assets-manager.tsx @@ -80,7 +80,7 @@ export function AssetsManager({ return (
{!canDownloadFull ? ( - +

Credits are required for full export.

Buy credits when your balance runs out to continue downloading full-resolution PNG and ZIP exports.

@@ -97,7 +97,7 @@ export function AssetsManager({

Generated pack

-

Review the pack before you ship it.

+

Review the pack before you ship it.

This is the finished output from your latest generation: inspect the hierarchy, download files, or adjust copy and rerender individual assets.

@@ -126,7 +126,7 @@ export function AssetsManager({ ].map(([label, value]) => (

{label}

-

{value}

+

{value}

))}
@@ -137,16 +137,18 @@ export function AssetsManager({ <>
{assetPurpose(heroAsset.asset_type)} - {heroAsset.width} x {heroAsset.height} + + {heroAsset.width} x {heroAsset.height} +
{assetLabel(heroAsset.asset_type)} ) : ( -
+
No preview asset found.
)} @@ -158,42 +160,44 @@ export function AssetsManager({

Asset review

-

All generated files

+

All generated files

Download the final asset directly, or edit the text layer and rerender when a headline needs more polish.

- {actionError ?

{actionError}

: null} + {actionError ?

{actionError}

: null}
{assets.map((asset) => ( -
+
{assetPurpose(asset.asset_type)} - {asset.width} x {asset.height} + + {asset.width} x {asset.height} +
- {assetLabel(asset.asset_type)} + {assetLabel(asset.asset_type)}
-

{assetLabel(asset.asset_type)}

+

{assetLabel(asset.asset_type)}

Template: {(asset.metadata_json as { template_family?: string } | null)?.template_family || "minimal"}

-

+

{(asset.metadata_json as { render_source?: string } | null)?.render_source === "mistral_image_generation" ? "Mistral image generated" : "Template fallback"}

{warningMessages(asset).length ? ( -
-

+

+

Quality warnings

-
+
{warningMessages(asset).map((message, index) => (

{message}

))} @@ -230,8 +234,12 @@ export function AssetsManager({
- - + +
) : null} diff --git a/components/dashboard/billing-actions.tsx b/components/dashboard/billing-actions.tsx index f699cbc..7f7ff07 100644 --- a/components/dashboard/billing-actions.tsx +++ b/components/dashboard/billing-actions.tsx @@ -60,15 +60,15 @@ export function BillingActions() {

{pack.label}

{pack.creditsGranted.toLocaleString()} credits

-

{pack.description}

+

{pack.priceLabel} · {pack.description}

))} - {error ?

{error}

:

Secure one-time checkout via Lemon Squeezy. Credits are added after payment confirmation.

} + {error ?

{error}

:

Secure one-time checkout via Lemon Squeezy. Credits are added after payment confirmation.

}
); } diff --git a/components/dashboard/empty-projects.tsx b/components/dashboard/empty-projects.tsx index b2895da..5e3e3d9 100644 --- a/components/dashboard/empty-projects.tsx +++ b/components/dashboard/empty-projects.tsx @@ -22,7 +22,7 @@ export function EmptyProjectsState() {
diff --git a/components/dashboard/generate-panel.tsx b/components/dashboard/generate-panel.tsx index f61c17f..5278f6a 100644 --- a/components/dashboard/generate-panel.tsx +++ b/components/dashboard/generate-panel.tsx @@ -118,52 +118,52 @@ export function GeneratePanel({ projectId, ready, missing, credits }: { projectI Five listing frames, one promo tile, and one hero banner generated from your screenshots and structured copy plan.

-
- {busy ? : ready ? : } +
+ {busy ? : ready ? : } {statusLabel[currentStatus] || "Ready when you are"}
-
+
{!ready ? (
- - + +
-

+

{blockedByCredits ? "Add credits to generate this launch pack." : "Complete the missing setup before generating."}

-

Missing: {missingText}.

+

Missing: {missingText}.

) : (
- - + +
-

Everything is ready for generation.

-

Start the render and LaunchPix will redirect you to the asset view when the pack is complete.

+

Everything is ready for generation.

+

Start the render and LaunchPix will redirect you to the asset view when the pack is complete.

)} -
-
+
+
- {generation?.status === "failed" ?

{generation.error_message || "Generation failed. Please retry."}

: null} - {apiError ?

{apiError}

: null} + {generation?.status === "failed" ?

{generation.error_message || "Generation failed. Please retry."}

: null} + {apiError ?

{apiError}

: null}
-
-

Output checklist

-
+
+

Output checklist

+
{["5 app listing frames", "1 promo tile", "1 hero banner", "Quality checks before export"].map((item) => (
- + {item}
))} @@ -194,7 +194,7 @@ export function GeneratePanel({ projectId, ready, missing, credits }: { projectI ) : null} {generation?.status === "failed" && qualityFailures.length > 0 ? ( -
+

Quality checks blocked export. Fix these and regenerate:

{Object.entries(groupedFailures) @@ -203,13 +203,13 @@ export function GeneratePanel({ projectId, ready, missing, credits }: { projectI const [assetType, code] = key.split("::"); const first = failures[0]; return ( -
+
-

{assetType.replaceAll("_", " ")}

- {code} +

{assetType.replaceAll("_", " ")}

+ {code}
-

{first.message}

- +

{first.message}

+
{fixActionLabel(code)}
@@ -219,7 +219,7 @@ export function GeneratePanel({ projectId, ready, missing, credits }: { projectI
) : null} {generation?.status === "completed" && qualityWarnings.length > 0 ? ( -
+

Export ready, but quality warnings were detected:

{Object.entries(groupedWarnings) @@ -228,12 +228,12 @@ export function GeneratePanel({ projectId, ready, missing, credits }: { projectI const [assetType, code] = key.split("::"); const first = warnings[0]; return ( -
+
-

{assetType.replaceAll("_", " ")}

- {code} +

{assetType.replaceAll("_", " ")}

+ {code}
-

{first.message}

+

{first.message}

); })} diff --git a/components/dashboard/new-project-wizard.tsx b/components/dashboard/new-project-wizard.tsx index 6572a29..6515ae4 100644 --- a/components/dashboard/new-project-wizard.tsx +++ b/components/dashboard/new-project-wizard.tsx @@ -15,14 +15,14 @@ import { saveStyleDirection, upsertProjectIdentity } from "@/lib/actions/project const labels: Record = { browser_extension: "Browser Extension", - saas: "SaaS", + saas: "Web App", web_app: "Web App", mobile_app: "Mobile App", other: "Other", chrome_web_store: "Chrome Web Store", firefox_addons: "Firefox Add-ons", product_launch: "Product Launch", - saas_marketing: "SaaS Marketing", + saas_marketing: "Product Marketing", general_promo: "General Promo", minimal: "Minimal", bold: "Bold", @@ -119,7 +119,7 @@ export function NewProjectWizard({ initialStep, project, initialUploads }: { ini

Step 1

Define the product identity once.

- Tell LaunchPix what you are shipping, who it is for, and the visual posture you want across the asset pack. + Tell Talocode LaunchPix what you are shipping, who it is for, and the visual posture you want across the asset pack.

@@ -128,7 +128,7 @@ export function NewProjectWizard({ initialStep, project, initialUploads }: { ini