Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions app/launch/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"use client";

import { type NextPage } from "next";
import { useState } from "react";
import { LaunchForm } from "@/components/Launch/LaunchForm";
import { CampaignResults } from "@/components/Launch/CampaignResults";
import { useLaunchCampaign, type LaunchFormData } from "@/hooks/useLaunchCampaign";

const LaunchPage: NextPage = () => {
const { sections, isGenerating, isDone, error, generate, reset, progress } = useLaunchCampaign();
const [formData, setFormData] = useState<LaunchFormData | null>(null);
const showResults = isGenerating || isDone;

const handleSubmit = (data: LaunchFormData) => {
setFormData(data);
generate(data);
};

const handleReset = () => {
setFormData(null);
reset();
};

return (
<div className="max-w-screen min-h-screen p-4 pb-16 max-w-5xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-left font-heading text-3xl font-bold dark:text-white mb-3">
Release Autopilot
</h1>
<p className="text-lg text-muted-foreground text-left font-light font-sans max-w-2xl">
Generate your entire music release campaign in under 60 seconds — press release, Spotify
pitch, social captions, TikTok hooks, fan newsletter, and curator email.
</p>
{!showResults && (
<p className="text-sm text-muted-foreground mt-2 font-sans">
What a PR firm charges{" "}
<span className="line-through">$3,000</span>
{" "}— free.
</p>
)}
</div>

{error && (
<div className="mb-6 px-4 py-3 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm dark:bg-red-950/20 dark:border-red-900 dark:text-red-400">
{error}
</div>
)}

{!showResults ? (
<div className="max-w-2xl">
<LaunchForm onSubmit={handleSubmit} isLoading={isGenerating} />
</div>
) : (
<CampaignResults
sections={sections}
progress={progress}
isGenerating={isGenerating}
isDone={isDone}
onReset={handleReset}
artistName={formData?.artist_name ?? ""}
songName={formData?.song_name ?? ""}
/>
)}
</div>
);
};

export default LaunchPage;
88 changes: 88 additions & 0 deletions components/Launch/CampaignResults.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CampaignSection } from "./CampaignSection";
import type { Section } from "@/hooks/useLaunchCampaign";

interface CampaignResultsProps {
sections: Section[];
progress: number;
isGenerating: boolean;
isDone: boolean;
onReset: () => void;
artistName: string;
songName: string;
}

