diff --git a/.env.example b/.env.example index 9702356..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= @@ -9,12 +12,18 @@ 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= +LAUNCHPIX_API_KEY= # Lemon Squeezy credit billing LEMON_SQUEEZY_API_KEY= @@ -23,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/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 6912d79..7827298 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,74 @@ -# LaunchPix +# Talocode LaunchPix -LaunchPix is a Mistral-assisted, deterministic 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 only) -- Deterministic SVG -> PNG 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) -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` -- `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` +- `MISTRAL_MODEL_VISION` +- `MISTRAL_IMAGE_MODEL` +- `MISTRAL_IMAGE_AGENT_ID` (optional) +- `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. -- 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. - -## 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, deterministic 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 new file mode 100644 index 0000000..5ccb515 --- /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 | Talocode LaunchPix", + description: "Learn how Talocode LaunchPix works as an API-first launch asset service for developers and product teams.", + openGraph: { + 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 Talocode LaunchPix", + description: "A detailed overview of Talocode LaunchPix, the API workflow, usage 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 source 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 generates polished image assets with an internal renderer as a fallback." + }, + { + 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", + "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" +]; + +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, image generation, and fallback 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

+

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 top-ups through Lemon Squeezy and continue generating. +

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

{label}

+

{value}

+

{detail}

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

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

+

+ 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 4a89778..ba950fc 100644 --- a/app/api/assets/[assetId]/route.ts +++ b/app/api/assets/[assetId]/route.ts @@ -3,6 +3,10 @@ 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 { runAssetQualityChecks } from "@/lib/render/quality"; +import { trackEvent } from "@/lib/services/analytics/events"; const ASSET_BUCKET = process.env.STORAGE_BUCKET_ASSETS || "launchpix-assets"; @@ -10,7 +14,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 +22,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 +51,72 @@ 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; + let qualityReport = { pass: false, issues: [] as Array<{ code: string; severity: "error" | "warning"; message: string }> }; + + 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[]; + + qualityReport = runAssetQualityChecks({ + assetType: asset.asset_type, + templateFamily, + 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, + cta: "Try Talocode LaunchPix", + screenshotUrls, + primaryColor: String(editable.primaryColor || project?.primary_color || "#4F46E5") + }); + + 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 Talocode LaunchPix", + screenshotUrls: [], + primaryColor: String(editable.primaryColor || project?.primary_color || "#4F46E5") + }); + qualityReport = runAssetQualityChecks({ + assetType: asset.asset_type, + 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 Talocode 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 +124,22 @@ 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(), + quality_report: qualityReport + } + }) + .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 29db9e0..907afaa 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,17 +15,40 @@ 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 }); - } catch { + } 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); + + 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") || message.includes("must be a numeric") || message.includes("belongs to Lemon Squeezy store")) { + return NextResponse.json({ error: message }, { status: 500 }); + } + return NextResponse.json({ error: "Checkout could not start. Please try again." }, { status: 500 }); } } diff --git a/app/api/generations/[projectId]/route.ts b/app/api/generations/[projectId]/route.ts index 9c6ab15..3d886f3 100644 --- a/app/api/generations/[projectId]/route.ts +++ b/app/api/generations/[projectId]/route.ts @@ -7,32 +7,47 @@ import { allowGenerationAttempt } from "@/lib/services/access/rate-limit"; import { getAccessContext } from "@/lib/services/access/permissions"; export async function GET(_: Request, { params }: { params: Promise<{ projectId: string }> }) { - const { user } = await requireUser(); - const { projectId } = await params; - await getProjectOverview(projectId, user.id); - const generation = await getLatestGeneration(projectId); - const { subscription, plan } = await getAccessContext(user.id); - return NextResponse.json({ generation, credits: subscription.credits_remaining, plan: plan.id }); + try { + const { user } = await requireUser(); + const { projectId } = await params; + await getProjectOverview(projectId, user.id); + const generation = await getLatestGeneration(projectId); + const { subscription, plan } = await getAccessContext(user.id); + return NextResponse.json({ generation, credits: subscription.credits_remaining, plan: plan.id }); + } 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 continue." }, { status: 401 }); + } + throw error; + } } export async function POST(_: Request, { params }: { params: Promise<{ projectId: string }> }) { - const { user } = await requireUser(); - const { projectId } = await params; + try { + const { user } = await requireUser(); + const { projectId } = await params; - if (!allowGenerationAttempt(user.id)) { - return NextResponse.json({ error: "Too many generation attempts. Please wait and retry." }, { status: 429 }); - } + if (!allowGenerationAttempt(user.id)) { + return NextResponse.json({ error: "Too many generation attempts. Please wait and retry." }, { status: 429 }); + } - const { project, uploads } = await getProjectOverview(projectId, user.id); + const { project, uploads } = await getProjectOverview(projectId, user.id); - if (!uploads.length) { - return NextResponse.json({ error: "At least one screenshot is required." }, { status: 400 }); - } + if (!uploads.length) { + return NextResponse.json({ error: "At least one screenshot is required." }, { status: 400 }); + } - try { 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 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 continue." }, { status: 401 }); + } + + 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/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 fda0c02..3098b52 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -1,13 +1,46 @@ 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"; 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@talocode.com" + }, + { + icon: CreditCard, + title: "Credit or billing issue", + text: "Include the account email, Lemon Squeezy checkout reference, credit top-up 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}

))}
-
+

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.

