diff --git a/app/launch/page.tsx b/app/launch/page.tsx new file mode 100644 index 000000000..7a27631bc --- /dev/null +++ b/app/launch/page.tsx @@ -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(null); + const showResults = isGenerating || isDone; + + const handleSubmit = (data: LaunchFormData) => { + setFormData(data); + generate(data); + }; + + const handleReset = () => { + setFormData(null); + reset(); + }; + + return ( +
+ {/* Header */} +
+

+ Release Autopilot +

+

+ Generate your entire music release campaign in under 60 seconds — press release, Spotify + pitch, social captions, TikTok hooks, fan newsletter, and curator email. +

+ {!showResults && ( +

+ What a PR firm charges{" "} + $3,000 + {" "}— free. +

+ )} +
+ + {error && ( +
+ {error} +
+ )} + + {!showResults ? ( +
+ +
+ ) : ( + + )} +
+ ); +}; + +export default LaunchPage; diff --git a/components/Launch/CampaignResults.tsx b/components/Launch/CampaignResults.tsx new file mode 100644 index 000000000..47e8d1ea4 --- /dev/null +++ b/components/Launch/CampaignResults.tsx @@ -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 ( +
+ {/* Progress bar */} +
+
+ + {isGenerating + ? `Generating campaign for "${songName}" by ${artistName}...` + : isDone + ? `Campaign complete for "${songName}" by ${artistName}` + : "Preparing..."} + + {progress}% +
+
+
+
+
+ + {/* Section grid */} +
+ {sections.map(section => ( + + ))} +
+ + {/* Actions */} + {isDone && ( +
+ + +
+ )} +
+ ); +} diff --git a/components/Launch/CampaignSection.tsx b/components/Launch/CampaignSection.tsx new file mode 100644 index 000000000..0ba4f09ec --- /dev/null +++ b/components/Launch/CampaignSection.tsx @@ -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 ( +
+ {/* Header */} +
+
+ {section.emoji} + {section.label} +
+
+ {section.status === "generating" && ( + + + Writing... + + )} + {section.status === "complete" && ( + <> + Done + + + )} + {section.status === "pending" && ( + Waiting... + )} +
+
+ + {/* Content */} +
+ {section.status === "pending" && ( +
+
+
+
+ )} + {(section.status === "generating" || section.status === "complete") && section.content && ( +

+ {section.content} + {section.status === "generating" && ( + + )} +

+ )} + {section.status === "generating" && !section.content && ( +
+ + Starting... +
+ )} +
+
+ ); +} diff --git a/components/Launch/LaunchForm.tsx b/components/Launch/LaunchForm.tsx new file mode 100644 index 000000000..299762fbf --- /dev/null +++ b/components/Launch/LaunchForm.tsx @@ -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({ + 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 ( +
+
+
+ + setFormData(d => ({ ...d, artist_name: e.target.value }))} + className={inputClass} + required + disabled={isLoading} + /> +
+
+ + setFormData(d => ({ ...d, song_name: e.target.value }))} + className={inputClass} + required + disabled={isLoading} + /> +
+
+ + setFormData(d => ({ ...d, genre: e.target.value }))} + className={inputClass} + required + disabled={isLoading} + /> +
+
+ + setFormData(d => ({ ...d, release_date: e.target.value }))} + className={inputClass} + required + disabled={isLoading} + /> +
+
+ +
+ +