export function CampaignResults({
sections,
progress,
isGenerating,
isDone,
onReset,
artistName,
songName,
}: CampaignResultsProps) {
const [allCopied, setAllCopied] = useState(false);

const handleCopyAll = async () => {
const allContent = sections
.filter(s => s.status === "complete")
.map(s => `## ${s.emoji} ${s.label}\n\n${s.content}`)
.join("\n\n---\n\n");
await navigator.clipboard.writeText(allContent);
setAllCopied(true);
setTimeout(() => setAllCopied(false), 2000);
};

return (
<div className="space-y-6">
{/* Progress bar */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-foreground">
{isGenerating
? `Generating campaign for "${songName}" by ${artistName}...`
: isDone
? `Campaign complete for "${songName}" by ${artistName}`
: "Preparing..."}
</span>
<span className="text-muted-foreground">{progress}%</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-[#345A5D] transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>

{/* Section grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{sections.map(section => (
<CampaignSection key={section.key} section={section} />
))}
</div>

{/* Actions */}
{isDone && (
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<Button
onClick={handleCopyAll}
className="flex-1 h-11 bg-[#345A5D] hover:bg-[#2a4a4d] text-white rounded-xl font-semibold"
>
{allCopied ? "Copied to clipboard!" : "Copy Full Campaign"}
</Button>
<Button
onClick={onReset}
variant="outline"
className="flex-1 h-11 rounded-xl font-semibold"
>
Generate Another Campaign
</Button>
</div>
)}
</div>
);
}
84 changes: 84 additions & 0 deletions components/Launch/CampaignSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"use client";

import { useState } from "react";
import { cn } from "@/lib/utils";
import type { Section } from "@/hooks/useLaunchCampaign";

interface CampaignSectionProps {
section: Section;
}

export function CampaignSection({ section }: CampaignSectionProps) {
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
await navigator.clipboard.writeText(section.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};

return (
<div
className={cn(
"rounded-2xl border transition-all duration-300",
section.status === "pending" && "border-border bg-muted/30 opacity-50",
section.status === "generating" && "border-[#345A5D]/40 bg-[#345A5D]/5 shadow-sm shadow-[#345A5D]/10",
section.status === "complete" && "border-border bg-background shadow-sm",
)}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border/50">
<div className="flex items-center gap-2.5">
<span className="text-xl">{section.emoji}</span>
<span className="font-semibold text-sm text-foreground">{section.label}</span>
</div>
<div className="flex items-center gap-2">
{section.status === "generating" && (
<span className="flex items-center gap-1.5 text-xs text-[#345A5D] font-medium">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#345A5D] animate-pulse" />
Writing...
</span>
)}
{section.status === "complete" && (
<>
<span className="text-xs text-green-600 font-medium">Done</span>
<button
onClick={handleCopy}
className="text-xs px-2.5 py-1 rounded-lg bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground transition-colors"
>
{copied ? "Copied!" : "Copy"}
</button>
</>
)}
{section.status === "pending" && (
<span className="text-xs text-muted-foreground">Waiting...</span>
)}
</div>
</div>

{/* Content */}
<div className="px-5 py-4 min-h-[80px]">
{section.status === "pending" && (
<div className="flex flex-col gap-2">
<div className="h-3 bg-muted rounded-full w-3/4 animate-pulse" />
<div className="h-3 bg-muted rounded-full w-1/2 animate-pulse" />
</div>
)}
{(section.status === "generating" || section.status === "complete") && section.content && (
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed font-sans">
{section.content}
{section.status === "generating" && (
<span className="inline-block w-0.5 h-4 bg-[#345A5D] ml-0.5 animate-pulse align-middle" />
)}
</p>
)}
{section.status === "generating" && !section.content && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-[#345A5D] border-t-transparent" />
Starting...
</div>
)}
</div>
</div>
);
}
123 changes: 123 additions & 0 deletions components/Launch/LaunchForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { LaunchFormData } from "@/hooks/useLaunchCampaign";

interface LaunchFormProps {
onSubmit: (data: LaunchFormData) => void;
isLoading: boolean;
}

export function LaunchForm({ onSubmit, isLoading }: LaunchFormProps) {
const [formData, setFormData] = useState<LaunchFormData>({
artist_name: "",
song_name: "",
genre: "",
release_date: "",
description: "",
});

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.artist_name || !formData.song_name || !formData.genre || !formData.release_date) {
return;
}
onSubmit(formData);
};

const inputClass =
"w-full px-4 py-2.5 rounded-xl border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-[#345A5D]/40 transition-all";

const labelClass = "block text-sm font-medium text-foreground mb-1.5";

return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<label className={labelClass}>Artist Name *</label>
<input
type="text"
placeholder="e.g. Olivia Rodrigo"
value={formData.artist_name}
onChange={e => setFormData(d => ({ ...d, artist_name: e.target.value }))}
className={inputClass}
required
disabled={isLoading}
/>
</div>
<div>
<label className={labelClass}>Song / Album Name *</label>
<input
type="text"
placeholder="e.g. Midnight Drive"
value={formData.song_name}
onChange={e => setFormData(d => ({ ...d, song_name: e.target.value }))}
className={inputClass}
required
disabled={isLoading}
/>
</div>
<div>
<label className={labelClass}>Genre *</label>
<input
type="text"
placeholder="e.g. Indie Pop, R&B, Hip-Hop"
value={formData.genre}
onChange={e => setFormData(d => ({ ...d, genre: e.target.value }))}
className={inputClass}
required
disabled={isLoading}
/>
</div>
<div>
<label className={labelClass}>Release Date *</label>
<input
type="text"
placeholder="e.g. April 15, 2026"
value={formData.release_date}
onChange={e => setFormData(d => ({ ...d, release_date: e.target.value }))}
className={inputClass}
required
disabled={isLoading}
/>
</div>
</div>

<div>
<label className={labelClass}>
Additional Context{" "}
<span className="text-muted-foreground font-normal">(optional)</span>
</label>
<textarea
placeholder="e.g. This song is about overcoming heartbreak, written after a 2-year relationship. Inspired by Bon Iver and Frank Ocean."
value={formData.description}
onChange={e => setFormData(d => ({ ...d, description: e.target.value }))}
className={`${inputClass} h-24 resize-none`}
disabled={isLoading}
/>
</div>

<Button
type="submit"
disabled={
isLoading ||
!formData.artist_name ||
!formData.song_name ||
!formData.genre ||
!formData.release_date
}
className="w-full h-12 text-base font-semibold bg-[#345A5D] hover:bg-[#2a4a4d] text-white rounded-xl transition-all"
>
{isLoading ? (
<span className="flex items-center gap-2">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
Generating Campaign...
</span>
) : (
"Generate My Campaign ✦"
)}
</Button>
</form>
);
}
22 changes: 22 additions & 0 deletions components/Sidebar/LaunchNavItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import NavButton from "./NavButton";

const LaunchNavItem = ({
isActive,
isExpanded,
onClick,
}: {
isActive: boolean;
isExpanded?: boolean;
onClick: () => void;
}) => (
<NavButton
icon="star"
label="Launch"
isActive={isActive}
isExpanded={isExpanded}
onClick={onClick}
aria-label="Release Autopilot"
/>
);

export default LaunchNavItem;
Loading
Loading