mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 01:12:40 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf2a3ad56f | ||
|
|
7b2b71eaf0 | ||
|
|
4883185ad8 | ||
|
|
a3b9cd71ad |
@@ -1,19 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0"></script>
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-VKW89949V0');
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
gtag("config", "G-VKW89949V0");
|
||||
</script>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<title>ArtCraft. AI Video and Images. Fast and Open Desktop App.</title>
|
||||
<meta name="description" content="ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!" />
|
||||
<meta name="keywords" content="ArtCraft, AI, AI Video, AI Images, Open Source" />
|
||||
<meta
|
||||
name="description"
|
||||
content="ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="ArtCraft, AI, AI Video, AI Images, Open Source"
|
||||
/>
|
||||
|
||||
<base href="/" />
|
||||
|
||||
@@ -23,10 +34,9 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { Route, Routes, Navigate } from "react-router-dom";
|
||||
import Download from "../pages/download";
|
||||
import Media from "../pages/media";
|
||||
import PressKit from "../pages/press-kit";
|
||||
@@ -8,7 +8,14 @@ import TutorialsPage from "../pages/tutorials";
|
||||
import TutorialsArticle from "../pages/tutorials/article";
|
||||
import FaqIndex from "../pages/faq/index";
|
||||
import FaqArticle from "../pages/faq/article";
|
||||
import { NewsIndex, NewsPost } from "@storyteller/markdown-content";
|
||||
import NewsIndex from "../pages/news/news-index";
|
||||
import NewsPost from "../pages/news/news-post";
|
||||
import Pricing from "../pages/pricing";
|
||||
import Login from "../pages/login";
|
||||
import Signup from "../pages/signup";
|
||||
import ForgotPassword from "../pages/forgot-password";
|
||||
import Welcome from "../pages/welcome";
|
||||
import { CheckoutSuccess, CheckoutCancel } from "../pages/checkout";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -27,6 +34,22 @@ export function App() {
|
||||
<Route path="/faq/:slug" element={<FaqArticle />} />
|
||||
<Route path="/news" element={<NewsIndex basePath="/news" />} />
|
||||
<Route path="/news/:slug" element={<NewsPost basePath="/news" />} />
|
||||
<Route path="/pricing" element={<Pricing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/welcome" element={<Welcome />} />
|
||||
<Route path="/checkout/success" element={<CheckoutSuccess />} />
|
||||
<Route path="/checkout/cancel" element={<CheckoutCancel />} />
|
||||
{/* Redirects for underscore-based URLs (legacy Stripe config) */}
|
||||
<Route
|
||||
path="/checkout_success"
|
||||
element={<Navigate to="/checkout/success" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/checkout_cancel"
|
||||
element={<Navigate to="/checkout/cancel" replace />}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./signup-form";
|
||||
@@ -0,0 +1,153 @@
|
||||
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faEye, faEyeSlash, faSpinner } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useState } from "react";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
|
||||
interface SignupFormProps {
|
||||
onSuccess: () => void;
|
||||
signupSource: string;
|
||||
className?: string;
|
||||
showGoogleButton?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export const SignupForm = ({
|
||||
onSuccess,
|
||||
signupSource,
|
||||
className = "",
|
||||
showGoogleButton = true,
|
||||
autoFocus = false,
|
||||
}: SignupFormProps) => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSignup = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!email || !password) {
|
||||
setError("All fields are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const api = new UsersApi();
|
||||
|
||||
// Generate a username from the email
|
||||
const emailPrefix = email.split("@")[0].replace(/[^a-zA-Z0-9]/g, "");
|
||||
// Ensure we don't exceed max length (7 chars from email + 3 digits = 10 total)
|
||||
const truncatedPrefix = emailPrefix.substring(0, 7);
|
||||
const randomSuffix = Math.floor(Math.random() * 900) + 100; // 100-999
|
||||
const generatedUsername = `${truncatedPrefix}${randomSuffix}`;
|
||||
|
||||
const response = await api.Signup({
|
||||
username: generatedUsername,
|
||||
email,
|
||||
password,
|
||||
passwordConfirmation: password,
|
||||
signupSource,
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (response.success) {
|
||||
window.dispatchEvent(new Event("auth-change"));
|
||||
onSuccess();
|
||||
} else {
|
||||
setError(response.errorMessage || "Failed to create account");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{showGoogleButton && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGoogle} className="text-lg" />
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<div className="relative flex items-center justify-center py-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10"></div>
|
||||
</div>
|
||||
<span className="relative bg-[#1C1C20] px-4 text-xs text-white/40 uppercase tracking-widest">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-xl text-sm text-center animate-in fade-in slide-in-from-top-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoFocus={autoFocus}
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSignup()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSignup()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
|
||||
onClick={handleSignup}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
) : (
|
||||
"Create Account"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,7 @@ export const DiscordButton = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`relative z-10 ${sizeClasses} font-semibold transition-all duration-300 shadow-lg hover:shadow-blue-500/25 ${className}`}
|
||||
className={`relative z-10 ${sizeClasses} font-semibold transition-all duration-300 shadow-lg bg-white text-black hover:bg-gray-200 ${className}`}
|
||||
icon={faDiscord}
|
||||
as="link"
|
||||
href={SOCIAL_LINKS.DISCORD}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
|
||||
import {
|
||||
faTimes,
|
||||
faEnvelope,
|
||||
faArrowLeft,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { SignupForm } from "./auth";
|
||||
|
||||
interface DownloadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DownloadModal = ({ isOpen, onClose }: DownloadModalProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [view, setView] = useState<"menu" | "signup">("menu");
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
// Reset state after animation
|
||||
setView("menu");
|
||||
}, 300);
|
||||
document.body.style.overflow = "unset";
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
if (!isVisible && !isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex items-center justify-center px-4 transition-all duration-300 ${
|
||||
isOpen
|
||||
? "opacity-100 backdrop-blur-lg"
|
||||
: "opacity-0 backdrop-blur-none pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/80 transition-opacity duration-300"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div
|
||||
className={`relative w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl transform transition-all duration-300 ${
|
||||
isOpen ? "scale-100 translate-y-0" : "scale-95 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/40 hover:text-white/80 transition-colors z-20"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
|
||||
{view === "signup" && (
|
||||
<button
|
||||
onClick={() => setView("menu")}
|
||||
className="absolute top-4 left-4 w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/40 hover:text-white/80 transition-colors z-20"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-primary/20 rounded-2xl flex items-center justify-center mx-auto mb-6 text-primary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8 animate-bounce"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">
|
||||
Downloading ArtCraft...
|
||||
</h2>
|
||||
<p className="text-white/60 text-sm">
|
||||
Your download has started.
|
||||
<br />
|
||||
While you wait, create an account to store your creations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{view === "menu" ? (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
className="w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
|
||||
onClick={() => {}} // Dummy op
|
||||
>
|
||||
<FontAwesomeIcon icon={faGoogle} className="text-lg" />
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="w-full bg-white/5 hover:bg-white/10 text-white border-white/10 justify-center gap-3 font-medium h-12"
|
||||
onClick={() => setView("signup")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEnvelope} className="text-lg" />
|
||||
Sign up with Email
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<SignupForm
|
||||
onSuccess={() => {
|
||||
onClose();
|
||||
navigate("/welcome");
|
||||
}}
|
||||
signupSource="artcraft"
|
||||
showGoogleButton={false}
|
||||
autoFocus={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-8 text-center pt-6 border-t border-white/5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/40 text-xs hover:text-white/60 transition-colors font-medium"
|
||||
>
|
||||
I already have an account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./navbar";
|
||||
export * from "./footer";
|
||||
export * from "./download-button";
|
||||
export * from "./pricing-table";
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
import {
|
||||
Disclosure,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
Transition,
|
||||
} from "@headlessui/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, Fragment } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { DiscordButton } from "../discord-button";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { UsersApi, UserInfo } from "@storyteller/api";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ name: "Home", href: "/" },
|
||||
@@ -10,12 +18,51 @@ const NAV_ITEMS = [
|
||||
{ name: "News", href: "/news" },
|
||||
{ name: "FAQ", href: "/faq" },
|
||||
{ name: "Press Kit", href: "/press-kit" },
|
||||
{ name: "Download", href: "/download" },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const location = useLocation();
|
||||
// Underline hover handled purely via CSS
|
||||
const [user, setUser] = useState<UserInfo | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Check session on mount
|
||||
// Check session on mount and when auth changes or location changes
|
||||
useEffect(() => {
|
||||
const checkSession = async () => {
|
||||
const api = new UsersApi();
|
||||
const response = await api.GetSession();
|
||||
if (
|
||||
response.success &&
|
||||
response.data &&
|
||||
response.data.loggedIn &&
|
||||
response.data.user
|
||||
) {
|
||||
setUser(response.data.user);
|
||||
} else {
|
||||
setUser(undefined);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
checkSession();
|
||||
|
||||
const handleAuthChange = () => {
|
||||
setIsLoading(true);
|
||||
checkSession();
|
||||
};
|
||||
|
||||
window.addEventListener("auth-change", handleAuthChange);
|
||||
return () => window.removeEventListener("auth-change", handleAuthChange);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
const api = new UsersApi();
|
||||
await api.Logout();
|
||||
// Reload to clear state/cache
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -89,21 +136,102 @@ export default function Navbar() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center">
|
||||
{/* <Button as="link" href="/download">
|
||||
Download
|
||||
</Button> */}
|
||||
<DiscordButton
|
||||
small
|
||||
className="bg-white text-black hover:bg-white/90"
|
||||
/>
|
||||
</div>
|
||||
<div className="-ml-2 flex items-center md:hidden">
|
||||
{/* Mobile menu button */}
|
||||
<DiscordButton
|
||||
className="text-sm bg-white text-black hover:bg-white/90"
|
||||
small
|
||||
/>
|
||||
{isLoading ? (
|
||||
// Loading placeholder
|
||||
<div className="hidden md:ml-4 md:flex items-center gap-6 opacity-0"></div>
|
||||
) : user ? (
|
||||
// Logged In State
|
||||
<div className="hidden md:ml-4 md:flex items-center gap-6">
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
<Menu as="div" className="relative ml-3">
|
||||
<div>
|
||||
<MenuButton className="flex rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<img
|
||||
className="h-8 w-8 rounded-full border border-white/10"
|
||||
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
|
||||
alt=""
|
||||
/>
|
||||
</MenuButton>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
modal={false}
|
||||
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-[#1C1C20] py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none border border-white/10"
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-white/10">
|
||||
<p className="text-sm text-white font-semibold truncate">
|
||||
{user.display_name || user.username}
|
||||
</p>
|
||||
</div>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={twMerge(
|
||||
active ? "bg-white/5" : "",
|
||||
"block w-full text-left px-4 py-2 text-sm text-white/70",
|
||||
)}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
) : (
|
||||
// Logged Out State
|
||||
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center gap-6">
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
<Button
|
||||
as="link"
|
||||
href="/signup"
|
||||
className="bg-white text-black hover:bg-white/90 text-sm font-semibold px-4 py-2 h-auto rounded-lg shadow-md"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Button - simplified for now */}
|
||||
<div className="-ml-2 flex items-center md:hidden gap-3">
|
||||
{!user && (
|
||||
<Button
|
||||
as="link"
|
||||
href="/signup"
|
||||
className="bg-white text-black hover:bg-white/90 text-xs font-semibold px-3 py-1.5 h-auto rounded-lg"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
)}
|
||||
{user && (
|
||||
<img
|
||||
className="h-8 w-8 rounded-full border border-white/10"
|
||||
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as PricingTable } from "./pricing-table";
|
||||
@@ -0,0 +1,412 @@
|
||||
import { faCheck } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import {
|
||||
SUBSCRIPTION_PLANS,
|
||||
SubscriptionPlanDetails,
|
||||
} from "@storyteller/subscription";
|
||||
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 { useNavigate } from "react-router-dom";
|
||||
|
||||
const billingTabs = [
|
||||
{ id: "yearly", label: "Yearly" },
|
||||
{ id: "monthly", label: "Monthly" },
|
||||
];
|
||||
|
||||
// Mapping from our plan slugs to API plan slugs
|
||||
const PLAN_SLUG_MAP: Record<string, string> = {
|
||||
artcraft_basic: "artcraft_basic",
|
||||
artcraft_pro: "artcraft_pro",
|
||||
artcraft_max: "artcraft_max",
|
||||
};
|
||||
|
||||
interface PricingTableProps {
|
||||
includeFree?: boolean;
|
||||
showHeader?: boolean;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const PricingTable = ({
|
||||
includeFree = false,
|
||||
showHeader = true,
|
||||
title = "Choose Your Plan",
|
||||
subtitle = "Support open-source development. Your subscription keeps ArtCraft free and open for everyone.",
|
||||
className = "",
|
||||
compact = false,
|
||||
}: PricingTableProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [billingType, setBillingType] = useState("yearly");
|
||||
const isYearly = billingType === "yearly";
|
||||
|
||||
// User and subscription state
|
||||
const [user, setUser] = useState<UserInfo | undefined>(undefined);
|
||||
const [activePlanSlug, setActivePlanSlug] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [processingPlan, setProcessingPlan] = useState<string | null>(null);
|
||||
|
||||
// Fetch user session and subscriptions on mount
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
try {
|
||||
const usersApi = new UsersApi();
|
||||
const sessionResponse = await usersApi.GetSession();
|
||||
|
||||
if (
|
||||
sessionResponse.success &&
|
||||
sessionResponse.data?.loggedIn &&
|
||||
sessionResponse.data?.user
|
||||
) {
|
||||
setUser(sessionResponse.data.user);
|
||||
|
||||
// Fetch active subscriptions
|
||||
const billingApi = new BillingApi();
|
||||
const subscriptionsResponse =
|
||||
await billingApi.ListActiveSubscriptions();
|
||||
|
||||
if (
|
||||
subscriptionsResponse.success &&
|
||||
subscriptionsResponse.data?.active_subscriptions
|
||||
) {
|
||||
// Find ArtCraft subscription
|
||||
const artcraftSub =
|
||||
subscriptionsResponse.data.active_subscriptions.find(
|
||||
(sub) => sub.namespace === "artcraft",
|
||||
);
|
||||
if (artcraftSub) {
|
||||
setActivePlanSlug(artcraftSub.product_slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching user data:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, []);
|
||||
|
||||
const plans = includeFree
|
||||
? SUBSCRIPTION_PLANS
|
||||
: SUBSCRIPTION_PLANS.filter((plan) => plan.slug !== "free");
|
||||
|
||||
const getColorSchemeClasses = (
|
||||
colorScheme: SubscriptionPlanDetails["colorScheme"],
|
||||
) => {
|
||||
const baseClasses =
|
||||
"relative rounded-3xl p-6 md:p-8 border flex flex-col transition-all duration-300 backdrop-blur-md";
|
||||
|
||||
switch (colorScheme) {
|
||||
case "dark":
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-[#1C1C20] border-white/10 hover:border-white/20",
|
||||
);
|
||||
case "green":
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-gradient-to-b from-[#002D23]/80 via-[#006B54]/50 to-[#00D28B]/10 border-[#00a873]/50",
|
||||
"hover:border-[#00a873] hover:shadow-[0_0_30px_rgba(0,210,139,0.2)]",
|
||||
);
|
||||
case "purple":
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-gradient-to-b from-[#2D004D]/80 via-[#6400A8]/50 to-[#C03FFF]/10 border-[#9D4CFF]/50",
|
||||
"hover:border-[#9D4CFF] hover:shadow-[0_0_30px_rgba(192,63,255,0.2)]",
|
||||
);
|
||||
case "orange":
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-gradient-to-b from-[#332100]/80 via-[#B35C00]/50 to-[#FFB347]/10 border-[#FF8C00]/50",
|
||||
"hover:border-[#FF8C00] hover:shadow-[0_0_30px_rgba(255,179,71,0.2)]",
|
||||
);
|
||||
default:
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-white/5 border-white/10 hover:border-white/20",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isCurrentPlan = (planSlug: string) => {
|
||||
return planSlug === activePlanSlug;
|
||||
};
|
||||
|
||||
const getButtonText = (planSlug: string) => {
|
||||
if (planSlug === "free") return "Get Started";
|
||||
|
||||
if (isCurrentPlan(planSlug)) {
|
||||
return "Current Plan";
|
||||
}
|
||||
|
||||
// User has an active paid subscription - show "Switch Plan"
|
||||
if (activePlanSlug && activePlanSlug !== "free") {
|
||||
return "Switch Plan";
|
||||
}
|
||||
|
||||
// No subscription - show "Select Plan"
|
||||
return "Select Plan";
|
||||
};
|
||||
|
||||
const handlePlanClick = async (planSlug: string) => {
|
||||
// Free plan - redirect to download
|
||||
if (planSlug === "free") {
|
||||
navigate("/download");
|
||||
return;
|
||||
}
|
||||
|
||||
// If current plan, do nothing
|
||||
if (isCurrentPlan(planSlug)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Not logged in - redirect to signup
|
||||
if (!user) {
|
||||
navigate("/signup");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set loading state for this plan
|
||||
setProcessingPlan(planSlug);
|
||||
|
||||
try {
|
||||
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({
|
||||
plan: apiPlanSlug,
|
||||
cadence: cadence,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Redirect to Stripe
|
||||
const redirectUrl =
|
||||
data.stripe_checkout_redirect_url || data.stripe_portal_url;
|
||||
if (redirectUrl) {
|
||||
window.location.href = redirectUrl;
|
||||
} else {
|
||||
console.error("No redirect URL returned from API");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initiating checkout:", error);
|
||||
} finally {
|
||||
setProcessingPlan(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (plan: SubscriptionPlanDetails) => {
|
||||
if (plan.monthlyPrice === 0) {
|
||||
return { current: 0, original: null };
|
||||
}
|
||||
|
||||
if (isYearly) {
|
||||
const val = Math.round(plan.yearlyPrice / 12);
|
||||
const original = plan.originalYearlyPrice
|
||||
? Math.round(plan.originalYearlyPrice / 12)
|
||||
: null;
|
||||
return { current: val, original };
|
||||
} else {
|
||||
const val = plan.originalMonthlyPrice || plan.monthlyPrice;
|
||||
return { current: val, original: null };
|
||||
}
|
||||
};
|
||||
|
||||
// Determine grid columns based on number of plans
|
||||
const gridCols =
|
||||
plans.length === 3
|
||||
? "grid-cols-1 md:grid-cols-3"
|
||||
: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4";
|
||||
|
||||
return (
|
||||
<div className={twMerge("w-full", className)}>
|
||||
{showHeader && (
|
||||
<div className={`text-center mx-auto ${compact ? "mb-6" : "mb-10"}`}>
|
||||
<h2
|
||||
className={`font-bold mb-4 ${compact ? "text-2xl md:text-3xl" : "text-3xl sm:text-4xl md:text-5xl"}`}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p
|
||||
className={`text-white/70 leading-relaxed ${compact ? "text-base mb-4" : "text-xl mb-8"}`}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Toggle */}
|
||||
<div
|
||||
className={`flex items-center justify-center gap-4 relative w-fit mx-auto ${compact ? "mb-14" : "mb-14"}`}
|
||||
>
|
||||
<TabSelector
|
||||
tabs={billingTabs}
|
||||
activeTab={billingType}
|
||||
onTabChange={setBillingType}
|
||||
className="w-fit border border-white/20 rounded-lg bg-white/5"
|
||||
tabClassName="w-24 text-md"
|
||||
indicatorClassName="bg-primary/30 border border-primary"
|
||||
selectedTabClassName="text-white"
|
||||
/>
|
||||
<span className="bg-primary text-white px-3 py-0.5 rounded-full text-sm font-medium -top-3 -right-10 md:-left-6 md:right-auto absolute pointer-events-none transform md:-rotate-12 rotate-12">
|
||||
-20%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={`max-w-7xl mx-auto grid ${gridCols} gap-4 md:gap-6`}>
|
||||
{plans.map((plan) => {
|
||||
const isPopular = plan.slug === "artcraft_pro";
|
||||
const isCurrent = isCurrentPlan(plan.slug);
|
||||
const { current: price, original: originalPrice } = formatPrice(plan);
|
||||
const isProcessing = processingPlan === plan.slug;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.slug}
|
||||
className={
|
||||
getColorSchemeClasses(plan.colorScheme) +
|
||||
(isPopular ? " transform md:-translate-y-4 shadow-2xl" : "") +
|
||||
(isCurrent ? " ring-2 ring-white/50" : "")
|
||||
}
|
||||
>
|
||||
{isPopular && !isCurrent && (
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-primary px-4 py-1 rounded-full text-sm font-bold shadow-lg whitespace-nowrap">
|
||||
MOST POPULAR
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCurrent && (
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white text-black px-4 py-1 rounded-full text-sm font-bold shadow-lg whitespace-nowrap">
|
||||
CURRENT PLAN
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-xl md:text-2xl font-bold mb-2 text-white">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<div className="mb-1 flex items-baseline gap-2">
|
||||
{originalPrice !== null && (
|
||||
<span className="text-white/40 line-through text-lg md:text-xl decoration-white/40">
|
||||
${originalPrice}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-3xl md:text-4xl font-bold">${price}</span>
|
||||
<span className="text-white/60">/month</span>
|
||||
</div>
|
||||
<div className="text-xs text-white/40 mb-4 md:mb-6 uppercase tracking-wider font-semibold min-h-[1rem]">
|
||||
{plan.monthlyPrice === 0
|
||||
? "Free forever"
|
||||
: isYearly
|
||||
? "Billed yearly"
|
||||
: "Billed monthly"}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className={`w-full justify-center border-transparent mb-6 md:mb-8 ${
|
||||
isCurrent
|
||||
? "bg-white/20 cursor-default"
|
||||
: isPopular
|
||||
? "bg-primary hover:bg-primary-600"
|
||||
: "bg-white hover:bg-white/80 text-black"
|
||||
}`}
|
||||
onClick={() => handlePlanClick(plan.slug)}
|
||||
disabled={isCurrent || isProcessing || isLoading}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<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>
|
||||
Processing...
|
||||
</span>
|
||||
) : (
|
||||
getButtonText(plan.slug)
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<ul className="space-y-3 md:space-y-4 flex-1">
|
||||
{plan.features.map((feature, idx) => (
|
||||
<Feature
|
||||
key={idx}
|
||||
text={feature.text}
|
||||
highlighted={isPopular}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Feature = ({
|
||||
text,
|
||||
highlighted = false,
|
||||
}: {
|
||||
text: string;
|
||||
highlighted?: boolean;
|
||||
}) => (
|
||||
<li className="flex items-start gap-3">
|
||||
<div
|
||||
className={`mt-1 w-5 h-5 rounded-full flex items-center justify-center shrink-0 ${highlighted ? "bg-white/20 text-white" : "bg-white/10 text-white/70"}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} className="text-xs" />
|
||||
</div>
|
||||
<span className={`text-sm ${highlighted ? "text-white" : "text-white/80"}`}>
|
||||
{text}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
export default PricingTable;
|
||||
@@ -0,0 +1,98 @@
|
||||
import { faXmarkCircle, faArrowLeft } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Link } from "react-router-dom";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const CheckoutCancel = () => {
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
|
||||
<Seo
|
||||
title="Checkout Cancelled - ArtCraft"
|
||||
description="Your checkout was cancelled. No payment was made."
|
||||
/>
|
||||
|
||||
{/* Background gradient - subtle/neutral */}
|
||||
<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-gray-500/20 via-gray-600/10 to-gray-700/5 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)]">
|
||||
{/* Cancel Card */}
|
||||
<div className="max-w-lg w-full">
|
||||
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12 text-center">
|
||||
{/* Cancel Icon */}
|
||||
<div className="mb-6">
|
||||
<div className="w-20 h-20 mx-auto rounded-full bg-white/10 flex items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faXmarkCircle}
|
||||
className="text-5xl text-white/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-4 text-white">
|
||||
Checkout Cancelled
|
||||
</h1>
|
||||
<p className="text-lg text-white/60 mb-8 max-w-md mx-auto">
|
||||
No worries! Your checkout was cancelled and no payment was made.
|
||||
You can try again whenever you're ready.
|
||||
</p>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-[#252529] rounded-2xl p-5 mb-8 text-left">
|
||||
<p className="text-white/70 text-sm">
|
||||
<span className="text-white font-medium">
|
||||
Changed your mind?
|
||||
</span>{" "}
|
||||
No problem. You can return to the pricing page to complete your
|
||||
purchase at any time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
as="link"
|
||||
href="/pricing"
|
||||
className="bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
|
||||
>
|
||||
View Plans Again
|
||||
</Button>
|
||||
<Button
|
||||
as="link"
|
||||
href="/"
|
||||
className="bg-white/10 hover:bg-white/20 px-8 py-3 text-sm font-bold rounded-xl justify-center border-transparent"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
Back to Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Links */}
|
||||
<div className="text-center mt-8 flex justify-center gap-6">
|
||||
<Link
|
||||
to="/faq"
|
||||
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
FAQ
|
||||
</Link>
|
||||
<span className="text-white/20">•</span>
|
||||
<a
|
||||
href="https://discord.gg/artcraft"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
Get Help
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckoutCancel;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as CheckoutSuccess } from "./success";
|
||||
export { default as CheckoutCancel } from "./cancel";
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
faCheckCircle,
|
||||
faDownload,
|
||||
faDesktop,
|
||||
faRocket,
|
||||
faArrowLeft,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Link } from "react-router-dom";
|
||||
import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { DOWNLOAD_LINKS } from "../../config/downloads";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const CheckoutSuccess = () => {
|
||||
const downloadUrl = isMacOs ? DOWNLOAD_LINKS.MACOS : DOWNLOAD_LINKS.WINDOWS;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
|
||||
<Seo
|
||||
title="Payment Successful - ArtCraft"
|
||||
description="Your ArtCraft subscription is now active. Download and start creating!"
|
||||
/>
|
||||
|
||||
{/* Background gradient - celebratory green/purple */}
|
||||
<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)]">
|
||||
{/* Success Card */}
|
||||
<div className="max-w-2xl w-full">
|
||||
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12 text-center">
|
||||
{/* Success Icon */}
|
||||
<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>
|
||||
|
||||
{/* Header */}
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-4 text-white">
|
||||
Thank You for Your Support!
|
||||
</h1>
|
||||
<p className="text-lg text-white/70 mb-8 max-w-md mx-auto">
|
||||
Your subscription is now active. Thank you for helping keep
|
||||
ArtCraft open-source and free for everyone. You're now ready to
|
||||
create amazing AI-generated art!
|
||||
</p>
|
||||
|
||||
{/* Next Steps */}
|
||||
<div className="bg-[#252529] rounded-2xl p-6 mb-8 text-left">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faRocket}
|
||||
className="text-primary text-sm"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-white">Next Steps</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
"Download ArtCraft if you haven't already",
|
||||
"Log in with your account",
|
||||
"Your subscription is automatically activated",
|
||||
"Start creating with all premium features!",
|
||||
].map((step, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 shrink-0 rounded-full bg-primary/50 flex items-center justify-center text-xs font-bold text-white mt-0.5">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<span className="text-white/80">{step}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
{!isMobile ? (
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
as="link"
|
||||
href={downloadUrl}
|
||||
className="bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} className="mr-2" />
|
||||
Download ArtCraft
|
||||
</Button>
|
||||
<Button
|
||||
as="link"
|
||||
href="/"
|
||||
className="bg-white/10 hover:bg-white/20 px-8 py-3 text-sm font-bold rounded-xl justify-center border-transparent"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
Back to Home
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[#431407] border border-orange-900/50 rounded-2xl p-6 text-orange-200 text-sm leading-relaxed">
|
||||
<div className="flex items-center justify-center mb-3 text-orange-400">
|
||||
<FontAwesomeIcon icon={faDesktop} className="text-2xl" />
|
||||
</div>
|
||||
ArtCraft is a desktop application. <br />
|
||||
Please head to your computer to download and start creating.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Links */}
|
||||
{!isMobile && (
|
||||
<div className="mt-6 flex justify-center gap-6 text-sm font-medium text-white/40">
|
||||
<a
|
||||
href={DOWNLOAD_LINKS.WINDOWS}
|
||||
className="hover:text-white transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
||||
Windows
|
||||
</a>
|
||||
<a
|
||||
href={DOWNLOAD_LINKS.MACOS}
|
||||
className="hover:text-white transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
||||
Mac
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Link */}
|
||||
<div className="text-center mt-8">
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
View Plan Details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckoutSuccess;
|
||||
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { DOWNLOAD_LINKS } from "../../config/downloads";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const Download = () => {
|
||||
const systemRequirements = [
|
||||
@@ -29,6 +30,10 @@ const Download = () => {
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots">
|
||||
<Seo
|
||||
title="Download ArtCraft - Windows and macOS"
|
||||
description="Download ArtCraft for Windows and macOS. Start creating AI artwork today."
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-50" />
|
||||
<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-30 blur-[120px]"></div>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { faArrowLeft, faEnvelope } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const ForgotPassword = () => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
|
||||
<Seo
|
||||
title="Reset Password - ArtCraft"
|
||||
description="Reset your ArtCraft password."
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">Reset Password</h1>
|
||||
<p className="text-white/60 text-sm">
|
||||
Enter your email to receive reset instructions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!submitted ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
|
||||
onClick={() => setSubmitted(true)}
|
||||
>
|
||||
Send Reset Link
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
|
||||
<FontAwesomeIcon icon={faEnvelope} className="text-2xl" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2">Check your email</h3>
|
||||
<p className="text-white/60 text-sm mb-8">
|
||||
We've sent a password reset link to <br />
|
||||
<span className="text-white font-medium">{email}</span>
|
||||
</p>
|
||||
<Button
|
||||
className="w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-12"
|
||||
onClick={() => setSubmitted(false)}
|
||||
>
|
||||
Try another email
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 text-center text-sm">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-white/40 hover:text-white transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} /> Back to Log in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPassword;
|
||||
@@ -0,0 +1,2 @@
|
||||
import ForgotPassword from "./forgot-password";
|
||||
export default ForgotPassword;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DiscordButton } from "../../components/discord-button";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { faWindows, faApple, faGithub } from "@fortawesome/free-brands-svg-icons";
|
||||
import {
|
||||
faVolumeMute,
|
||||
@@ -25,6 +25,8 @@ 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";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
@@ -52,8 +54,33 @@ const Landing = () => {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [activeFeature, setActiveFeature] = useState(0);
|
||||
const [activeVideo, setActiveVideo] = useState<number | null>(null);
|
||||
const [showDownloadModal, setShowDownloadModal] = useState(false);
|
||||
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);
|
||||
}
|
||||
};
|
||||
checkSession();
|
||||
}, []);
|
||||
|
||||
const onDownloadClick = (e: React.MouseEvent) => {
|
||||
if (isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowDownloadModal(true);
|
||||
|
||||
localStorage.setItem("artcraft_download_initiated", "true");
|
||||
};
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: faMapMarkerAlt,
|
||||
@@ -263,26 +290,17 @@ const Landing = () => {
|
||||
Download on a desktop
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className="text-md px-4 py-3 font-semibold rounded-xl shadow-lg gap-3"
|
||||
as="link"
|
||||
href={WINDOWS_LINK}
|
||||
>
|
||||
<FontAwesomeIcon icon={faWindows} />
|
||||
Download Windows
|
||||
</Button>
|
||||
<Button
|
||||
className="text-md px-4 py-3 font-semibold rounded-xl shadow-lg gap-3"
|
||||
as="link"
|
||||
href={MAC_LINK}
|
||||
>
|
||||
<FontAwesomeIcon icon={faApple} />
|
||||
Download Mac
|
||||
</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-[45%] -top-5 h-40 pointer-events-none select-none hidden md:block" />
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
{/* Glassy Video Mockup */}
|
||||
@@ -773,13 +791,13 @@ const Landing = () => {
|
||||
<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-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"
|
||||
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">
|
||||
<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-gray-200 mb-6 sm:mb-8 max-w-md leading-relaxed">
|
||||
<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 />
|
||||
@@ -807,9 +825,41 @@ const Landing = () => {
|
||||
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" />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DownloadModal isOpen={showDownloadModal} onClose={() => setShowDownloadModal(false)} />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import LogIn from "./login";
|
||||
export default LogIn;
|
||||
@@ -0,0 +1,154 @@
|
||||
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faEye, faEyeSlash, faSpinner } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
const api = new UsersApi();
|
||||
const response = await api.Login({
|
||||
usernameOrEmail: email,
|
||||
password: password,
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (response.success) {
|
||||
window.dispatchEvent(new Event("auth-change"));
|
||||
navigate("/");
|
||||
} else {
|
||||
setError(response.errorMessage || "Invalid credentials");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
|
||||
<Seo
|
||||
title="Login - ArtCraft"
|
||||
description="Login to your ArtCraft account."
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">Welcome Back</h1>
|
||||
<p className="text-white/60 text-sm">Log in to your account</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
className="w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGoogle} className="text-lg" />
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<div className="relative flex items-center justify-center py-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10"></div>
|
||||
</div>
|
||||
<span className="relative bg-[#1C1C20] px-4 text-xs text-white/40 uppercase tracking-widest">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Email or Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com or username"
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center ml-1">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide">
|
||||
Password
|
||||
</label>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-xs text-primary hover:text-primary-400 transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
) : (
|
||||
"Log in"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center text-sm text-white/60">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
to="/signup"
|
||||
className="text-primary hover:text-primary-400 font-semibold transition-colors"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
|
||||
© {new Date().getFullYear()} ArtCraft. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -30,14 +30,15 @@ import {
|
||||
getProviderDisplayName,
|
||||
getProviderIconByName,
|
||||
} from "@storyteller/model-list";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v'];
|
||||
const VIDEO_EXTENSIONS = [".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"];
|
||||
const COPY_FEEDBACK_DURATION = 1500;
|
||||
const SHARE_URL_BASE = 'https://getartcraft.com/media/';
|
||||
const SHARE_URL_BASE = "https://getartcraft.com/media/";
|
||||
|
||||
const isVideoUrl = (url: string): boolean => {
|
||||
const urlLower = url.toLowerCase();
|
||||
return VIDEO_EXTENSIONS.some(ext => urlLower.includes(ext));
|
||||
return VIDEO_EXTENSIONS.some((ext) => urlLower.includes(ext));
|
||||
};
|
||||
|
||||
interface ContextImage {
|
||||
@@ -78,7 +79,7 @@ const EMPTY_PROMPT_DATA: PromptData = {
|
||||
const createPromptData = (
|
||||
data: any,
|
||||
hasToken: boolean,
|
||||
loading: boolean = false
|
||||
loading: boolean = false,
|
||||
): PromptData => ({
|
||||
text: data?.maybe_positive_prompt || null,
|
||||
loading,
|
||||
@@ -114,7 +115,13 @@ const useCopyFeedback = () => {
|
||||
return { copied, triggerCopy };
|
||||
};
|
||||
|
||||
const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => (
|
||||
const InfoRow = ({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) => (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5 last:border-0">
|
||||
<span className="text-sm text-white/60 font-medium">{label}</span>
|
||||
<span className="text-sm text-white font-medium flex items-center gap-2">
|
||||
@@ -155,7 +162,7 @@ export default function MediaPage() {
|
||||
if (mediaResponse.success && mediaResponse.data) {
|
||||
const file = mediaResponse.data;
|
||||
const url = file.media_links?.cdn_url || null;
|
||||
|
||||
|
||||
setMedia((prev) => {
|
||||
const isSameUrl = prev.url === url;
|
||||
return {
|
||||
@@ -171,8 +178,8 @@ export default function MediaPage() {
|
||||
});
|
||||
|
||||
if (file.maybe_prompt_token) {
|
||||
setPromptData(prev => ({ ...prev, hasToken: true, loading: true }));
|
||||
|
||||
setPromptData((prev) => ({ ...prev, hasToken: true, loading: true }));
|
||||
|
||||
try {
|
||||
const promptsApi = new PromptsApi();
|
||||
const promptResponse = await promptsApi.GetPromptsByToken({
|
||||
@@ -182,17 +189,31 @@ export default function MediaPage() {
|
||||
const data = promptResponse.success ? promptResponse.data : null;
|
||||
setPromptData(createPromptData(data, true, false));
|
||||
} catch {
|
||||
setPromptData(prev => ({ ...prev, loading: false }));
|
||||
setPromptData((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
} else {
|
||||
setPromptData(EMPTY_PROMPT_DATA);
|
||||
}
|
||||
} else {
|
||||
setMedia({ url: null, token: null, createdAt: null, isVideo: false, isLoaded: false, creator: null });
|
||||
setMedia({
|
||||
url: null,
|
||||
token: null,
|
||||
createdAt: null,
|
||||
isVideo: false,
|
||||
isLoaded: false,
|
||||
creator: null,
|
||||
});
|
||||
toast.error("Media not found");
|
||||
}
|
||||
} catch {
|
||||
setMedia({ url: null, token: null, createdAt: null, isVideo: false, isLoaded: false, creator: null });
|
||||
setMedia({
|
||||
url: null,
|
||||
token: null,
|
||||
createdAt: null,
|
||||
isVideo: false,
|
||||
isLoaded: false,
|
||||
creator: null,
|
||||
});
|
||||
toast.error("Failed to load media");
|
||||
} finally {
|
||||
setMediaRecordLoading(false);
|
||||
@@ -205,8 +226,6 @@ export default function MediaPage() {
|
||||
}
|
||||
}, [mediaIdParam, loadMedia]);
|
||||
|
||||
|
||||
|
||||
const handleCopyPrompt = useCallback(async () => {
|
||||
if (!promptData.text) return;
|
||||
try {
|
||||
@@ -231,10 +250,12 @@ export default function MediaPage() {
|
||||
|
||||
const shareButtonIcon = shareCopy.copied ? faCheck : faLink;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full p-4 pt-16 bg-dots flex items-start lg:items-center justify-center">
|
||||
<Seo
|
||||
title="Shared Media - ArtCraft"
|
||||
description="View shared media from ArtCraft."
|
||||
/>
|
||||
<div className="mx-auto max-w-[1920px] w-full h-auto lg:h-[calc(100vh-100px)] min-h-[500px]">
|
||||
<div className="flex flex-col lg:flex-row h-full w-full overflow-hidden rounded-xl border border-white/[2%]">
|
||||
{/* Media Preview Area */}
|
||||
@@ -254,14 +275,14 @@ export default function MediaPage() {
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onError={() => console.error('Video failed to load')}
|
||||
onError={() => console.error("Video failed to load")}
|
||||
onLoadedData={(e) => {
|
||||
const el = e.currentTarget;
|
||||
setMedia(prev => ({
|
||||
...prev,
|
||||
setMedia((prev) => ({
|
||||
...prev,
|
||||
isLoaded: true,
|
||||
width: el.videoWidth,
|
||||
height: el.videoHeight
|
||||
height: el.videoHeight,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
@@ -273,18 +294,22 @@ export default function MediaPage() {
|
||||
className="h-full w-full object-contain transition-opacity duration-300"
|
||||
style={{ opacity: media.isLoaded ? 1 : 0 }}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).src = PLACEHOLDER_IMAGES.DEFAULT;
|
||||
(e.currentTarget as HTMLImageElement).style.opacity = "0.3";
|
||||
(e.currentTarget as HTMLImageElement).dataset.brokenurl = media.url || "";
|
||||
setMedia(prev => ({ ...prev, isLoaded: true }));
|
||||
(e.currentTarget as HTMLImageElement).src =
|
||||
PLACEHOLDER_IMAGES.DEFAULT;
|
||||
(e.currentTarget as HTMLImageElement).style.opacity =
|
||||
"0.3";
|
||||
(
|
||||
e.currentTarget as HTMLImageElement
|
||||
).dataset.brokenurl = media.url || "";
|
||||
setMedia((prev) => ({ ...prev, isLoaded: true }));
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget;
|
||||
setMedia(prev => ({
|
||||
...prev,
|
||||
setMedia((prev) => ({
|
||||
...prev,
|
||||
isLoaded: true,
|
||||
width: el.naturalWidth,
|
||||
height: el.naturalHeight
|
||||
height: el.naturalHeight,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
@@ -327,7 +352,10 @@ export default function MediaPage() {
|
||||
<div className="h-4 w-32 bg-white/10 rounded" />
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="aspect-square w-full bg-white/10 rounded-lg" />
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square w-full bg-white/10 rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -341,8 +369,12 @@ export default function MediaPage() {
|
||||
size={36}
|
||||
username={media.creator.username}
|
||||
email_hash={media.creator.email_gravatar_hash}
|
||||
avatarIndex={media.creator.core_info.default_avatar.image_index}
|
||||
backgroundIndex={media.creator.core_info.default_avatar.color_index}
|
||||
avatarIndex={
|
||||
media.creator.core_info.default_avatar.image_index
|
||||
}
|
||||
backgroundIndex={
|
||||
media.creator.core_info.default_avatar.color_index
|
||||
}
|
||||
className="rounded-xl border-white/10"
|
||||
/>
|
||||
) : (
|
||||
@@ -351,8 +383,12 @@ export default function MediaPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-white text-sm font-semibold leading-none">{media.creator.display_name}</span>
|
||||
<span className="text-white/60 text-xs font-medium">Author</span>
|
||||
<span className="text-white text-sm font-semibold leading-none">
|
||||
{media.creator.display_name}
|
||||
</span>
|
||||
<span className="text-white/60 text-xs font-medium">
|
||||
Author
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -381,11 +417,13 @@ export default function MediaPage() {
|
||||
onClick={handleCopyPrompt}
|
||||
className="flex items-center gap-1.5 text-xs text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<FontAwesomeIcon icon={promptCopy.copied ? faCheck : faCopy} />
|
||||
<FontAwesomeIcon
|
||||
icon={promptCopy.copied ? faCheck : faCopy}
|
||||
/>
|
||||
<span>{promptCopy.copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="relative text-sm text-white/90 break-words px-4 py-3 rounded-xl bg-black/20 leading-relaxed border border-white/5">
|
||||
{promptData.loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -404,52 +442,59 @@ export default function MediaPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{promptData.contextImages && promptData.contextImages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
|
||||
<FontAwesomeIcon icon={faImage} />
|
||||
<span>Reference Images</span>
|
||||
{promptData.contextImages &&
|
||||
promptData.contextImages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
|
||||
<FontAwesomeIcon icon={faImage} />
|
||||
<span>Reference Images</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{promptData.contextImages.map(
|
||||
(contextImage, index) => {
|
||||
const { thumbnail } =
|
||||
getContextImageThumbnail(contextImage, {
|
||||
size: THUMBNAIL_SIZES.SMALL,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={contextImage.media_token}
|
||||
className="glass relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={`Reference image ${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{promptData.contextImages.map((contextImage, index) => {
|
||||
const { thumbnail } = getContextImageThumbnail(
|
||||
contextImage,
|
||||
{ size: THUMBNAIL_SIZES.SMALL }
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={contextImage.media_token}
|
||||
className="glass relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={`Reference image ${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{(promptData.provider || promptData.modelType || media.createdAt) && (
|
||||
{(promptData.provider ||
|
||||
promptData.modelType ||
|
||||
media.createdAt) && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
|
||||
<FontAwesomeIcon icon={faCircleInfo} />
|
||||
<span>Information</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col rounded-xl bg-black/20 border border-white/5 overflow-hidden">
|
||||
|
||||
|
||||
{promptData.modelType && (
|
||||
<InfoRow
|
||||
label="Model"
|
||||
value={
|
||||
<>
|
||||
{getModelCreatorIcon(promptData.modelType)}
|
||||
<span>{getModelDisplayName(promptData.modelType)}</span>
|
||||
<span>
|
||||
{getModelDisplayName(
|
||||
promptData.modelType,
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -460,8 +505,15 @@ export default function MediaPage() {
|
||||
label="Provider"
|
||||
value={
|
||||
<>
|
||||
{getProviderIconByName(promptData.provider, "h-4 w-4 invert")}
|
||||
<span>{getProviderDisplayName(promptData.provider)}</span>
|
||||
{getProviderIconByName(
|
||||
promptData.provider,
|
||||
"h-4 w-4 invert",
|
||||
)}
|
||||
<span>
|
||||
{getProviderDisplayName(
|
||||
promptData.provider,
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -473,11 +525,13 @@ export default function MediaPage() {
|
||||
value={`${media.width} × ${media.height}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{media.createdAt && (
|
||||
<InfoRow
|
||||
label="Created"
|
||||
value={dayjs(media.createdAt).format("MMMM D, YYYY")}
|
||||
value={dayjs(media.createdAt).format(
|
||||
"MMMM D, YYYY",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -504,11 +558,15 @@ export default function MediaPage() {
|
||||
"w-full inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors border border-ui-panel-border",
|
||||
media.url
|
||||
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
|
||||
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none"
|
||||
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none",
|
||||
)}
|
||||
href={media.url ? addCorsParam(media.url) || media.url : undefined}
|
||||
href={
|
||||
media.url ? addCorsParam(media.url) || media.url : undefined
|
||||
}
|
||||
download={
|
||||
media.url ? `artcraft-${media.token || (media.isVideo ? "video" : "image")}` : undefined
|
||||
media.url
|
||||
? `artcraft-${media.token || (media.isVideo ? "video" : "image")}`
|
||||
: undefined
|
||||
}
|
||||
aria-disabled={!media.url}
|
||||
target="_blank"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NewsIndex as LibNewsIndex } from "@storyteller/markdown-content";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const NewsIndex = ({ basePath }: { basePath: string }) => {
|
||||
return (
|
||||
<>
|
||||
<Seo
|
||||
title="News & Updates - ArtCraft"
|
||||
description="Latest updates, features, and announcements from the ArtCraft team."
|
||||
/>
|
||||
<LibNewsIndex basePath={basePath} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsIndex;
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
NewsPost as LibNewsPost,
|
||||
getNewsPostBySlug,
|
||||
} from "@storyteller/markdown-content";
|
||||
import Seo from "../../components/seo";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const NewsPost = ({ basePath }: { basePath: string }) => {
|
||||
const { slug } = useParams();
|
||||
const post = slug ? getNewsPostBySlug(slug) : null;
|
||||
|
||||
const title = post
|
||||
? `${post.title} - ArtCraft`
|
||||
: "Article Not Found - ArtCraft";
|
||||
const desc = post ? post.description : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Seo title={title} description={desc} />
|
||||
<LibNewsPost basePath={basePath} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsPost;
|
||||
@@ -0,0 +1,2 @@
|
||||
import Pricing from "./pricing";
|
||||
export default Pricing;
|
||||
@@ -0,0 +1,33 @@
|
||||
import Footer from "../../components/footer";
|
||||
import Seo from "../../components/seo";
|
||||
import { PricingTable } from "../../components/pricing-table";
|
||||
|
||||
const Pricing = () => {
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots">
|
||||
<Seo
|
||||
title="Pricing - ArtCraft"
|
||||
description="Simple, transparent pricing for ArtCraft. Start for free and scale as you grow."
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-50" />
|
||||
<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-32 pb-20 px-4 sm:px-6 lg:px-8">
|
||||
<PricingTable
|
||||
title="Invest in ArtCraft"
|
||||
subtitle="Support open-source development. Your subscription makes ArtCraft better and gets you tons of generations."
|
||||
/>
|
||||
</main>
|
||||
|
||||
<p className="relative z-10 pt-32 pb-20 px-4 sm:px-6 lg:px-8">
|
||||
Note: Pricing table is wrong, but we'll fix this shortly.
|
||||
</p>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pricing;
|
||||
@@ -0,0 +1,2 @@
|
||||
import SignUp from "./signup";
|
||||
export default SignUp;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { SignupForm } from "../../components/auth";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const Signup = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
|
||||
<Seo
|
||||
title="Sign Up - ArtCraft"
|
||||
description="Create your ArtCraft account."
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">Create an Account</h1>
|
||||
<p className="text-white/60 text-sm">Join thousands of creators</p>
|
||||
</div>
|
||||
|
||||
<SignupForm
|
||||
onSuccess={() => navigate("/welcome")}
|
||||
signupSource="artcraft"
|
||||
/>
|
||||
|
||||
<div className="mt-8 text-center text-sm text-white/60">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-primary hover:text-primary-400 font-semibold transition-colors"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
|
||||
© {new Date().getFullYear()} ArtCraft. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Signup;
|
||||
@@ -0,0 +1,2 @@
|
||||
import Welcome from "./welcome";
|
||||
export default Welcome;
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
faCheckCircle,
|
||||
faDownload,
|
||||
faDesktop,
|
||||
faRocket,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Link } from "react-router-dom";
|
||||
import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { DOWNLOAD_LINKS } from "../../config/downloads";
|
||||
import Seo from "../../components/seo";
|
||||
import { PricingTable } from "../../components/pricing-table";
|
||||
|
||||
const Welcome = () => {
|
||||
const downloadUrl = isMacOs ? DOWNLOAD_LINKS.MACOS : DOWNLOAD_LINKS.WINDOWS;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
|
||||
<Seo
|
||||
title="Welcome - ArtCraft"
|
||||
description="Welcome to ArtCraft. Get started with your subscription."
|
||||
/>
|
||||
|
||||
{/* Background gradient */}
|
||||
<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-primary/40 via-purple-600/30 to-blue-500/20 opacity-30 blur-[120px]"></div>
|
||||
</div>
|
||||
|
||||
<main className="relative z-10 pt-28 pb-20 px-4 sm:px-6 lg:px-8">
|
||||
{/* Success Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center gap-3 bg-primary/10 border border-primary/30 rounded-full px-6 py-3 mb-6">
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="text-xl text-primary"
|
||||
/>
|
||||
<span className="text-white font-medium">
|
||||
Account created successfully!
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold mb-4 tracking-tight">
|
||||
Welcome to <span className="text-primary">ArtCraft</span>
|
||||
</h1>
|
||||
<p className="text-xl text-white/70 max-w-2xl mx-auto">
|
||||
Support open-source development and unlock premium AI art features.
|
||||
Your subscription keeps ArtCraft free for everyone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pricing Table */}
|
||||
<div className="mb-16">
|
||||
<PricingTable
|
||||
showHeader={false}
|
||||
compact={true}
|
||||
className="max-w-5xl mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Next Steps Section */}
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-10">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faRocket}
|
||||
className="text-primary text-lg"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white">Getting Started</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-white/60 mb-6">
|
||||
Your download should have started automatically. Follow these
|
||||
steps to begin creating:
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
|
||||
{[
|
||||
{ step: 1, text: "Open the installer" },
|
||||
{ step: 2, text: "Follow setup instructions" },
|
||||
{ step: 3, text: "Log in to your account" },
|
||||
{ step: 4, text: "Start creating!" },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.step}
|
||||
className="bg-[#252529] p-4 rounded-xl flex items-center gap-4"
|
||||
>
|
||||
<div className="w-8 h-8 shrink-0 rounded-full bg-primary/60 flex items-center justify-center text-sm font-bold text-white">
|
||||
{item.step}
|
||||
</div>
|
||||
<div className="text-white/90 font-medium">{item.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Download Contingency */}
|
||||
{!isMobile && (
|
||||
<div className="pt-6 border-t border-white/10">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<span className="text-white/50 text-sm">
|
||||
Download didn't start?
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Button
|
||||
as="link"
|
||||
href={downloadUrl}
|
||||
className="bg-white text-black hover:bg-gray-100 dark:bg-white dark:text-black dark:hover:bg-gray-200 px-6 py-2.5 text-sm font-bold rounded-xl"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} className="mr-2" />
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</Button>
|
||||
<div className="flex gap-4 text-sm font-medium text-white/30">
|
||||
<a
|
||||
href={DOWNLOAD_LINKS.WINDOWS}
|
||||
className="hover:text-white transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
||||
Windows
|
||||
</a>
|
||||
<a
|
||||
href={DOWNLOAD_LINKS.MACOS}
|
||||
className="hover:text-white transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
||||
Mac
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<div className="bg-[#431407] border border-orange-900/50 rounded-2xl p-6 text-orange-200 text-sm leading-relaxed">
|
||||
<div className="flex items-center justify-center mb-3 text-orange-400">
|
||||
<FontAwesomeIcon icon={faDesktop} className="text-2xl" />
|
||||
</div>
|
||||
ArtCraft is a powerful desktop experience. <br />
|
||||
Please head to your computer to download and install.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Link */}
|
||||
<div className="text-center mt-8">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Welcome;
|
||||
@@ -3,8 +3,27 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: "Outfit", sans-serif;
|
||||
}
|
||||
body {
|
||||
font-family: "Poppins", sans-serif;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
"Open Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
color: #ffffff;
|
||||
background-color: #1b1b1f;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ module.exports = {
|
||||
content: [
|
||||
join(
|
||||
__dirname,
|
||||
"{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}"
|
||||
"{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}",
|
||||
),
|
||||
...createGlobPatternsForDependencies(__dirname),
|
||||
"./app/index.html",
|
||||
@@ -23,6 +23,7 @@ module.exports = {
|
||||
white: colors.white,
|
||||
gray: colors.gray,
|
||||
black: colors.black,
|
||||
red: colors.red,
|
||||
blue: colors.blue,
|
||||
orange: colors.orange,
|
||||
green: colors.green,
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
"src/components/footer"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../libs/components/tab-selector/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/state/subscription/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/model-list/tsconfig.lib.json"
|
||||
},
|
||||
@@ -52,14 +58,14 @@
|
||||
{
|
||||
"path": "../../libs/components/loading-spinner/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/markdown-content/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/api/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/button/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/markdown-content/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../libs/components/tab-selector"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/state/subscription"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/model-list"
|
||||
},
|
||||
@@ -17,15 +23,15 @@
|
||||
{
|
||||
"path": "../../libs/components/loading-spinner"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/markdown-content"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/api"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/button"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/markdown-content"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
|
||||
@@ -32,6 +32,9 @@ export default defineConfig(() => ({
|
||||
target: 'https://api.storyteller.ai',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
headers: {
|
||||
Origin: 'https://api.storyteller.ai',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum SubscriptionNamespace {
|
||||
FAKEYOU = "fakeyou",
|
||||
ARTCRAFT = "artcraft",
|
||||
}
|
||||
export enum SubscriptionProduct {
|
||||
PLUS = "fakeyou_plus",
|
||||
|
||||
@@ -8,7 +8,7 @@ interface SignupRequest {
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
signup_source?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class UsersApi extends ApiManager {
|
||||
private async authFetch<B, T>(
|
||||
@@ -19,7 +19,7 @@ export class UsersApi extends ApiManager {
|
||||
}: {
|
||||
method: string;
|
||||
body?: B;
|
||||
}
|
||||
},
|
||||
): Promise<T> {
|
||||
const bodyInString = JSON.stringify(body);
|
||||
|
||||
@@ -33,8 +33,14 @@ export class UsersApi extends ApiManager {
|
||||
body: bodyInString,
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
return responseData as T;
|
||||
const text = await response.text();
|
||||
try {
|
||||
const responseData = JSON.parse(text);
|
||||
return responseData as T;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse response as JSON:", text);
|
||||
throw new Error(text || `Request failed with status ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
public GetSession(): Promise<
|
||||
@@ -164,7 +170,7 @@ export class UsersApi extends ApiManager {
|
||||
signupSource?: string;
|
||||
}): Promise<ApiResponse<{ signedSession?: string }>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/create_account`;
|
||||
const body : SignupRequest = {
|
||||
const body: SignupRequest = {
|
||||
email_address: email,
|
||||
password,
|
||||
password_confirmation: passwordConfirmation,
|
||||
@@ -207,4 +213,4 @@ export class UsersApi extends ApiManager {
|
||||
}
|
||||
}
|
||||
|
||||
(window as any).UsersApi = new UsersApi();
|
||||
(window as any).UsersApi = new UsersApi();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum SubscriptionNamespace {
|
||||
FAKEYOU = "fakeyou",
|
||||
ARTCRAFT = "artcraft",
|
||||
}
|
||||
export enum SubscriptionProduct {
|
||||
PLUS = "fakeyou_plus",
|
||||
|
||||
@@ -26,10 +26,10 @@ export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
|
||||
originalMonthlyPrice: 10,
|
||||
originalYearlyPrice: 120,
|
||||
features: [
|
||||
{ text: "~1,000 Flux images", included: true },
|
||||
{ text: "~100 GPT-Image-1 images", included: true },
|
||||
{ text: "~10 minutes Kling video", included: true },
|
||||
{ text: "Commercial license", included: true },
|
||||
{ text: "~10,000 Nano Banana Pro 4K images", included: true },
|
||||
{ text: "~10,000 GPT-Image-1.5 images", included: true },
|
||||
{ text: "~60 minutes Kling video", included: true },
|
||||
{ text: "You Own ArtCraft Forever", included: true },
|
||||
],
|
||||
colorScheme: "green" as const,
|
||||
},
|
||||
@@ -42,10 +42,10 @@ export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
|
||||
originalMonthlyPrice: 35,
|
||||
originalYearlyPrice: 420,
|
||||
features: [
|
||||
{ text: "~5,000 Flux images", included: true },
|
||||
{ text: "~300 GPT-Image-1 images", included: true },
|
||||
{ text: "~30 minutes Kling video", included: true },
|
||||
{ text: "Commercial license", included: true },
|
||||
{ text: "~50,000 Nano Banana Pro 4K images", included: true },
|
||||
{ text: "~50,000 GPT-Image-1.5 images", included: true },
|
||||
{ text: "~2 hours Kling video", included: true },
|
||||
{ text: "You Own ArtCraft Forever", included: true },
|
||||
],
|
||||
colorScheme: "purple" as const,
|
||||
},
|
||||
@@ -58,10 +58,10 @@ export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
|
||||
originalMonthlyPrice: 60,
|
||||
originalYearlyPrice: 720,
|
||||
features: [
|
||||
{ text: "~15,000 Flux images", included: true },
|
||||
{ text: "~500 GPT-Image-1 images", included: true },
|
||||
{ text: "~60 minutes Kling video", included: true },
|
||||
{ text: "Commercial license", included: true },
|
||||
{ text: "~100,000 Nano Banana Pro 4K images", included: true },
|
||||
{ text: "~100,000 GPT-Image-1.5 images", included: true },
|
||||
{ text: "~6 hours Kling video", included: true },
|
||||
{ text: "You Own ArtCraft Forever", included: true },
|
||||
],
|
||||
colorScheme: "orange" as const,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user