feat(demo): start sandbox from landing
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
emitSessionChanged,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
isDemoSession,
|
||||
} from "../lib/session"
|
||||
|
||||
let refreshRequest: Promise<string | null> | null = null
|
||||
@@ -88,9 +89,10 @@ const normalizeJsonResponse = (response: Response) => {
|
||||
}
|
||||
|
||||
const clearSessionAndRedirect = () => {
|
||||
const redirectTarget = isDemoSession() ? "/" : "/auth"
|
||||
clearSessionTokens()
|
||||
if (window.location.pathname !== "/auth") {
|
||||
window.location.href = "/auth"
|
||||
if (window.location.pathname !== redirectTarget) {
|
||||
window.location.href = redirectTarget
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +147,7 @@ const shouldAttemptRefresh = (endpoint: string) => {
|
||||
"/api/users/otp/send/",
|
||||
"/api/users/otp/login/",
|
||||
"/api/users/token/refresh/",
|
||||
"/api/demo/start/",
|
||||
].includes(normalizedEndpoint)
|
||||
}
|
||||
|
||||
|
||||
17
src/api/demo.ts
Normal file
17
src/api/demo.ts
Normal 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()
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export const SESSION_CHANGED_EVENT = "auth_session_changed"
|
||||
const DEMO_EXPIRES_AT_KEY = "demoExpiresAt"
|
||||
|
||||
export const getAccessToken = () => localStorage.getItem("accessToken")
|
||||
|
||||
@@ -14,8 +15,21 @@ export const setSessionTokens = (accessToken: string, refreshToken: string) => {
|
||||
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 = () => {
|
||||
localStorage.removeItem("accessToken")
|
||||
localStorage.removeItem("refreshToken")
|
||||
localStorage.removeItem(DEMO_EXPIRES_AT_KEY)
|
||||
emitSessionChanged()
|
||||
}
|
||||
|
||||
@@ -485,6 +485,15 @@ export const en = {
|
||||
finalCtaDescription:
|
||||
"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: {
|
||||
createdAtDesc: "Newest First",
|
||||
|
||||
@@ -482,6 +482,15 @@ export const fa = {
|
||||
finalCtaDescription:
|
||||
"اپ را باز کنید، ورکاسپیس بسازید و ببینید وقتی محصول نشت بستر را متوقف میکند، انضباط گزارشدهی چقدر سریع بهتر میشود.",
|
||||
},
|
||||
demo: {
|
||||
badge: "محیط دمو",
|
||||
starting: "در حال آمادهسازی دمو...",
|
||||
started: "محیط دمو آماده شد.",
|
||||
startError: "امکان ساخت محیط دمو وجود ندارد.",
|
||||
expiresAt: "زمان انقضا",
|
||||
resetAction: "شروع دوباره دمو",
|
||||
reset: "محیط دموی تازه آماده شد.",
|
||||
},
|
||||
|
||||
ordering: {
|
||||
createdAtDesc: "جدیدترین",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -15,11 +15,14 @@ import {
|
||||
TimerReset,
|
||||
Waypoints,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "../components/ui/button"
|
||||
import { useTheme } from "../components/ThemeProvider"
|
||||
import { useTranslation } from "../hooks/useTranslation"
|
||||
import { cn } from "../lib/utils"
|
||||
import { startDemo } from "../api/demo"
|
||||
import { setDemoSessionMeta, setSessionTokens } from "../lib/session"
|
||||
|
||||
const formatNumber = (value: number, lang: "en" | "fa") =>
|
||||
new Intl.NumberFormat(lang === "fa" ? "fa-IR" : "en-US").format(value)
|
||||
@@ -28,6 +31,7 @@ export default function Landing() {
|
||||
const navigate = useNavigate()
|
||||
const { t, lang, setLanguage } = useTranslation()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [isStartingDemo, setIsStartingDemo] = useState(false)
|
||||
|
||||
const isAuthenticated = typeof window !== "undefined" && !!localStorage.getItem("accessToken")
|
||||
const isDarkMode =
|
||||
@@ -87,6 +91,23 @@ export default function Landing() {
|
||||
|
||||
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 (
|
||||
<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" />
|
||||
@@ -164,12 +185,14 @@ export default function Landing() {
|
||||
{isAuthenticated ? t.landing.actions.openWorkspace : t.landing.actions.startNow}
|
||||
<ArrowRight className={cn("ms-2 h-4 w-4", lang === "fa" && "rtl:rotate-180")} />
|
||||
</Button>
|
||||
<a
|
||||
href="#demo"
|
||||
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"
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
</a>
|
||||
{isStartingDemo ? t.demo?.starting || "Preparing demo..." : t.landing.actions.watchDemo}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="animate-landing-rise grid gap-3 sm:grid-cols-3 [animation-delay:320ms]">
|
||||
|
||||
Reference in New Issue
Block a user