@@ -60,7 +77,7 @@ export default function ContactPage() {
diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 8e28f21..64bdc0b 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -4,16 +4,22 @@ 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); 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 9dd39d9..793f2ce 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/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 ecca5da..eb09943 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -9,10 +9,12 @@ 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"; + export default async function LoginPage() { const session = await auth(); if (session?.user?.email) redirect("/dashboard/projects"); @@ -22,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.

@@ -70,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 fc41516..f01ad83 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -1,41 +1,67 @@ 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 = { - 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 = [ + { + 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 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: "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. Talocode 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: "Talocode 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@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 fc862c3..95489bb 100644 --- a/app/settings/layout.tsx +++ b/app/settings/layout.tsx @@ -4,13 +4,15 @@ 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); 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 1b3f0a4..0024a77 100644 --- a/app/terms/page.tsx +++ b/app/terms/page.tsx @@ -1,47 +1,67 @@ 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 = { - 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 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, + 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: "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: "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, + 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@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 3d49c7b..6a80481 100644 --- a/components/brand/logo.tsx +++ b/components/brand/logo.tsx @@ -1,33 +1,20 @@ import { cn } from "@/lib/utils"; +import Image from "next/image"; export function LaunchPixLogo({ className, markClassName }: { className?: string; markClassName?: string }) { return ( - + - LaunchPix - Launch studio + Talocode LaunchPix + Launch API ); diff --git a/components/dashboard/assets-manager.tsx b/components/dashboard/assets-manager.tsx index 1e7c435..fe090fc 100644 --- a/components/dashboard/assets-manager.tsx +++ b/components/dashboard/assets-manager.tsx @@ -1,40 +1,86 @@ "use client"; import { useState, useTransition } from "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"; -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(""); 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(); } + 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 ? ( - +

Credits are required for full export.

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

@@ -45,61 +91,163 @@ 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}

+
+ ))}
+
+ +
+ {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. +

+
+ {actionError ?

{actionError}

: null} - - {asset.asset_type} +
+ {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"}

+

+ + {(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} +
+ +
-
- {editing === asset.id ? ( -
- setHeadline(event.target.value)} /> - setSubheadline(event.target.value)} /> - -
- - + {editing === asset.id ? ( +
+ setHeadline(event.target.value)} /> + setSubheadline(event.target.value)} /> + +
+ + +
-
- ) : null} - - - ))} -
+ ) : null} + + + ))} +
+
); } diff --git a/components/dashboard/billing-actions.tsx b/components/dashboard/billing-actions.tsx index 942e702..7f7ff07 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); } @@ -40,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 b0f63a6..5278f6a 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", @@ -23,12 +29,44 @@ 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 [sessionExpired, setSessionExpired] = useState(false); 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}`); + if (res.status === 401) { + setSessionExpired(true); + return; + } if (!res.ok) return; const json = await res.json(); setGeneration(json.generation); @@ -48,11 +86,19 @@ export function GeneratePanel({ projectId, ready, missing, credits }: { projectI async function generate() { setApiError(null); + setNeedsCredits(false); + setSessionExpired(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 === 401) setSessionExpired(true); + if (res.status === 402 || message.toLowerCase().includes("no credits remaining")) { + setNeedsCredits(true); + } return; } await fetchState(); @@ -72,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

