diff --git a/src/api/client.ts b/src/api/client.ts index cde271f..a93a6cc 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -10,6 +10,7 @@ import { emitSessionChanged, getAccessToken, getRefreshToken, + isDemoSession, } from "../lib/session" let refreshRequest: Promise | 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) } diff --git a/src/api/demo.ts b/src/api/demo.ts new file mode 100644 index 0000000..95ef4b0 --- /dev/null +++ b/src/api/demo.ts @@ -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 => { + const response = await authFetch("/api/demo/start/", { + method: "POST", + }) + if (!response.ok) throw await buildApiError(response) + return response.json() +} diff --git a/src/lib/session.ts b/src/lib/session.ts index 55e1181..85161e0 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -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() } diff --git a/src/locales/en.ts b/src/locales/en.ts index 9d33180..59d65ef 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -396,7 +396,7 @@ export const en = { collapse: 'Collapse', }, - landing: { + landing: { brandLabel: "Operating system for time", eyebrow: "Built for high-discipline teams that need clean time intelligence", nav: { @@ -484,7 +484,16 @@ export const en = { finalCtaTitle: "If your team sells expertise or ships client work, your time system should look this serious.", 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", diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 6213add..d10c8e5 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -393,7 +393,7 @@ export const fa = { collapse: 'جمع کردن', }, - landing: { + landing: { brandLabel: "زیرساخت عملیاتی زمان", eyebrow: "طراحی‌شده برای تیم‌های دقیق که به داده زمانی قابل اتکا نیاز دارند", nav: { @@ -481,7 +481,16 @@ export const fa = { finalCtaTitle: "اگر تیم شما تخصص می‌فروشد یا پروژه مشتری تحویل می‌دهد، سیستم زمان شما هم باید همین‌قدر جدی باشد.", finalCtaDescription: "اپ را باز کنید، ورک‌اسپیس بسازید و ببینید وقتی محصول نشت بستر را متوقف می‌کند، انضباط گزارش‌دهی چقدر سریع بهتر می‌شود.", - }, + }, + demo: { + badge: "محیط دمو", + starting: "در حال آماده‌سازی دمو...", + started: "محیط دمو آماده شد.", + startError: "امکان ساخت محیط دمو وجود ندارد.", + expiresAt: "زمان انقضا", + resetAction: "شروع دوباره دمو", + reset: "محیط دموی تازه آماده شد.", + }, ordering: { createdAtDesc: "جدیدترین", diff --git a/src/pages/Landing.tsx b/src/pages/Landing.tsx index 12bb9c7..5f26730 100644 --- a/src/pages/Landing.tsx +++ b/src/pages/Landing.tsx @@ -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 (
@@ -164,12 +185,14 @@ export default function Landing() { {isAuthenticated ? t.landing.actions.openWorkspace : t.landing.actions.startNow} - - {t.landing.actions.watchDemo} - + {isStartingDemo ? t.demo?.starting || "Preparing demo..." : t.landing.actions.watchDemo} +