Feature/purchase onboarding flow (#1027)

This commit is contained in:
BFlat authored and GitHub committed 2026-02-02 23:45:26 +07:00
1 parent 9ea44e4aa6
commit 2800c84699
14 files changed
+1425 -368

No files matched your search

@@ -2,6 +2,14 @@
/checkout_cancel/ /index.html 200
/checkout_cancel/* /index.html 200
/onboarding /index.html 200
/onboarding/ /index.html 200
/onboarding/* /index.html 200
/portal_closed /index.html 200
/portal_closed/ /index.html 200
/portal_closed/* /index.html 200
/checkout_success /index.html 200
/checkout_success/ /index.html 200
/checkout_success/* /index.html 200
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -15,6 +15,7 @@ import Login from "../pages/login";
import Signup from "../pages/signup";
import ForgotPassword from "../pages/forgot-password";
import Welcome from "../pages/welcome";
import Onboarding from "../pages/onboarding";
import { CheckoutSuccess, CheckoutCancel } from "../pages/checkout";
export function App() {
@@ -39,6 +40,7 @@ export function App() {
<Route path="/signup" element={<Signup />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/welcome" element={<Welcome />} />
<Route path="/onboarding" element={<Onboarding />} />
<Route path="/checkout/success" element={<CheckoutSuccess />} />
<Route path="/checkout/cancel" element={<CheckoutCancel />} />
{/* Redirects for underscore-based URLs (legacy Stripe config) */}
@@ -50,6 +52,10 @@ export function App() {
path="/checkout_cancel"
element={<Navigate to="/checkout/cancel" replace />}
/>
<Route
path="/portal_closed"
element={<Navigate to="/checkout/cancel" replace />}
/>
</Routes>
</div>
);
@@ -0,0 +1,54 @@
import { Button } from "@storyteller/ui-button";
import { isMobile, isMacOs } from "react-device-detect";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faWindows, faApple } from "@fortawesome/free-brands-svg-icons";
import { faRocket } from "@fortawesome/pro-solid-svg-icons";
import { DOWNLOAD_LINKS } from "../config/downloads";
interface LandingActionButtonsProps {
onDownloadClick?: (e: React.MouseEvent) => void;
className?: string;
}
export const LandingActionButtons = ({
onDownloadClick,
className,
}: LandingActionButtonsProps) => {
const MAC_LINK = DOWNLOAD_LINKS.MACOS;
const WINDOWS_LINK = DOWNLOAD_LINKS.WINDOWS;
const downloadUrl = isMacOs ? MAC_LINK : WINDOWS_LINK;
return (
<div
className={`flex flex-col sm:flex-row items-center justify-center gap-2.5 md:gap-4 ${
className || ""
}`}
>
{isMobile ? (
<Button className="text-lg font-semibold rounded-xl shadow-lg" disabled>
Download on a desktop
</Button>
) : (
<>
<Button
className="glow-border-animated text-md px-8 py-4 text-lg font-semibold rounded-xl shadow-lg gap-3 transition-all duration-300 hover:scale-105 hover:shadow-primary/25 border-2 border-primary/30 bg-gradient-to-r from-primary/20 to-purple-600/20 hover:from-primary/30 hover:to-purple-600/30 backdrop-blur-md"
as="link"
href="/pricing"
>
<FontAwesomeIcon icon={faRocket} />
Supercharge Credits
</Button>
<Button
className="text-md px-8 py-4 text-lg font-semibold rounded-xl shadow-lg gap-3 transition-all duration-300 bg-white hover:bg-white/80 text-black"
as="link"
href={downloadUrl}
onClick={onDownloadClick}
>
<FontAwesomeIcon icon={isMacOs ? faApple : faWindows} />
Download for {isMacOs ? "Mac" : "Windows"}
</Button>
</>
)}
</div>
);
};
@@ -8,12 +8,7 @@ import {
import { twMerge } from "tailwind-merge";
import { useState, useEffect } from "react";
import { TabSelector } from "@storyteller/ui-tab-selector";
import {
UsersApi,
BillingApi,
UserInfo,
StorytellerApiHostStore,
} from "@storyteller/api";
import { UsersApi, BillingApi, UserInfo } from "@storyteller/api";
import { useNavigate } from "react-router-dom";
const billingTabs = [
@@ -54,6 +49,7 @@ const PricingTable = ({
const [activePlanSlug, setActivePlanSlug] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [processingPlan, setProcessingPlan] = useState<string | null>(null);
const [isCancelling, setIsCancelling] = useState(false);
// Fetch user session and subscriptions on mount
useEffect(() => {
@@ -172,12 +168,6 @@ const PricingTable = ({
return;
}
// Not logged in - redirect to signup
if (!user) {
navigate("/signup");
return;
}
// Set loading state for this plan
setProcessingPlan(planSlug);
@@ -185,41 +175,59 @@ const PricingTable = ({
const cadence = isYearly ? "yearly" : "monthly";
const apiPlanSlug = PLAN_SLUG_MAP[planSlug] || planSlug;
// Determine if we need checkout (new subscription) or portal (switch plan)
const hasActiveSub = activePlanSlug && activePlanSlug !== "free";
const endpoint = hasActiveSub
? "/v1/stripe_artcraft/portal/switch_plan"
: "/v1/stripe_artcraft/checkout/subscription";
const apiHost =
StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const response = await fetch(`${apiHost}${endpoint}`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
// Not logged in - use user_signup_subscription_checkout
if (!user) {
const billingApi = new BillingApi();
const response = await billingApi.UserSignupSubscriptionCheckout({
plan: apiPlanSlug,
cadence: cadence,
}),
});
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
if (!response.success || !response.data) {
throw new Error(
response.errorMessage || "Failed to initiate checkout",
);
}
// Redirect to Stripe
window.location.href = response.data.stripeCheckoutRedirectUrl;
return;
}
const data = await response.json();
// Logged in - determine if we need checkout (new subscription) or portal (switch plan)
const hasActiveSub = activePlanSlug && activePlanSlug !== "free";
const billingApi = new BillingApi();
// Redirect to Stripe
const redirectUrl =
data.stripe_checkout_redirect_url || data.stripe_portal_url;
if (redirectUrl) {
window.location.href = redirectUrl;
if (hasActiveSub) {
// User has active subscription - switch plan via portal
const response = await billingApi.SwitchPlan({
plan: apiPlanSlug,
cadence: cadence,
});
if (!response.success || !response.data) {
throw new Error(
response.errorMessage || "Failed to initiate plan switch",
);
}
// Redirect to Stripe Portal
window.location.href = response.data.stripePortalUrl;
} else {
console.error("No redirect URL returned from API");
// User logged in but no active subscription - checkout
const response = await billingApi.SubscriptionCheckout({
plan: apiPlanSlug,
cadence: cadence,
});
if (!response.success || !response.data) {
throw new Error(
response.errorMessage || "Failed to initiate checkout",
);
}
// Redirect to Stripe Checkout
window.location.href = response.data.stripeCheckoutRedirectUrl;
}
} catch (error) {
console.error("Error initiating checkout:", error);
@@ -228,6 +236,28 @@ const PricingTable = ({
}
};
const handleCancelPlan = async () => {
setIsCancelling(true);
try {
const billingApi = new BillingApi();
// Access general portal where user can cancel
const response = await billingApi.GetPortalUrl();
if (!response.success || !response.data) {
throw new Error(
response.errorMessage || "Failed to access subscription management",
);
}
// Redirect to Stripe Portal where user can cancel
window.location.href = response.data.stripePortalUrl;
} catch (error) {
console.error("Error accessing subscription management:", error);
} finally {
setIsCancelling(false);
}
};
const formatPrice = (plan: SubscriptionPlanDetails) => {
if (plan.monthlyPrice === 0) {
return { current: 0, original: null };
@@ -386,6 +416,19 @@ const PricingTable = ({
);
})}
</div>
{/* Manage Plan Button - Only shown if user has active subscription */}
{activePlanSlug && activePlanSlug !== "free" && (
<div className="mt-2 text-center">
<button
onClick={handleCancelPlan}
disabled={isCancelling}
className="text-sm text-white/40 hover:text-white/60 underline transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isCancelling ? "Loading..." : "Manage Plan"}
</button>
</div>
)}
</div>
);
};
@@ -2,11 +2,64 @@ import { faXmarkCircle } from "@fortawesome/pro-solid-svg-icons";
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { SOCIAL_LINKS } from "../../config/links";
import Seo from "../../components/seo";
import { useEffect, useState } from "react";
import { UsersApi } from "@storyteller/api";
const CheckoutCancel = () => {
const [searchParams] = useSearchParams();
const skipOnboarding = searchParams.get("skip_onboarding") === "true";
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const checkOnboarding = async () => {
// Skip onboarding check if we already went through it
if (skipOnboarding) {
setIsLoading(false);
return;
}
try {
const usersApi = new UsersApi();
const sessionResponse = await usersApi.GetSession();
if (sessionResponse.success && sessionResponse.data?.loggedIn) {
const onboarding = sessionResponse.data.onboarding;
// If onboarding is undefined or has any required fields not set, redirect to onboarding
if (
!onboarding ||
onboarding.password_not_set ||
onboarding.email_not_set ||
onboarding.email_not_confirmed
) {
// Redirect to onboarding - it will add skip_onboarding=true after username step
window.location.href =
"/onboarding?redirect_to=" +
encodeURIComponent("/checkout/cancel");
return;
}
}
} catch (error) {
console.error("Error checking onboarding status:", error);
} finally {
setIsLoading(false);
}
};
checkOnboarding();
}, [skipOnboarding]);
if (isLoading) {
return (
<div className="relative min-h-screen bg-[#101014] text-white flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
</div>
);
}
return (
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
<Seo
@@ -7,14 +7,66 @@ import {
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { isMobile, isMacOs } from "react-device-detect";
import { DOWNLOAD_LINKS } from "../../config/downloads";
import { SOCIAL_LINKS } from "../../config/links";
import Seo from "../../components/seo";
import { UsersApi } from "@storyteller/api";
import { useEffect, useState } from "react";
const CheckoutSuccess = () => {
const [searchParams] = useSearchParams();
const downloadUrl = isMacOs ? DOWNLOAD_LINKS.MACOS : DOWNLOAD_LINKS.WINDOWS;
const [isCheckingOnboarding, setIsCheckingOnboarding] = useState(true);
useEffect(() => {
const checkOnboarding = async () => {
// If coming from completed onboarding, skip the check
if (searchParams.get("onboarding_complete") === "true") {
setIsCheckingOnboarding(false);
return;
}
try {
const usersApi = new UsersApi();
const sessionResponse = await usersApi.GetSession();
if (sessionResponse.success && sessionResponse.data?.loggedIn) {
const onboarding = sessionResponse.data.onboarding;
// If onboarding is undefined or has any required fields not set, redirect to onboarding
// This handles the case where the backend hasn't populated onboarding data yet
if (
!onboarding ||
onboarding.password_not_set ||
onboarding.email_not_set ||
onboarding.email_not_confirmed
) {
// Use window.location for a hard redirect to ensure it works
// Do NOT set isCheckingOnboarding to false - keep showing loading spinner
window.location.href = "/onboarding";
return;
}
}
} catch (error) {
console.error("Error checking onboarding status:", error);
}
// Only set loading to false if we're actually staying on this page
setIsCheckingOnboarding(false);
};
checkOnboarding();
}, [searchParams]);
if (isCheckingOnboarding) {
return (
<div className="relative min-h-screen bg-[#101014] text-white flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
</div>
);
}
return (
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
@@ -1,7 +1,7 @@
import { DiscordButton } from "../../components/discord-button";
import { Button } from "@storyteller/ui-button";
import { isMobile, isMacOs } from "react-device-detect";
import { faWindows, faApple, faGithub } from "@fortawesome/free-brands-svg-icons";
import { isMobile } from "react-device-detect";
import { faGithub } from "@fortawesome/free-brands-svg-icons";
import {
faVolumeMute,
faVolumeHigh,
@@ -23,19 +23,18 @@ import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import ModelBadgeGrid from "../../components/model-badge-grid";
import Seo from "../../components/seo";
import { DOWNLOAD_LINKS } from "../../config/downloads";
import { OwnershipComparison } from "../../components/ownership-comparison/ownership-comparison";
import { DownloadModal } from "../../components/download-modal";
import { UsersApi } from "@storyteller/api";
import { LandingActionButtons } from "../../components/landing-action-buttons";
gsap.registerPlugin(ScrollTrigger);
//const HERO_VIDEO_URL = "https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/artcraft_commercial.mp4";
const HERO_VIDEO_URL = "https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/artcraft_website_v2.mp4";
// Versions and links are now centralized in downloads config - BFlat
const MAC_LINK = DOWNLOAD_LINKS.MACOS;
const WINDOWS_LINK = DOWNLOAD_LINKS.WINDOWS;
const HERO_VIDEO_URL =
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/artcraft_website_v2.mp4";
const Landing = () => {
const videos = [
@@ -58,26 +57,24 @@ const Landing = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const downloadUrl = isMacOs ? MAC_LINK : WINDOWS_LINK;
useEffect(() => {
const checkSession = async () => {
const api = new UsersApi();
const response = await api.GetSession();
if (response.success && response.data?.loggedIn) {
setIsLoggedIn(true);
}
const api = new UsersApi();
const response = await api.GetSession();
if (response.success && response.data?.loggedIn) {
setIsLoggedIn(true);
}
};
checkSession();
}, []);
const onDownloadClick = (e: React.MouseEvent) => {
if (isLoggedIn) {
return;
return;
}
setShowDownloadModal(true);
localStorage.setItem("artcraft_download_initiated", "true");
};
@@ -85,45 +82,52 @@ const Landing = () => {
{
icon: faMapMarkerAlt,
title: "Image to Location",
description: "Placing virtual actors into physical environments establishes single-location consistency. You can film multiple shots within a room without having things disappear.",
src: "/videos/features/WorldLabs_Demo_2.webm"
description:
"Placing virtual actors into physical environments establishes single-location consistency. You can film multiple shots within a room without having things disappear.",
src: "/videos/features/WorldLabs_Demo_2.webm",
},
{
icon: faCube,
title: "3D Image Compositing",
description: "Use images (backdrops, foreground elements, props, etc.) in scenes with depth and blend them naturally together. Just a couple of images usually leads to great compositions.",
src: "/videos/features/Panel.webm"
description:
"Use images (backdrops, foreground elements, props, etc.) in scenes with depth and blend them naturally together. Just a couple of images usually leads to great compositions.",
src: "/videos/features/Panel.webm",
},
{
icon: faLayerGroup,
title: "2D Image Compositing",
description: "Use images, background removal, layers, and simple drawing tools to precisely compose a scene.",
src: "/videos/features/Editor.webm"
description:
"Use images, background removal, layers, and simple drawing tools to precisely compose a scene.",
src: "/videos/features/Editor.webm",
},
{
icon: faShapes,
title: "Image to 3D Mesh",
description: "It's almost impossible to lay out complicated objects or block complicated scenes; turning images into 3D helps position elements exactingly and intentionally.",
src: "/videos/features/Make_3D.webm"
description:
"It's almost impossible to lay out complicated objects or block complicated scenes; turning images into 3D helps position elements exactingly and intentionally.",
src: "/videos/features/Make_3D.webm",
},
{
icon: faTools,
title: "Mixed Asset Crafting",
description: "You can use image cutouts, worlds, and simple 3D meshes all together to precisely and intentionally lay out your scenes.",
src: "/videos/features/Mixed.webm"
description:
"You can use image cutouts, worlds, and simple 3D meshes all together to precisely and intentionally lay out your scenes.",
src: "/videos/features/Mixed.webm",
},
{
icon: faUser,
title: "Character Posing",
description: "You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling \"action\".",
src: "/videos/features/Pose_Second_Version.webm"
description:
'You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action".',
src: "/videos/features/Pose_Second_Version.webm",
},
{
icon: faEraser,
title: "Background Removal",
description: "Instantly remove backgrounds from images to create assets for your scenes. Clean, precise, and ready for compositing.",
src: "/videos/features/Background.webm"
}
description:
"Instantly remove backgrounds from images to create assets for your scenes. Clean, precise, and ready for compositing.",
src: "/videos/features/Background.webm",
},
];
// Refs for GSAP animations
@@ -142,9 +146,9 @@ const Landing = () => {
// ONLY on non-mobile devices to ensure performance and prevent content loading issues
if (!isMobile) {
const elements = gsap.utils.toArray<HTMLElement>("[data-animate]");
gsap.set(elements, { autoAlpha: 0, y: 20 });
elements.forEach((el) => {
gsap.to(el, {
autoAlpha: 1,
@@ -155,7 +159,7 @@ const Landing = () => {
trigger: el,
start: "top 90%",
toggleActions: "play none none none",
}
},
});
});
}
@@ -171,13 +175,13 @@ const Landing = () => {
pinSpacing: false,
invalidateOnRefresh: true,
});
featureRefs.current.forEach((el, index) => {
if (!el) return;
ScrollTrigger.create({
trigger: el,
start: "top 40%",
end: "bottom 40%",
end: "bottom 40%",
onEnter: () => setActiveFeature(index),
onEnterBack: () => setActiveFeature(index),
});
@@ -211,15 +215,17 @@ const Landing = () => {
// GSAP animation for mobile media and description
useEffect(() => {
if (mobileMediaRef.current) {
gsap.fromTo(mobileMediaRef.current,
gsap.fromTo(
mobileMediaRef.current,
{ opacity: 0 },
{ opacity: 1, duration: 0.4, ease: "power2.out" }
{ opacity: 1, duration: 0.4, ease: "power2.out" },
);
}
if (mobileDescRef.current) {
gsap.fromTo(mobileDescRef.current,
gsap.fromTo(
mobileDescRef.current,
{ opacity: 0, y: 10 },
{ opacity: 1, y: 0, duration: 0.3, ease: "power2.out" }
{ opacity: 1, y: 0, duration: 0.3, ease: "power2.out" },
);
}
}, [activeFeature]);
@@ -236,7 +242,10 @@ const Landing = () => {
};
return (
<div ref={mainRef} className="relative min-h-screen bg-[#101014] text-white overflow-x-hidden bg-dots">
<div
ref={mainRef}
className="relative min-h-screen bg-[#101014] text-white overflow-x-hidden bg-dots"
>
<Seo
title="ArtCraft. AI Video and Images. Fast and Open Desktop App."
description="ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!"
@@ -279,28 +288,25 @@ const Landing = () => {
{/* CTA Buttons */}
<div
className="relative flex flex-col sm:flex-row items-center justify-center gap-2.5 md:gap-4 mb-10 sm:mb-16 w-fit max-w-xs sm:max-w-none mx-auto"
className="relative flex flex-col sm:flex-row items-center justify-center mb-10 sm:mb-16 w-full"
data-animate
>
{isMobile ? (
<Button
className="text-lg font-semibold rounded-xl shadow-lg"
disabled
>
Download on a desktop
</Button>
) : (
<Button
className="text-md px-8 py-4 text-lg font-semibold rounded-xl shadow-lg gap-3 transition-all duration-300 hover:scale-105 hover:shadow-primary/25"
as="link"
href={downloadUrl}
onClick={onDownloadClick}
>
<FontAwesomeIcon icon={isMacOs ? faApple : faWindows} />
Download for {isMacOs ? "Mac" : "Windows"}
</Button>
)}
<img src="/images/try-free.png" alt="Try Free" draggable={false} className="absolute -left-[58%] -top-2 h-40 pointer-events-none select-none hidden md:block" />
<LandingActionButtons
onDownloadClick={onDownloadClick}
className="w-fit max-w-xs sm:max-w-none mx-auto z-10"
/>
<img
src="/images/buy-credits.png"
alt="Buy Credits"
draggable={false}
className="absolute left-[10%] -top-2 h-40 pointer-events-none select-none hidden md:block"
/>
<img
src="/images/try-download.png"
alt="Try Download"
draggable={false}
className="absolute right-[9%] -bottom-2 h-40 pointer-events-none select-none hidden md:block"
/>
</div>
{/* Glassy Video Mockup */}
@@ -313,21 +319,18 @@ const Landing = () => {
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<video
ref={videoRef}
autoPlay
loop
playsInline
muted
preload="none"
poster="/images/hero-poster.jpg"
className="w-full h-full object-cover rounded-2xl bg-white"
src={HERO_VIDEO_URL}
<video
ref={videoRef}
autoPlay
loop
playsInline
muted
preload="none"
poster="/images/hero-poster.jpg"
className="w-full h-full object-cover rounded-2xl bg-white"
src={HERO_VIDEO_URL}
>
<source
src={HERO_VIDEO_URL}
type="video/mp4"
/>
<source src={HERO_VIDEO_URL} type="video/mp4" />
Your browser does not support the video tag.
</video>
<button
@@ -337,8 +340,8 @@ const Landing = () => {
isMuted
? "opacity-100"
: isHovering
? "opacity-100"
: "opacity-0"
? "opacity-100"
: "opacity-0"
}`}
aria-label={isMuted ? "Unmute video" : "Mute video"}
>
@@ -353,160 +356,182 @@ const Landing = () => {
</main>
<div className="relative z-10 py-12 md:py-24 px-4 sm:px-8 lg:px-12 max-w-[1400px] mx-auto overflow-visible">
{/* Gradient Orb for Section */}
<div className="absolute left-[-200px] top-[-100px] w-[600px] h-[600px] rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-blue-700 opacity-15 blur-[80px] md:blur-[120px] z-0 pointer-events-none transform-gpu" />
{/* Gradient Orb for Section */}
<div className="absolute left-[-200px] top-[-100px] w-[600px] h-[600px] rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-blue-700 opacity-15 blur-[80px] md:blur-[120px] z-0 pointer-events-none transform-gpu" />
{/* Title Area */}
<div className="relative z-10 mb-6 md:-mb-12 min-[1980px]:-mb-48 pointer-events-none">
{/* Title Area */}
<div className="relative z-10 mb-6 md:-mb-12 min-[1980px]:-mb-48 pointer-events-none">
<div className="max-w-3xl mx-auto text-center" data-animate>
<h2 className="text-primary font-bold text-sm md:text-base mb-6 tracking-widest uppercase">
Advanced Crafting Features
</h2>
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.1] mb-8">
We're Pulling You <br />{" "}
<span className="text-white">Out of Prompting</span>
</h1>
<p className="text-lg md:text-xl text-white/60 leading-relaxed max-w-2xl mx-auto">
Text prompting is neat, but artists <em>crave control</em>.
</p>
<br />
<p className="text-lg md:text-xl text-white/60 leading-relaxed max-w-2xl mx-auto">
Before you type "rotate arm thirty degrees", try ArtCraft and let
our advanced toolset help you achieve consistency and
repeatability.
</p>
<br />
<p className="text-lg md:text-xl text-white/60 leading-relaxed max-w-2xl mx-auto">
ArtCraft is the control that mere words cannot buy.
</p>
</div>
</div>
<div className="relative">
{/* Mobile Layout */}
<div className="lg:hidden flex flex-col gap-6">
{/* Mobile Navigation (Tabs) */}
<div className="w-full overflow-x-auto pb-4 no-scrollbar flex gap-3 snap-x px-1">
{features.map((feature, index) => (
<button
key={index}
onClick={() => setActiveFeature(index)}
className={`snap-center shrink-0 px-5 py-2.5 rounded-full text-sm font-bold border transition-all duration-300 whitespace-nowrap ${
activeFeature === index
? "bg-primary border-primary text-white shadow-lg shadow-primary/20"
: "bg-white/5 border-white/10 text-white/60 hover:bg-white/10"
}`}
>
{feature.title}
</button>
))}
</div>
{/* Mobile Media Preview */}
<div className="relative aspect-[4/3] w-full bg-[#050505] rounded-xl overflow-hidden shadow-2xl border border-white/10 ring-1 ring-white/5">
<div
key={activeFeature}
ref={mobileMediaRef}
className="absolute inset-0"
>
<video
src={features[activeFeature].src}
className="w-full h-full object-cover select-none"
autoPlay
loop
muted
playsInline
/>
<div className="absolute inset-0 pointer-events-none bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-60" />
</div>
</div>
{/* Mobile Description */}
<div
className="max-w-3xl mx-auto text-center"
data-animate
key={activeFeature}
ref={mobileDescRef}
className="bg-white/5 border border-white/10 rounded-2xl p-5"
>
<h2 className="text-primary font-bold text-sm md:text-base mb-6 tracking-widest uppercase">
Advanced Crafting Features
</h2>
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.1] mb-8">
We're Pulling You <br /> <span className="text-white">Out of Prompting</span>
</h1>
<p className="text-lg md:text-xl text-white/60 leading-relaxed max-w-2xl mx-auto">
Text prompting is neat, but artists <em>crave control</em>.
</p>
<br />
<p className="text-lg md:text-xl text-white/60 leading-relaxed max-w-2xl mx-auto">
Before you type "rotate arm thirty degrees", try ArtCraft and let our advanced
toolset help you achieve consistency and repeatability.
</p>
<br />
<p className="text-lg md:text-xl text-white/60 leading-relaxed max-w-2xl mx-auto">
ArtCraft is the control that mere words cannot buy.
<h3 className="text-lg font-bold text-white mb-2">
{features[activeFeature].title}
</h3>
<p className="text-white/70 text-sm leading-relaxed">
{features[activeFeature].description}
</p>
</div>
</div>
</div>
<div className="relative">
{/* Mobile Layout */}
<div className="lg:hidden flex flex-col gap-6">
{/* Mobile Navigation (Tabs) */}
<div className="w-full overflow-x-auto pb-4 no-scrollbar flex gap-3 snap-x px-1">
{/* Desktop Sticky Scroll Layout */}
{!isMobile && (
<div
className="hidden lg:flex flex-row relative items-start"
ref={containerRef}
>
{/* Left Column: Scrolling Text */}
<div className="w-[40%] flex flex-col relative z-20 left-column py-[10vh]">
{features.map((feature, index) => (
<button
<div
key={index}
onClick={() => setActiveFeature(index)}
className={`snap-center shrink-0 px-5 py-2.5 rounded-full text-sm font-bold border transition-all duration-300 whitespace-nowrap ${
activeFeature === index
? "bg-primary border-primary text-white shadow-lg shadow-primary/20"
: "bg-white/5 border-white/10 text-white/60 hover:bg-white/10"
}`}
ref={(el) => {
featureRefs.current[index] = el;
}}
className="min-h-[80vh] flex flex-col justify-center pr-8 xl:pr-16"
>
{feature.title}
</button>
<div className="max-w-lg">
<div
className={`w-12 h-12 mb-6 rounded-2xl flex items-center justify-center text-2xl transition-all duration-500 ${
activeFeature === index
? "bg-primary text-white shadow-lg shadow-primary/30 scale-110"
: "bg-white/10 text-white/50"
}`}
>
<FontAwesomeIcon icon={feature.icon} />
</div>
<h3
className={`text-3xl xl:text-4xl font-bold mb-6 transition-all duration-500 ${
activeFeature === index
? "text-white"
: "text-white/40"
}`}
>
{feature.title}
</h3>
<p
className={`text-xl leading-relaxed transition-all duration-500 ${
activeFeature === index
? "text-white/80"
: "text-white/30"
}`}
>
{feature.description}
</p>
</div>
</div>
))}
</div>
{/* Mobile Media Preview */}
<div className="relative aspect-[4/3] w-full bg-[#050505] rounded-xl overflow-hidden shadow-2xl border border-white/10 ring-1 ring-white/5">
<div
key={activeFeature}
ref={mobileMediaRef}
className="absolute inset-0"
>
<video
src={features[activeFeature].src}
className="w-full h-full object-cover select-none"
autoPlay
loop
muted
playsInline
/>
<div className="absolute inset-0 pointer-events-none bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-60" />
</div>
</div>
{/* Mobile Description */}
<div
key={activeFeature}
ref={mobileDescRef}
className="bg-white/5 border border-white/10 rounded-2xl p-5"
>
<h3 className="text-lg font-bold text-white mb-2">
{features[activeFeature].title}
</h3>
<p className="text-white/70 text-sm leading-relaxed">
{features[activeFeature].description}
</p>
</div>
</div>
{/* Desktop Sticky Scroll Layout */}
{!isMobile && (
<div className="hidden lg:flex flex-row relative items-start" ref={containerRef}>
{/* Left Column: Scrolling Text */}
<div className="w-[40%] flex flex-col relative z-20 left-column py-[10vh]">
{/* Right Column: Sticky Image */}
<div className="w-[60%] h-screen flex items-center justify-center pl-4 py-8 z-10 right-column">
<div className="relative w-full aspect-[4/3] max-h-[80vh] rounded-[32px] overflow-hidden shadow-2xl border-[4px] border-white/5 bg-[#050505]">
{features.map((feature, index) => (
<div
key={index}
ref={(el) => { featureRefs.current[index] = el; }}
className="min-h-[80vh] flex flex-col justify-center pr-8 xl:pr-16"
ref={(el) => {
cardRefs.current[index] = el;
}}
className="absolute inset-0 w-full h-full bg-[#050505] shadow-2xl origin-bottom"
style={{
zIndex: index,
transform:
index === 0 ? "translateY(0%)" : "translateY(110%)",
}}
>
<div className="max-w-lg">
<div className={`w-12 h-12 mb-6 rounded-2xl flex items-center justify-center text-2xl transition-all duration-500 ${
activeFeature === index ? "bg-primary text-white shadow-lg shadow-primary/30 scale-110" : "bg-white/10 text-white/50"
}`}>
<FontAwesomeIcon icon={feature.icon} />
</div>
<h3 className={`text-3xl xl:text-4xl font-bold mb-6 transition-all duration-500 ${
activeFeature === index ? "text-white" : "text-white/40"
}`}>
{feature.title}
</h3>
<p className={`text-xl leading-relaxed transition-all duration-500 ${
activeFeature === index ? "text-white/80" : "text-white/30"
}`}>
{feature.description}
</p>
</div>
<video
src={feature.src}
className="w-full h-full object-cover"
autoPlay
loop
muted
playsInline
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent pointer-events-none" />
</div>
))}
</div>
{/* Right Column: Sticky Image */}
<div className="w-[60%] h-screen flex items-center justify-center pl-4 py-8 z-10 right-column">
<div className="relative w-full aspect-[4/3] max-h-[80vh] rounded-[32px] overflow-hidden shadow-2xl border-[4px] border-white/5 bg-[#050505]">
{features.map((feature, index) => (
<div
key={index}
ref={(el) => { cardRefs.current[index] = el; }}
className="absolute inset-0 w-full h-full bg-[#050505] shadow-2xl origin-bottom"
style={{ zIndex: index, transform: index === 0 ? 'translateY(0%)' : 'translateY(110%)' }}
>
<video
src={feature.src}
className="w-full h-full object-cover"
autoPlay
loop
muted
playsInline
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent pointer-events-none" />
</div>
))}
{/* Decorative Elements */}
<div className="absolute top-4 right-4 px-4 py-2 bg-black/40 backdrop-blur-md rounded-full border border-white/10 z-20">
<span className="text-white/80 text-sm font-medium tracking-wide">
{activeFeature + 1} / {features.length}
</span>
</div>
{/* Decorative Elements */}
<div className="absolute top-4 right-4 px-4 py-2 bg-black/40 backdrop-blur-md rounded-full border border-white/10 z-20">
<span className="text-white/80 text-sm font-medium tracking-wide">
{activeFeature + 1} / {features.length}
</span>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
<OwnershipComparison />
@@ -546,16 +571,11 @@ const Landing = () => {
{/* Gradient Orbs for Section */}
<div className="absolute right-[-150px] top-[-100px] w-[500px] h-[500px] rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-blue-700 opacity-10 blur-[80px] md:blur-[110px] z-0 pointer-events-none transform-gpu" />
<div
className="mx-auto max-w-[88rem] md:px-6 lg:px-8"
data-animate
>
<div className="mx-auto max-w-[88rem] md:px-6 lg:px-8" data-animate>
{/* Bento Grid Container */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-12 gap-6 sm:gap-8">
{/* Reason #1 - Large Box */}
<div
className="xl:col-span-6 bg-[#28282C] rounded-2xl sm:rounded-3xl p-6 lg:p-8 shadow-2xl transition-all duration-300 group"
>
<div className="xl:col-span-6 bg-[#28282C] rounded-2xl sm:rounded-3xl p-6 lg:p-8 shadow-2xl transition-all duration-300 group">
<div className="flex xl:flex-col gap-4 lg:gap-8 h-full flex-col-reverse">
<div className="grow h-40">
<img
@@ -582,9 +602,7 @@ const Landing = () => {
</div>
{/* Reason #2 - Medium Box */}
<div
className="xl:col-span-6 bg-[#28282C] rounded-2xl sm:rounded-3xl p-6 lg:p-8 pb-0 lg:pb-0 shadow-2xl transition-all duration-300 group"
>
<div className="xl:col-span-6 bg-[#28282C] rounded-2xl sm:rounded-3xl p-6 lg:p-8 pb-0 lg:pb-0 shadow-2xl transition-all duration-300 group">
<div className="relative flex flex-col h-full">
<h3 className="font-bold text-xl sm:text-2xl lg:text-3xl mb-3 sm:mb-4 leading-tight ">
Desktop App
@@ -623,18 +641,16 @@ const Landing = () => {
</div>
{/* Reason #3 - Medium Box */}
<div
className="xl:col-span-4 bg-[#28282C] rounded-2xl sm:rounded-3xl p-6 lg:p-8 shadow-2xl transition-all duration-300 group"
>
<div className="xl:col-span-4 bg-[#28282C] rounded-2xl sm:rounded-3xl p-6 lg:p-8 shadow-2xl transition-all duration-300 group">
<div className="flex flex-col h-full">
<h3 className="font-bold text-xl sm:text-2xl lg:text-3xl mb-3 sm:mb-4 leading-tight">
It's Open Source
</h3>
<p className="text-white/80 text-sm sm:text-base lg:text-lg mb-4 sm:mb-6 leading-relaxed flex-1">
Our desktop app's code and infrastructure are all{" "}
<a
href="https://github.com/storytold/artcraft"
target="_blank"
<a
href="https://github.com/storytold/artcraft"
target="_blank"
rel="noopener noreferrer"
className="text-primary-400 font-semibold hover:text-primary-300 underline underline-offset-2 transition-colors"
>
@@ -654,9 +670,7 @@ const Landing = () => {
</div>
{/* Reason #4 - Large Box */}
<div
className="xl:col-span-8 bg-[#28282C] rounded-2xl sm:rounded-3xl shadow-2xl transition-all duration-300 group overflow-hidden"
>
<div className="xl:col-span-8 bg-[#28282C] rounded-2xl sm:rounded-3xl shadow-2xl transition-all duration-300 group overflow-hidden">
<div className="lg:flex-1 flex flex-col justify-between">
<div className="p-6 lg:p-8">
<h3 className="font-bold text-xl sm:text-2xl lg:text-3xl mb-3 sm:mb-4 leading-tight">
@@ -679,9 +693,7 @@ const Landing = () => {
</div>
{/* Reason #5 - Full Width Box */}
<div
className="xl:col-span-12 md:col-span-2 bg-[#28282C] rounded-2xl sm:rounded-3xl p-6 lg:p-8 shadow-2xl transition-all duration-300 group"
>
<div className="xl:col-span-12 md:col-span-2 bg-[#28282C] rounded-2xl sm:rounded-3xl p-6 lg:p-8 shadow-2xl transition-all duration-300 group">
<div className="flex flex-col lg:flex-row gap-4 lg:gap-8 items-center">
<div className="lg:flex-1">
<h3 className="font-bold text-xl sm:text-2xl lg:text-3xl mb-3 sm:mb-4 leading-tight">
@@ -723,16 +735,12 @@ const Landing = () => {
</div>
</div>
</div>
<div className="relative z-10 mx-auto w-full max-w-screen py-10 sm:py-20 md:py-32 sm:px-8 lg:px-32 overflow-visible px-4">
{/* Gradient Orb for Section */}
<div className="absolute left-[-250px] top-[-150px] w-[700px] h-[700px] rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-blue-700 opacity-[0.07] blur-[100px] md:blur-[150px] z-0 pointer-events-none transform-gpu" />
{/* Videos Section */}
<div
className="space-y-12"
data-animate
>
<div className="space-y-12" data-animate>
<div className="text-center">
<h1 className="md:mb-8 text-3xl sm:text-4xl md:text-6xl font-bold mb-4">
Made using{" "}
@@ -741,8 +749,8 @@ const Landing = () => {
</span>
</h1>
<p className="md:text-xl text-lg text-white/80 max-w-2xl mx-auto">
Check out the videos below!
</p>
Check out the videos below!
</p>
</div>
<div className="grid gap-6 sm:gap-8 xl:grid-cols-3">
{videos.map((video, index) => (
@@ -750,31 +758,37 @@ const Landing = () => {
key={index}
className="group relative rounded-2xl bg-white/10 backdrop-blur-md p-2 sm:p-4 transition-all duration-300 hover:bg-white/20 hover:scale-[1.02] hover:shadow-2xl hover:shadow-primary/20"
>
<div className="aspect-video overflow-hidden rounded-xl shadow-lg w-full relative group-hover:cursor-pointer" onClick={() => setActiveVideo(index)}>
<div
className="aspect-video overflow-hidden rounded-xl shadow-lg w-full relative group-hover:cursor-pointer"
onClick={() => setActiveVideo(index)}
>
{activeVideo === index ? (
<iframe
width="100%"
height="100%"
src={video.src + "&autoplay=1"}
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="w-full h-full min-h-[180px]"
style={{ minHeight: "180px" }}
/>
<iframe
width="100%"
height="100%"
src={video.src + "&autoplay=1"}
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="w-full h-full min-h-[180px]"
style={{ minHeight: "180px" }}
/>
) : (
<div className="w-full h-full bg-black/50 flex items-center justify-center relative">
{/* Placeholder Thumbnail - In a real app we'd fetch the YT thumb */}
<div className="absolute inset-0 bg-[#28282C]">
<img
src={`https://img.youtube.com/vi/${video.src.split('/').pop()?.split('?')[0]}/maxresdefault.jpg`}
alt="Video Thumbnail"
className="w-full h-full object-cover opacity-60 group-hover:opacity-80 transition-opacity duration-300"
/>
</div>
<div className="w-16 h-16 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center border border-white/20 group-hover:scale-110 transition-transform duration-300 z-10">
<FontAwesomeIcon icon={faFilm} className="text-white text-2xl" />
</div>
{/* Placeholder Thumbnail - In a real app we'd fetch the YT thumb */}
<div className="absolute inset-0 bg-[#28282C]">
<img
src={`https://img.youtube.com/vi/${video.src.split("/").pop()?.split("?")[0]}/maxresdefault.jpg`}
alt="Video Thumbnail"
className="w-full h-full object-cover opacity-60 group-hover:opacity-80 transition-opacity duration-300"
/>
</div>
<div className="w-16 h-16 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center border border-white/20 group-hover:scale-110 transition-transform duration-300 z-10">
<FontAwesomeIcon
icon={faFilm}
className="text-white text-2xl"
/>
</div>
</div>
)}
</div>
@@ -787,79 +801,80 @@ const Landing = () => {
<div className="relative z-10 mx-auto w-full max-w-[1400px] pt-10 sm:pt-20 md:pt-32 pb-12 sm:pb-24 md:pb-40 lg:pb-56 sm:px-8 overflow-visible px-4">
{/* Gradient Orb for Section */}
<div className="absolute right-[-300px] bottom-[-100px] w-[800px] h-[800px] rounded-full bg-gradient-to-br from-blue-700 via-[#00AABA] to-pink-500 opacity-10 blur-[160px] z-0 pointer-events-none" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
{/* Discord CTA Section */}
<div
className="flex flex-col items-center justify-center text-center p-8 sm:p-12 lg:p-16 rounded-[24px] sm:rounded-[40px] bg-[#1a1b36] backdrop-blur-lg border-[3px] sm:border-[6px] border-[#3f415c] shadow-2xl"
data-animate
>
<h2 className="relative mb-4 sm:mb-6 font-bold text-2xl sm:text-3xl lg:text-4xl max-w-lg !leading-tight text-white">
Join the Discord
</h2>
<p className="text-base sm:text-lg text-[#aab3c9] mb-6 sm:mb-8 max-w-md leading-relaxed">
Connect with other creators, share your work, get feedback, and stay updated.
</p>
<DiscordButton />
</div>
{/* Discord CTA Section */}
<div
className="flex flex-col items-center justify-center text-center p-8 sm:p-12 lg:p-16 rounded-[24px] sm:rounded-[40px] bg-[#1a1b36] backdrop-blur-lg border-[3px] sm:border-[6px] border-[#3f415c] shadow-2xl"
data-animate
>
<h2 className="relative mb-4 sm:mb-6 font-bold text-2xl sm:text-3xl lg:text-4xl max-w-lg !leading-tight text-white">
Join the Discord
</h2>
<p className="text-base sm:text-lg text-[#aab3c9] mb-6 sm:mb-8 max-w-md leading-relaxed">
Connect with other creators, share your work, get feedback, and
stay updated.
</p>
<DiscordButton />
</div>
{/* GitHub CTA Section */}
<div
className="flex flex-col items-center justify-center text-center p-8 sm:p-12 rounded-[24px] sm:rounded-[40px] bg-[#161b22] backdrop-blur-lg border-[3px] sm:border-[6px] border-[#30363d] shadow-2xl"
data-animate
{/* GitHub CTA Section */}
<div
className="flex flex-col items-center justify-center text-center p-8 sm:p-12 rounded-[24px] sm:rounded-[40px] bg-[#161b22] backdrop-blur-lg border-[3px] sm:border-[6px] border-[#30363d] shadow-2xl"
data-animate
>
<h2 className="relative mb-4 sm:mb-6 font-bold text-2xl sm:text-3xl lg:text-4xl max-w-lg !leading-tight text-white">
We're Open Source
</h2>
<p className="text-base sm:text-lg text-[#8b949e] mb-6 sm:mb-8 max-w-md leading-relaxed">
Help us build the future of creative tools. Contribute code,
report bugs, or just star the repo.
</p>
<Button
className="text-md px-6 py-3 font-semibold rounded-xl shadow-lg gap-3 bg-white text-black hover:bg-gray-200"
as="link"
href="https://github.com/storytold/artcraft"
target="_blank"
rel="noopener noreferrer"
>
<h2 className="relative mb-4 sm:mb-6 font-bold text-2xl sm:text-3xl lg:text-4xl max-w-lg !leading-tight text-white">
We're Open Source
</h2>
<p className="text-base sm:text-lg text-[#8b949e] mb-6 sm:mb-8 max-w-md leading-relaxed">
Help us build the future of creative tools. Contribute code, report bugs, or just star the repo.
</p>
<Button
className="text-md px-6 py-3 font-semibold rounded-xl shadow-lg gap-3 bg-white text-black hover:bg-gray-200"
as="link"
href="https://github.com/storytold/artcraft"
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon icon={faGithub} className="text-xl"/>
Star on GitHub
</Button>
</div>
<FontAwesomeIcon icon={faGithub} className="text-xl" />
Star on GitHub
</Button>
</div>
{/* Download CTA Section - New */}
<div
className="flex flex-col items-center justify-center text-center p-8 sm:p-12 lg:col-span-2 rounded-[24px] sm:rounded-[40px] bg-gradient-to-br from-primary/30 to-primary/50 backdrop-blur-lg border-[3px] sm:border-[6px] border-primary/70 shadow-2xl shadow-primary/30 overflow-hidden relative"
data-animate
>
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-purple-500/20 opacity-30 pointer-events-none" />
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
{/* Download CTA Section - New */}
<div
className="flex flex-col items-center justify-center text-center p-8 sm:p-12 lg:col-span-2 rounded-[24px] sm:rounded-[40px] bg-gradient-to-br from-primary/30 to-primary/50 backdrop-blur-lg border-[3px] sm:border-[6px] border-primary/70 shadow-2xl shadow-primary/30 overflow-hidden relative"
data-animate
>
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-purple-500/20 opacity-30 pointer-events-none" />
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
<h2 className="relative mb-4 sm:mb-6 font-bold text-3xl sm:text-4xl lg:text-5xl max-w-2xl !leading-tight text-white z-10">
Ready to Create?
</h2>
<p className="text-base sm:text-l md:text-xl text-white/90 mb-8 sm:mb-10 max-w-xl leading-relaxed z-10">
Join thousands of artists and filmmakers using ArtCraft to bring their vision to life.
</p>
<div className="relative z-10">
<Button
className="text-md px-10 py-5 text-xl font-semibold rounded-2xl shadow-xl gap-3 transition-all duration-300 hover:scale-105 hover:shadow-primary/40 bg-white text-black hover:bg-white"
as="link"
href={downloadUrl}
onClick={onDownloadClick}
>
<FontAwesomeIcon icon={isMacOs ? faApple : faWindows} />
Download for {isMacOs ? "Mac" : "Windows"}
</Button>
<div className="mt-4 text-sm text-white/80 font-medium">Free to use.</div>
<div className="text-xs text-white/80 mt-1 font-medium">Unlimited Nano Banana Pro for a limited time!</div>
<h2 className="relative mb-4 sm:mb-6 font-bold text-3xl sm:text-4xl lg:text-5xl max-w-2xl !leading-tight text-white z-10">
Ready to Create?
</h2>
<p className="text-base sm:text-l md:text-xl text-white/90 mb-8 sm:mb-10 max-w-xl leading-relaxed z-10">
Join thousands of artists and filmmakers using ArtCraft to bring
their vision to life.
</p>
<div className="relative z-10 w-full flex flex-col items-center">
<LandingActionButtons onDownloadClick={onDownloadClick} />
<div className="mt-4 text-sm text-white/80 font-medium">
Free to use.
</div>
<div className="text-xs text-white/80 mt-1 font-medium">
Unlimited Nano Banana Pro for a limited time!
</div>
</div>
</div>
</div>
</div>
<DownloadModal isOpen={showDownloadModal} onClose={() => setShowDownloadModal(false)} />
<DownloadModal
isOpen={showDownloadModal}
onClose={() => setShowDownloadModal(false)}
/>
<Footer />
</div>
);
@@ -0,0 +1 @@
export { default } from "./onboarding";
@@ -0,0 +1,512 @@
import { useState, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "@storyteller/ui-button";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faUser,
faEnvelope,
faLock,
faCheckCircle,
faExclamationCircle,
faInfoCircle,
} from "@fortawesome/pro-solid-svg-icons";
import Seo from "../../components/seo";
import { UsersApi, BillingApi } from "@storyteller/api";
interface OnboardingData {
email_not_set: boolean;
email_not_confirmed: boolean;
password_not_set: boolean;
username_not_customized: boolean;
}
const Onboarding = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get("redirect_to") || "/checkout/success";
const [onboardingData, setOnboardingData] = useState<OnboardingData | null>(
null,
);
const [isLoading, setIsLoading] = useState(true);
const [currentStep, setCurrentStep] = useState<
"email" | "password" | "username" | "complete"
>("email");
const [isNewAccount, setIsNewAccount] = useState(false);
// Form state
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [username, setUsername] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
checkOnboardingStatus();
}, []);
const getSuccessUrl = (afterUsernameStep = false) => {
// If redirecting to success, append parameter
const decodedRedirect = decodeURIComponent(redirectTo);
if (decodedRedirect.includes("/checkout/success")) {
return `${decodedRedirect}?onboarding_complete=true`;
}
// If redirecting to checkout/cancel and we've completed username step, add skip_onboarding
if (decodedRedirect.includes("/checkout/cancel") && afterUsernameStep) {
const separator = decodedRedirect.includes("?") ? "&" : "?";
return `${decodedRedirect}${separator}skip_onboarding=true`;
}
return decodedRedirect;
};
const getRedirectMessage = () => {
const decodedRedirect = decodeURIComponent(redirectTo);
if (decodedRedirect === "/") return "Redirecting you to the home page...";
if (decodedRedirect === "/pricing")
return "Redirecting you to view plans...";
if (decodedRedirect.includes("success"))
return "Redirecting you to your download...";
return "Redirecting you...";
};
const handleCompletion = async (afterUsernameStep = false) => {
const targetUrl = getSuccessUrl(afterUsernameStep);
// If heading to success/download, verify subscription first
if (targetUrl.includes("success") || targetUrl.includes("download")) {
try {
const billingApi = new BillingApi();
const subResponse = await billingApi.ListActiveSubscriptions();
if (subResponse.success && subResponse.data) {
const hasActiveSub = subResponse.data.active_subscriptions.length > 0;
if (!hasActiveSub) {
navigate("/pricing");
return;
}
}
} catch (e) {}
}
// Determine step complete immediately
setCurrentStep("complete");
setTimeout(() => navigate(targetUrl), 2000);
};
const checkOnboardingStatus = async () => {
try {
const usersApi = new UsersApi();
const sessionResponse = await usersApi.GetSession();
if (!sessionResponse.success || !sessionResponse.data?.loggedIn) {
navigate("/");
return;
}
const onboarding = sessionResponse.data.onboarding;
// If onboarding is undefined, assume full onboarding is needed (new account)
if (!onboarding) {
setOnboardingData({
email_not_set: true,
email_not_confirmed: true,
password_not_set: true,
username_not_customized: true,
});
setCurrentStep("email");
setIsNewAccount(true);
setIsLoading(false);
return;
}
// If all onboarding fields are false, no onboarding needed
if (
!onboarding.password_not_set &&
!onboarding.email_not_set &&
!onboarding.email_not_confirmed &&
!onboarding.username_not_customized
) {
await handleCompletion();
return;
}
setOnboardingData(onboarding);
// Determine if this looks like a new account that needs setup
if (onboarding.password_not_set) {
setIsNewAccount(true);
}
// Determine the first step needed - prioritize email, then password, then username
if (onboarding.email_not_set) {
setCurrentStep("email");
} else if (onboarding.password_not_set) {
setCurrentStep("password");
} else if (onboarding.username_not_customized) {
setCurrentStep("username");
} else {
// All done!
await handleCompletion();
}
} catch (err) {
// console.error("❌ Error checking onboarding status:", err);
navigate(getSuccessUrl());
} finally {
setIsLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsSubmitting(true);
try {
const usersApi = new UsersApi();
if (currentStep === "email") {
const response = await usersApi.EditEmail({
emailAddress: email,
});
if (!response.success) {
const errorMsg = response.errorMessage || "Failed to set email";
throw new Error(errorMsg);
}
// Re-fetch session to get updated onboarding state
const sessionResponse = await usersApi.GetSession();
if (sessionResponse.success && sessionResponse.data?.onboarding) {
const updatedOnboarding = sessionResponse.data.onboarding;
setOnboardingData(updatedOnboarding);
// Determine next step based on actual backend state
if (updatedOnboarding.password_not_set) {
setCurrentStep("password");
} else if (updatedOnboarding.username_not_customized) {
setCurrentStep("username");
} else {
await handleCompletion();
}
} else {
// Fallback to old logic if session fetch fails
if (onboardingData?.password_not_set) {
setCurrentStep("password");
} else if (onboardingData?.username_not_customized) {
setCurrentStep("username");
} else {
await handleCompletion();
}
}
setEmail("");
} else if (currentStep === "password") {
if (password !== passwordConfirmation) {
setError("Passwords do not match");
setIsSubmitting(false);
return;
}
const response = await usersApi.ChangePassword({
password,
passwordConfirmation,
});
if (!response.success) {
const errorMsg = response.errorMessage || "Failed to set password";
throw new Error(errorMsg);
}
// Re-fetch session to get updated onboarding state
const sessionResponse = await usersApi.GetSession();
if (sessionResponse.success && sessionResponse.data?.onboarding) {
const updatedOnboarding = sessionResponse.data.onboarding;
setOnboardingData(updatedOnboarding);
// Determine next step based on actual backend state
if (updatedOnboarding.username_not_customized) {
setCurrentStep("username");
} else {
await handleCompletion();
}
} else {
// Fallback to old logic if session fetch fails
if (onboardingData?.username_not_customized) {
setCurrentStep("username");
} else {
await handleCompletion();
}
}
setPassword("");
setPasswordConfirmation("");
} else if (currentStep === "username") {
const response = await usersApi.EditUsername({
displayName: username,
});
if (!response.success) {
const errorMsg = response.errorMessage || "Failed to set username";
throw new Error(errorMsg);
}
await handleCompletion(true);
}
} catch (err) {
const errorMessage =
err instanceof Error
? err.message
: "An error occurred. Please try again.";
setError(errorMessage);
} finally {
setIsSubmitting(false);
}
};
const handleSkip = async () => {
if (currentStep === "username") {
// Username is low priority, can skip
await handleCompletion(true);
}
};
if (isLoading) {
return (
<div className="relative min-h-screen bg-[#101014] text-white flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
</div>
);
}
if (currentStep === "complete") {
return (
<div className="relative min-h-screen bg-[#101014] text-white">
<Seo
title="Setup Complete - ArtCraft"
description="Your account setup is complete."
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-green-500/40 via-primary/30 to-purple-600/20 opacity-40 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-28 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
<div className="max-w-md w-full text-center">
<div className="mb-6">
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/20 flex items-center justify-center">
<FontAwesomeIcon
icon={faCheckCircle}
className="text-5xl text-green-400"
/>
</div>
</div>
<h1 className="text-3xl md:text-4xl font-bold mb-4 text-white">
All Set!
</h1>
<p className="text-lg text-white/70 mb-8">{getRedirectMessage()}</p>
</div>
</main>
</div>
);
}
return (
<div className="relative min-h-screen bg-[#101014] text-white">
<Seo
title="Complete Your Setup - ArtCraft"
description="Just a few more details to get you started with ArtCraft."
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-28 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
<div className="max-w-md w-full">
{isNewAccount && (
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-4 mb-6 flex items-start gap-3 animate-fade-in shadow-lg shadow-blue-900/10">
<FontAwesomeIcon
icon={faInfoCircle}
className="text-blue-400 mt-1 flex-shrink-0 text-lg"
/>
<div>
<h3 className="text-blue-200 font-semibold mb-1">
Account Created
</h3>
<p className="text-blue-300/80 text-sm leading-relaxed">
We've automatically created an account for you. Please
complete your setup below to secure it.
</p>
</div>
</div>
)}
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12">
<div className="text-center mb-8">
<h1 className="text-3xl md:text-3xl font-bold mb-4 text-white">
{currentStep === "password" &&
(isNewAccount ? "Secure Your Account" : "Set Your Password")}
{currentStep === "email" &&
(isNewAccount
? "Complete Your Profile"
: "Verify Your Email")}
{currentStep === "username" && "Choose A Username"}
</h1>
<p className="text-white/70">
{currentStep === "password" &&
"Create a password to access your account across devices."}
{currentStep === "email" &&
(isNewAccount
? "Enter your email address to secure your account and receive important updates."
: "Please provide a valid email address for your account updates.")}
{currentStep === "username" &&
"Pick a unique display name for the community (optional)."}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{currentStep === "password" && (
<>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-white/80 mb-2"
>
<FontAwesomeIcon icon={faLock} className="mr-2" />
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-3 bg-[#252529] border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-primary transition-colors"
placeholder="Enter your password"
minLength={8}
/>
</div>
<div>
<label
htmlFor="passwordConfirmation"
className="block text-sm font-medium text-white/80 mb-2"
>
<FontAwesomeIcon icon={faLock} className="mr-2" />
Confirm Password
</label>
<input
type="password"
id="passwordConfirmation"
value={passwordConfirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)}
required
className="w-full px-4 py-3 bg-[#252529] border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-primary transition-colors"
placeholder="Confirm your password"
minLength={8}
/>
</div>
</>
)}
{currentStep === "email" && (
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-white/80 mb-2"
>
<FontAwesomeIcon icon={faEnvelope} className="mr-2" />
Email Address
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 bg-[#252529] border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-primary transition-colors"
placeholder="your@email.com"
/>
</div>
)}
{currentStep === "username" && (
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-white/80 mb-2"
>
<FontAwesomeIcon icon={faUser} className="mr-2" />
Username
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-3 bg-[#252529] border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-primary transition-colors"
placeholder="Your display name"
/>
</div>
)}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 text-red-400 text-sm flex items-start gap-3">
<FontAwesomeIcon
icon={faExclamationCircle}
className="text-red-400 mt-0.5 flex-shrink-0"
/>
<span className="flex-1">{error}</span>
</div>
)}
<div className="flex gap-3">
<Button
type="submit"
className="flex-1 bg-primary hover:bg-primary-600 px-6 py-3 text-sm font-bold rounded-xl justify-center"
disabled={isSubmitting}
>
{isSubmitting ? (
<span className="flex items-center gap-2">
<svg
className="animate-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Saving...
</span>
) : (
"Continue"
)}
</Button>
{currentStep === "username" && (
<Button
type="button"
onClick={handleSkip}
className="bg-white/10 hover:bg-white/20 px-6 py-3 text-sm font-bold rounded-xl justify-center"
>
Skip
</Button>
)}
</div>
</form>
</div>
</div>
</main>
</div>
);
};
export default Onboarding;
@@ -35,3 +35,52 @@
background-size: 33px 33px;
@apply bg-center;
}
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes rotate {
0% {
--angle: 0deg;
}
100% {
--angle: 360deg;
}
}
.glow-border-animated {
position: relative;
overflow: hidden;
}
.glow-border-animated::before {
content: "";
position: absolute;
inset: -3px;
background: conic-gradient(
from var(--angle),
transparent 0%,
transparent 80%,
rgba(147, 197, 253, 0.4) 85%,
rgba(191, 219, 254, 0.9) 90%,
rgba(255, 255, 255, 1) 92.5%,
rgba(191, 219, 254, 0.9) 95%,
rgba(147, 197, 253, 0.4) 100%,
transparent 100%
);
border-radius: inherit;
animation: rotate 2.2s linear infinite;
z-index: -1;
}
.glow-border-animated::after {
content: "";
position: absolute;
inset: 2px;
background: inherit;
border-radius: inherit;
z-index: -1;
}
+145
View File
@@ -30,4 +30,149 @@ export class BillingApi extends ApiManager {
};
});
}
public async UserSignupSubscriptionCheckout({
plan,
cadence,
}: {
plan: string;
cadence: "yearly" | "monthly";
}): Promise<ApiResponse<{ stripeCheckoutRedirectUrl: string }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/stripe_artcraft/user_signup_subscription_checkout`;
return await this.post<
{ plan: string; cadence: string },
{
success: boolean;
stripe_checkout_redirect_url?: string;
error_message?: string;
}
>({
endpoint: endpoint,
body: {
plan,
cadence,
},
})
.then((response) => ({
success: response.success,
data: response.stripe_checkout_redirect_url
? { stripeCheckoutRedirectUrl: response.stripe_checkout_redirect_url }
: undefined,
errorMessage: response.error_message,
}))
.catch((err) => {
return {
success: false,
errorMessage: err.message,
};
});
}
public async SubscriptionCheckout({
plan,
cadence,
}: {
plan: string;
cadence: "yearly" | "monthly";
}): Promise<ApiResponse<{ stripeCheckoutRedirectUrl: string }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/stripe_artcraft/checkout/subscription`;
return await this.post<
{ plan: string; cadence: string },
{
success: boolean;
stripe_checkout_redirect_url?: string;
error_message?: string;
}
>({
endpoint: endpoint,
body: {
plan,
cadence,
},
})
.then((response) => ({
success: response.success,
data: response.stripe_checkout_redirect_url
? { stripeCheckoutRedirectUrl: response.stripe_checkout_redirect_url }
: undefined,
errorMessage: response.error_message,
}))
.catch((err) => {
return {
success: false,
errorMessage: err.message,
};
});
}
public async SwitchPlan({
plan,
cadence,
}: {
plan: string;
cadence: "yearly" | "monthly";
}): Promise<ApiResponse<{ stripePortalUrl: string }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/stripe_artcraft/portal/switch_plan`;
return await this.post<
{ plan: string; cadence: string },
{
success: boolean;
stripe_portal_url?: string;
error_message?: string;
}
>({
endpoint: endpoint,
body: {
plan,
cadence,
},
})
.then((response) => ({
success: response.success,
data: response.stripe_portal_url
? { stripePortalUrl: response.stripe_portal_url }
: undefined,
errorMessage: response.error_message,
}))
.catch((err) => {
return {
success: false,
errorMessage: err.message,
};
});
}
public async GetPortalUrl(): Promise<
ApiResponse<{ stripePortalUrl: string }>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/stripe_artcraft/portal/manage_plan`;
return await this.post<
Record<string, never>,
{
success: boolean;
stripe_portal_url?: string;
error_message?: string;
}
>({
endpoint: endpoint,
body: {},
})
.then((response) => ({
success: response.success,
data: response.stripe_portal_url
? { stripePortalUrl: response.stripe_portal_url }
: undefined,
errorMessage: response.error_message,
}))
.catch((err) => {
return {
success: false,
errorMessage: err.message,
};
});
}
}
+120 -1
View File
@@ -47,13 +47,26 @@ export class UsersApi extends ApiManager {
ApiResponse<{
loggedIn: boolean;
user?: UserInfo;
onboarding?: {
email_not_set: boolean;
email_not_confirmed: boolean;
password_not_set: boolean;
username_not_customized: boolean;
};
}>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/session`;
return this.get<{
success: boolean;
logged_in: boolean;
user?: UserInfo;
user?: UserInfo & {
onboarding?: {
email_not_set: boolean;
email_not_confirmed: boolean;
password_not_set: boolean;
username_not_customized: boolean;
};
};
error_message?: string;
}>({ endpoint: endpoint })
.then((response) => ({
@@ -61,6 +74,7 @@ export class UsersApi extends ApiManager {
data: {
loggedIn: response.logged_in,
user: response.user,
onboarding: response.user?.onboarding,
},
}))
.catch((err) => {
@@ -249,6 +263,111 @@ export class UsersApi extends ApiManager {
};
}
}
public async ChangePassword({
password,
passwordConfirmation,
}: {
password: string;
passwordConfirmation: string;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/user/change_password`;
const body = {
password,
password_confirmation: passwordConfirmation,
};
try {
const response = await this.authFetch<
{ password: string; password_confirmation: string },
{
success: boolean;
error_message?: string;
}
>(endpoint, {
method: "POST",
body: body,
});
return {
success: response.success,
errorMessage: response.error_message,
};
} catch (err: any) {
return {
success: false,
errorMessage: err.message,
};
}
}
public async EditEmail({
emailAddress,
}: {
emailAddress: string;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/user/edit_email`;
const body = {
email_address: emailAddress,
};
try {
const response = await this.authFetch<
{ email_address: string },
{
success: boolean;
error_message?: string;
}
>(endpoint, {
method: "POST",
body: body,
});
return {
success: response.success,
errorMessage: response.error_message,
};
} catch (err: any) {
return {
success: false,
errorMessage: err.message,
};
}
}
public async EditUsername({
displayName,
}: {
displayName: string;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/user/edit_username`;
const body = {
display_name: displayName,
};
try {
const response = await this.authFetch<
{ display_name: string },
{
success: boolean;
error_message?: string;
}
>(endpoint, {
method: "POST",
body: body,
});
return {
success: response.success,
errorMessage: response.error_message,
};
} catch (err: any) {
return {
success: false,
errorMessage: err.message,
};
}
}
}
(window as any).UsersApi = new UsersApi();