feat(demo): start sandbox from landing

This commit is contained in:
2026-06-07 00:51:35 +03:30
parent ce6cd6cccc
commit c6b1712486
6 changed files with 87 additions and 12 deletions

View File

@@ -10,6 +10,7 @@ import {
emitSessionChanged, emitSessionChanged,
getAccessToken, getAccessToken,
getRefreshToken, getRefreshToken,
isDemoSession,
} from "../lib/session" } from "../lib/session"
let refreshRequest: Promise<string | null> | null = null let refreshRequest: Promise<string | null> | null = null
@@ -88,9 +89,10 @@ const normalizeJsonResponse = (response: Response) => {
} }
const clearSessionAndRedirect = () => { const clearSessionAndRedirect = () => {
const redirectTarget = isDemoSession() ? "/" : "/auth"
clearSessionTokens() clearSessionTokens()
if (window.location.pathname !== "/auth") { if (window.location.pathname !== redirectTarget) {
window.location.href = "/auth" window.location.href = redirectTarget
} }
} }
@@ -145,6 +147,7 @@ const shouldAttemptRefresh = (endpoint: string) => {
"/api/users/otp/send/", "/api/users/otp/send/",
"/api/users/otp/login/", "/api/users/otp/login/",
"/api/users/token/refresh/", "/api/users/token/refresh/",
"/api/demo/start/",
].includes(normalizedEndpoint) ].includes(normalizedEndpoint)
} }

17
src/api/demo.ts Normal file
View File

@@ -0,0 +1,17 @@
import { authFetch, buildApiError } from "./client"
export interface DemoStartResponse {
access: string
refresh: string
workspace_id: string
expires_at: string
demo_environment_id: string
}
export const startDemo = async (): Promise<DemoStartResponse> => {
const response = await authFetch("/api/demo/start/", {
method: "POST",
})
if (!response.ok) throw await buildApiError(response)
return response.json()
}

View File

@@ -1,4 +1,5 @@
export const SESSION_CHANGED_EVENT = "auth_session_changed" export const SESSION_CHANGED_EVENT = "auth_session_changed"
const DEMO_EXPIRES_AT_KEY = "demoExpiresAt"
export const getAccessToken = () => localStorage.getItem("accessToken") export const getAccessToken = () => localStorage.getItem("accessToken")
@@ -14,8 +15,21 @@ export const setSessionTokens = (accessToken: string, refreshToken: string) => {
emitSessionChanged() emitSessionChanged()
} }
export const setDemoSessionMeta = (expiresAt: string | null | undefined) => {
if (expiresAt) {
localStorage.setItem(DEMO_EXPIRES_AT_KEY, expiresAt)
} else {
localStorage.removeItem(DEMO_EXPIRES_AT_KEY)
}
}
export const getDemoSessionExpiresAt = () => localStorage.getItem(DEMO_EXPIRES_AT_KEY)
export const isDemoSession = () => !!getDemoSessionExpiresAt()
export const clearSessionTokens = () => { export const clearSessionTokens = () => {
localStorage.removeItem("accessToken") localStorage.removeItem("accessToken")
localStorage.removeItem("refreshToken") localStorage.removeItem("refreshToken")
localStorage.removeItem(DEMO_EXPIRES_AT_KEY)
emitSessionChanged() emitSessionChanged()
} }

View File

