diff --git a/frontend/apps/artcraft-website/_redirects b/frontend/apps/artcraft-website/_redirects index 8498c25070..532126700a 100644 --- a/frontend/apps/artcraft-website/_redirects +++ b/frontend/apps/artcraft-website/_redirects @@ -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 diff --git a/frontend/apps/artcraft-website/public/images/buy-credits.png b/frontend/apps/artcraft-website/public/images/buy-credits.png new file mode 100644 index 0000000000..f6bd102d28 Binary files /dev/null and b/frontend/apps/artcraft-website/public/images/buy-credits.png differ diff --git a/frontend/apps/artcraft-website/public/images/try-download.png b/frontend/apps/artcraft-website/public/images/try-download.png new file mode 100644 index 0000000000..9130f225c4 Binary files /dev/null and b/frontend/apps/artcraft-website/public/images/try-download.png differ diff --git a/frontend/apps/artcraft-website/src/app/app.tsx b/frontend/apps/artcraft-website/src/app/app.tsx index dafdede0d9..6c4e66fb9a 100644 --- a/frontend/apps/artcraft-website/src/app/app.tsx +++ b/frontend/apps/artcraft-website/src/app/app.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> {/* Redirects for underscore-based URLs (legacy Stripe config) */} @@ -50,6 +52,10 @@ export function App() { path="/checkout_cancel" element={} /> + } + /> ); diff --git a/frontend/apps/artcraft-website/src/components/landing-action-buttons.tsx b/frontend/apps/artcraft-website/src/components/landing-action-buttons.tsx new file mode 100644 index 0000000000..dba0ba073a --- /dev/null +++ b/frontend/apps/artcraft-website/src/components/landing-action-buttons.tsx @@ -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 ( +
+ {isMobile ? ( + + ) : ( + <> + + + + )} +
+ ); +}; diff --git a/frontend/apps/artcraft-website/src/components/pricing-table/pricing-table.tsx b/frontend/apps/artcraft-website/src/components/pricing-table/pricing-table.tsx index 40d66e3599..5f3b85f285 100644 --- a/frontend/apps/artcraft-website/src/components/pricing-table/pricing-table.tsx +++ b/frontend/apps/artcraft-website/src/components/pricing-table/pricing-table.tsx @@ -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(null); const [isLoading, setIsLoading] = useState(true); const [processingPlan, setProcessingPlan] = useState(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 = ({ ); })} + + {/* Manage Plan Button - Only shown if user has active subscription */} + {activePlanSlug && activePlanSlug !== "free" && ( +
+ +
+ )} ); }; diff --git a/frontend/apps/artcraft-website/src/pages/checkout/cancel.tsx b/frontend/apps/artcraft-website/src/pages/checkout/cancel.tsx index 95a0b9ef9d..4c812ab148 100644 --- a/frontend/apps/artcraft-website/src/pages/checkout/cancel.tsx +++ b/frontend/apps/artcraft-website/src/pages/checkout/cancel.tsx @@ -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 ( +
+
+
+ ); + } + return (
{ + 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 ( +
+
+
+ ); + } return (
diff --git a/frontend/apps/artcraft-website/src/pages/landing2/landing2.tsx b/frontend/apps/artcraft-website/src/pages/landing2/landing2.tsx index 5d2836a2cc..5f03c0dbd2 100644 --- a/frontend/apps/artcraft-website/src/pages/landing2/landing2.tsx +++ b/frontend/apps/artcraft-website/src/pages/landing2/landing2.tsx @@ -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(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("[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 ( -
+
{ {/* CTA Buttons */}
- {isMobile ? ( - - ) : ( - - )} - Try Free + + Buy Credits + Try Download
{/* Glassy Video Mockup */} @@ -313,21 +319,18 @@ const Landing = () => { onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} > - + ))} +
+ + {/* Mobile Media Preview */} +
+
+
+ + {/* Mobile Description */}
-

- Advanced Crafting Features -

-

- We're Pulling You
Out of Prompting -

-

- Text prompting is neat, but artists crave control. -

- -
- -

- Before you type "rotate arm thirty degrees", try ArtCraft and let our advanced - toolset help you achieve consistency and repeatability. -

- -
- -

- ArtCraft is the control that mere words cannot buy. +

+ {features[activeFeature].title} +

+

+ {features[activeFeature].description}

-
+
-
- {/* Mobile Layout */} -
- {/* Mobile Navigation (Tabs) */} -
+ {/* Desktop Sticky Scroll Layout */} + {!isMobile && ( +
+ {/* Left Column: Scrolling Text */} +
{features.map((feature, index) => ( - +
+
+ +
+

+ {feature.title} +

+

+ {feature.description} +

+
+
))}
- {/* Mobile Media Preview */} -
-
-
- - {/* Mobile Description */} -
-

- {features[activeFeature].title} -

-

- {features[activeFeature].description} -

-
-
- - {/* Desktop Sticky Scroll Layout */} - {!isMobile && ( -
- {/* Left Column: Scrolling Text */} -
+ {/* Right Column: Sticky Image */} +
+
{features.map((feature, index) => (
{ 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%)", + }} > -
-
- -
-

- {feature.title} -

-

- {feature.description} -

-
+
- {/* Right Column: Sticky Image */} -
-
- {features.map((feature, index) => ( -
{ 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%)' }} - > -
- )} -
+
+ )} +
@@ -546,16 +571,11 @@ const Landing = () => { {/* Gradient Orbs for Section */}
-
+
{/* Bento Grid Container */}
{/* Reason #1 - Large Box */} -
+
{
{/* Reason #2 - Medium Box */} -
+

Desktop App @@ -623,18 +641,16 @@ const Landing = () => {

{/* Reason #3 - Medium Box */} -
+

It's Open Source

Our desktop app's code and infrastructure are all{" "} - @@ -654,9 +670,7 @@ const Landing = () => {

{/* Reason #4 - Large Box */} -
+

@@ -679,9 +693,7 @@ const Landing = () => {

{/* Reason #5 - Full Width Box */} -
+

@@ -723,16 +735,12 @@ const Landing = () => {

-
{/* Gradient Orb for Section */}
{/* Videos Section */} -
+

Made using{" "} @@ -741,8 +749,8 @@ const Landing = () => {

- Check out the videos below! -

+ Check out the videos below! +

{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" > -
setActiveVideo(index)}> +
setActiveVideo(index)} + > {activeVideo === index ? ( -