-
- {["5 app listing frames", "1 promo tile", "1 hero banner", "ZIP export package"].map((item) => ( +
+

Output checklist

+
+ {["5 app listing frames", "1 promo tile", "1 hero banner", "Quality checks before export"].map((item) => (
- + {item}
))} @@ -137,6 +183,66 @@ 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} + {sessionExpired ? ( + + ) : null} + {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}
); 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
+
+
+

Sample pack

+

Visitors can see the kind of visuals Talocode 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}

+
+ ))} +
+
+
@@ -270,12 +325,12 @@ export function LandingSections() {

-
+
{workflow.map((item, index) => (
- +

{item.step}

@@ -291,10 +346,10 @@ export function LandingSections() {
-
+
- Before LaunchPix + Before Talocode LaunchPix

The launch looks assembled at the last minute.

@@ -310,10 +365,10 @@ export function LandingSections() {
-
-
+
+
- After LaunchPix + After Talocode LaunchPix

The launch has one clear visual system.

@@ -322,7 +377,7 @@ export function LandingSections() { "Listing frames, promo tiles, and hero banners feel connected.", "Your team previews quality first, then exports when it is ready." ].map((item) => ( -

+

{item}

))} @@ -333,13 +388,13 @@ export function LandingSections() {
-
+

What changes

Fewer design detours. More consistent launch assets.

- LaunchPix keeps the brief, screenshots, generation state, billing, and exports in one workspace so teams can move from product capture to launch delivery without losing context. + Talocode LaunchPix keeps the brief, screenshots, generation state, billing, and exports in one workspace so teams can move from product capture to launch delivery without losing context.

{[ @@ -355,8 +410,8 @@ export function LandingSections() {
-
- +
+

Built for review before commitment.

Start with 300 included credits. Buy more only after the balance runs out and the workflow is worth continuing. @@ -380,14 +435,14 @@ export function LandingSections() {

Use cases

One workflow for the places your launch has to show up.

- High-converting pages make the next use case obvious. LaunchPix keeps each output tied to the same product story. + High-converting pages make the next use case obvious. Talocode LaunchPix keeps each output tied to the same product story.

{useCases.map((item, index) => (
-
+
0{index + 1}

{item.title}

@@ -401,7 +456,7 @@ export function LandingSections() {
-
+

Decision support

Answer the objections before they slow the signup.

@@ -414,7 +469,7 @@ export function LandingSections() { { icon: MessageSquareText, title: "Less guessing", text: "Preview before export keeps risk low." } ].map((item) => (

- +

{item.title}

{item.text}

@@ -422,7 +477,7 @@ export function LandingSections() {
-
+
{faq.map((item, index) => (

{item.question}

diff --git a/components/marketing/page-shell.tsx b/components/marketing/page-shell.tsx index e1c201e..9ca8aeb 100644 --- a/components/marketing/page-shell.tsx +++ b/components/marketing/page-shell.tsx @@ -18,26 +18,26 @@ export function MarketingPageShell({
-
+

{eyebrow}

{title}

{description}

-
+

Why it matters

-
-
- Pain - Unpolished screenshots +
+
+ Pain + Unpolished screenshots
-
- Cost - Slow handoff loops +
+ Cost + Slow handoff loops
- Fix - Launch-ready packs + Fix + Launch-ready packs
diff --git a/components/marketing/top-nav.tsx b/components/marketing/top-nav.tsx index ff3194d..9a67847 100644 --- a/components/marketing/top-nav.tsx +++ b/components/marketing/top-nav.tsx @@ -5,23 +5,34 @@ import { Button } from "@/components/ui/button"; import { ThemeToggle } from "@/components/ui/theme-toggle"; const navItems = [ - { href: "/pricing" as const, label: "Pricing" }, + { href: "/about" as const, label: "About" }, + { href: "/pricing" as const, label: "Credits" }, { href: "/contact" as const, label: "Support" } ]; +const resourceItems = [ + { href: "/docs/api" as const, label: "API" }, + { href: "/terms" as const, label: "Terms" } +]; + export function TopNav() { return ( -
-
- +
+
+ -
-