@@ -485,6 +485,15 @@ export const en = {
finalCtaDescription: finalCtaDescription:
"Open the app, create a workspace, and see how fast your reporting discipline improves when the product stops leaking context.", "Open the app, create a workspace, and see how fast your reporting discipline improves when the product stops leaking context.",
}, },
demo: {
badge: "Demo environment",
starting: "Preparing demo...",
started: "Demo environment is ready.",
startError: "Could not start the demo environment.",
expiresAt: "Expires at",
resetAction: "Reset demo",
reset: "Fresh demo environment is ready.",
},
ordering: { ordering: {
createdAtDesc: "Newest First", createdAtDesc: "Newest First",

View File

@@ -482,6 +482,15 @@ export const fa = {
finalCtaDescription: finalCtaDescription:
"اپ را باز کنید، ورک‌اسپیس بسازید و ببینید وقتی محصول نشت بستر را متوقف می‌کند، انضباط گزارش‌دهی چقدر سریع بهتر می‌شود.", "اپ را باز کنید، ورک‌اسپیس بسازید و ببینید وقتی محصول نشت بستر را متوقف می‌کند، انضباط گزارش‌دهی چقدر سریع بهتر می‌شود.",
}, },
demo: {
badge: "محیط دمو",
starting: "در حال آماده‌سازی دمو...",
started: "محیط دمو آماده شد.",
startError: "امکان ساخت محیط دمو وجود ندارد.",
expiresAt: "زمان انقضا",
resetAction: "شروع دوباره دمو",
reset: "محیط دموی تازه آماده شد.",
},
ordering: { ordering: {
createdAtDesc: "جدیدترین", createdAtDesc: "جدیدترین",

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react" import { useMemo, useState } from "react"
import { Link, useNavigate } from "react-router-dom" import { Link, useNavigate } from "react-router-dom"
import { import {
ArrowRight, ArrowRight,
@@ -15,11 +15,14 @@ import {
TimerReset, TimerReset,
Waypoints, Waypoints,
} from "lucide-react" } from "lucide-react"
import { toast } from "sonner"
import { Button } from "../components/ui/button" import { Button } from "../components/ui/button"
import { useTheme } from "../components/ThemeProvider" import { useTheme } from "../components/ThemeProvider"
import { useTranslation } from "../hooks/useTranslation" import { useTranslation } from "../hooks/useTranslation"
import { cn } from "../lib/utils" import { cn } from "../lib/utils"
import { startDemo } from "../api/demo"
import { setDemoSessionMeta, setSessionTokens } from "../lib/session"
const formatNumber = (value: number, lang: "en" | "fa") => const formatNumber = (value: number, lang: "en" | "fa") =>
new Intl.NumberFormat(lang === "fa" ? "fa-IR" : "en-US").format(value) new Intl.NumberFormat(lang === "fa" ? "fa-IR" : "en-US").format(value)
@@ -28,6 +31,7 @@ export default function Landing() {
const navigate = useNavigate() const navigate = useNavigate()
const { t, lang, setLanguage } = useTranslation() const { t, lang, setLanguage } = useTranslation()
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const [isStartingDemo, setIsStartingDemo] = useState(false)
const isAuthenticated = typeof window !== "undefined" && !!localStorage.getItem("accessToken") const isAuthenticated = typeof window !== "undefined" && !!localStorage.getItem("accessToken")
const isDarkMode = const isDarkMode =
@@ -87,6 +91,23 @@ export default function Landing() {
const ctaTarget = isAuthenticated ? "/timesheet" : "/auth" const ctaTarget = isAuthenticated ? "/timesheet" : "/auth"
const handleStartDemo = async () => {
if (isStartingDemo) return
setIsStartingDemo(true)
try {
const demo = await startDemo()
setSessionTokens(demo.access, demo.refresh)
setDemoSessionMeta(demo.expires_at)
toast.success(t.demo?.started || "Demo environment is ready.")
navigate("/timesheet")
} catch (error) {
console.error(error)
toast.error(t.demo?.startError || "Could not start the demo environment.")
} finally {
setIsStartingDemo(false)
}
}
return ( return (
<div className="scroll-smooth min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top,#e0f2fe_0%,#f8fafc_36%,#eef2ff_100%)] text-slate-950 dark:bg-[radial-gradient(circle_at_top,#082f49_0%,#020617_40%,#020617_100%)] dark:text-slate-50"> <div className="scroll-smooth min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top,#e0f2fe_0%,#f8fafc_36%,#eef2ff_100%)] text-slate-950 dark:bg-[radial-gradient(circle_at_top,#082f49_0%,#020617_40%,#020617_100%)] dark:text-slate-50">
<div className="landing-aurora pointer-events-none fixed inset-0 opacity-80" /> <div className="landing-aurora pointer-events-none fixed inset-0 opacity-80" />
@@ -164,12 +185,14 @@ export default function Landing() {
{isAuthenticated ? t.landing.actions.openWorkspace : t.landing.actions.startNow} {isAuthenticated ? t.landing.actions.openWorkspace : t.landing.actions.startNow}
<ArrowRight className={cn("ms-2 h-4 w-4", lang === "fa" && "rtl:rotate-180")} /> <ArrowRight className={cn("ms-2 h-4 w-4", lang === "fa" && "rtl:rotate-180")} />
</Button> </Button>
<a <button
href="#demo" type="button"
className="inline-flex h-14 items-center justify-center rounded-full border border-slate-200 bg-white/85 px-7 text-base font-medium text-slate-800 shadow-sm backdrop-blur transition hover:bg-white dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-100 dark:hover:bg-slate-900" onClick={handleStartDemo}
disabled={isStartingDemo}
className="inline-flex h-14 items-center justify-center rounded-full border border-slate-200 bg-white/85 px-7 text-base font-medium text-slate-800 shadow-sm backdrop-blur transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-70 dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-100 dark:hover:bg-slate-900"
> >
{t.landing.actions.watchDemo} {isStartingDemo ? t.demo?.starting || "Preparing demo..." : t.landing.actions.watchDemo}
</a> </button>
</div> </div>
<div className="animate-landing-rise grid gap-3 sm:grid-cols-3 [animation-delay:320ms]"> <div className="animate-landing-rise grid gap-3 sm:grid-cols-3 [animation-delay:320ms]">