diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..14c8fef --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# ============================================================================= +# 前端本地配置示例 +# ============================================================================= + +# Next 开发服务代理目标:浏览器请求 /api/* 时由 Next 转发到这里。 +# 默认值已经在 next.config.ts 中兜底为 http://127.0.0.1:8000;本地 Laravel 端口不同时再改。 +LOTTERY_API_PROXY_TARGET=http://127.0.0.1:8000 + +# 可选:如果设置此值,浏览器会绕过 Next 代理,直接请求该 API 地址。 +# 一般本地开发建议留空,让请求走同源 /api 代理,避免 CORS。 +# NEXT_PUBLIC_LOTTERY_API_BASE_URL=http://127.0.0.1:8000 + +# 可选:入口授权失败时“返回主站重新进入”的地址。 +# NEXT_PUBLIC_MAIN_SITE_URL=http://localhost:5173 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5ef6a52..7b8da95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/next.config.ts b/next.config.ts index 66e1566..2372b96 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,19 @@ import type { NextConfig } from "next"; +const lotteryApiProxyTarget = + process.env.LOTTERY_API_PROXY_TARGET?.trim() || "http://127.0.0.1:8000"; + const nextConfig: NextConfig = { - /* config options here */ reactCompiler: true, + + async rewrites() { + return [ + { + source: "/api/:path*", + destination: `${lotteryApiProxyTarget}/api/:path*`, + }, + ]; + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index d9a8fbd..2ddc091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "react-dom": "19.2.4", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zustand": "^5.0.13" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -10464,6 +10465,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 93be0b2..17f7d14 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "react-dom": "19.2.4", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zustand": "^5.0.13" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/(player)/(main)/hall/page.tsx b/src/app/(player)/(main)/hall/page.tsx new file mode 100644 index 0000000..fec4ced --- /dev/null +++ b/src/app/(player)/(main)/hall/page.tsx @@ -0,0 +1,24 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +/** 下注大厅占位(§4.2 再接表格与期号) */ +export default function LotteryHallPlaceholderPage() { + return ( + + + 下注大厅 + + 路由已打通;玩法表格、期号与余额将在此页迭代。 + + + + 从入口页授权成功后可刷新本页;鉴权头会从会话存储恢复。 + + + ); +} diff --git a/src/app/(player)/(main)/layout.tsx b/src/app/(player)/(main)/layout.tsx new file mode 100644 index 0000000..942eb6f --- /dev/null +++ b/src/app/(player)/(main)/layout.tsx @@ -0,0 +1,15 @@ +import { PlayerAppShell } from "@/components/layout/player-app-shell"; +import { HydratePlayerAuth } from "@/features/player/hydrate-player-auth"; + +export default function PlayerMainLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + <> + + {children} + + ); +} diff --git a/src/app/(player)/layout.tsx b/src/app/(player)/layout.tsx index 744671a..5fd7b57 100644 --- a/src/app/(player)/layout.tsx +++ b/src/app/(player)/layout.tsx @@ -1,9 +1,7 @@ -import { PlayerAppShell } from "@/components/layout/player-app-shell"; - -export default function PlayerLayout({ +export default function PlayerRootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - return {children}; + return children; } diff --git a/src/app/(player)/page.tsx b/src/app/(player)/page.tsx index 82d0d01..bb34605 100644 --- a/src/app/(player)/page.tsx +++ b/src/app/(player)/page.tsx @@ -1,23 +1,20 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import type { ReactNode } from "react"; +import { Suspense } from "react"; -export default function PlayerHomePage() { +import { EntryGate } from "@/features/player/entry-gate"; + +function EntryFallback(): ReactNode { return ( - - - 玩家端 - - 基础 layout 已就绪;后续在此挂钱包、大厅等路由。 - - - - 顶栏与主区域宽度由 PlayerAppShell 统一控制。 - - +
+

加载入口页…

+
+ ); +} + +export default function EntryPage() { + return ( + }> + + ); } diff --git a/src/features/player/entry-gate.tsx b/src/features/player/entry-gate.tsx new file mode 100644 index 0000000..d83d6d9 --- /dev/null +++ b/src/features/player/entry-gate.tsx @@ -0,0 +1,389 @@ +"use client"; + +import { isAxiosError } from "axios"; +import { + AlertTriangle, + Bell, + Check, + ChevronRight, + Languages, + Loader2, + Shield, +} from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import type { ReactNode } from "react"; +import { useCallback, useEffect } from "react"; + +import { getPlayerMe, getPlayerPing } from "@/api/player"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { usePlayerSessionStore } from "@/stores/player-session-store"; +import { LotteryApiBizError } from "@/types/api/errors"; + +const MAIN_SITE_URL = process.env.NEXT_PUBLIC_MAIN_SITE_URL?.trim() ?? ""; + +const RETRY_ATTEMPTS = 3; +const RETRY_DELAY_MS = 2000; + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function shouldRetryEntryRequest(error: unknown): boolean { + if (error instanceof LotteryApiBizError) { + return false; + } + if (isAxiosError(error)) { + if (error.code === "ECONNABORTED") { + return true; + } + if (!error.response) { + return true; + } + const s = error.response.status; + return s >= 500 || s === 429; + } + return false; +} + +async function withEntryRetries(fn: () => Promise): Promise { + let last: unknown; + for (let i = 0; i < RETRY_ATTEMPTS; i++) { + try { + return await fn(); + } catch (e) { + last = e; + if (!shouldRetryEntryRequest(e) || i === RETRY_ATTEMPTS - 1) { + throw e; + } + await sleep(RETRY_DELAY_MS); + } + } + throw last; +} + +function normalizeTokenInput(raw: string | null): string | null { + if (raw === null) { + return null; + } + const t = decodeURIComponent(raw).trim(); + return t === "" ? null : t; +} + +function stripTokenFromUrl(): void { + if (typeof window === "undefined") { + return; + } + const url = new URL(window.location.href); + if (!url.searchParams.has("token")) { + return; + } + url.searchParams.delete("token"); + const qs = url.searchParams.toString(); + window.history.replaceState( + {}, + "", + `${url.pathname}${qs ? `?${qs}` : ""}${url.hash}`, + ); +} + +export function EntryGate(): ReactNode { + const router = useRouter(); + const searchParams = useSearchParams(); + + const phase = usePlayerSessionStore((state) => state.phase); + const progress = usePlayerSessionStore((state) => state.progress); + const errorMessage = usePlayerSessionStore((state) => state.errorMessage); + const steps = usePlayerSessionStore((state) => state.steps); + const setBearerToken = usePlayerSessionStore((state) => state.setBearerToken); + const restoreBearerToken = usePlayerSessionStore( + (state) => state.restoreBearerToken, + ); + const clearBearerToken = usePlayerSessionStore( + (state) => state.clearBearerToken, + ); + const setProfile = usePlayerSessionStore((state) => state.setProfile); + const setPhase = usePlayerSessionStore((state) => state.setPhase); + const setProgress = usePlayerSessionStore((state) => state.setProgress); + const setErrorMessage = usePlayerSessionStore( + (state) => state.setErrorMessage, + ); + const updateStep = usePlayerSessionStore((state) => state.updateStep); + const resetEntryFlow = usePlayerSessionStore((state) => state.resetEntryFlow); + + const applyProgress = useCallback( + (doneCount: number) => { + setProgress(Math.round((doneCount / 3) * 100)); + }, + [setProgress], + ); + + const runBootstrap = useCallback(async () => { + resetEntryFlow(); + + const fromQuery = normalizeTokenInput(searchParams.get("token")); + const fromStorage = normalizeTokenInput(restoreBearerToken()); + const token = fromQuery ?? fromStorage; + + if (fromQuery) { + stripTokenFromUrl(); + } + + if (!token) { + setPhase("error"); + setErrorMessage("缺少登录凭证,请从主站重新进入彩票系统。"); + updateStep("token", "pending"); + applyProgress(0); + return; + } + + setBearerToken(token); + + try { + updateStep("token", "done"); + applyProgress(1); + updateStep("account", "active"); + + const profile = await withEntryRetries(() => getPlayerMe()); + setProfile(profile); + + updateStep("account", "done"); + applyProgress(2); + updateStep("hall", "active"); + + await withEntryRetries(() => getPlayerPing()); + + updateStep("hall", "done"); + applyProgress(3); + setProgress(100); + setPhase("success"); + } catch (e) { + const authFailure = + e instanceof LotteryApiBizError || + (isAxiosError(e) && e.response?.status === 401); + if (authFailure) { + clearBearerToken(); + } + setPhase("error"); + if (e instanceof LotteryApiBizError) { + setErrorMessage( + e.code === 8001 || e.code === 8002 + ? "授权已失效,请从主站重新进入。" + : e.message, + ); + } else if (isAxiosError(e) && !e.response) { + setErrorMessage( + `网络异常,已重试 ${RETRY_ATTEMPTS} 次仍失败,请稍后再试。`, + ); + } else if (isAxiosError(e)) { + setErrorMessage(e.message || "请求失败,请稍后重试。"); + } else { + setErrorMessage( + e instanceof Error ? e.message : "进入彩票系统失败,请稍后重试。", + ); + } + } + }, [ + applyProgress, + clearBearerToken, + resetEntryFlow, + restoreBearerToken, + searchParams, + setBearerToken, + setErrorMessage, + setPhase, + setProfile, + setProgress, + updateStep, + ]); + + useEffect(() => { + void runBootstrap(); + }, [runBootstrap]); + + return ( +
+
+ Lottery +
+ + +
+
+ +
+
+

+ Play smart. Win more. +

+ + {phase === "loading" || phase === "success" ? ( + + + + {phase === "success" + ? "授权成功" + : "正在进入彩票系统"} + + + {phase === "success" + ? "即将跳转至下注大厅" + : "请稍候,正在连接服务器"} + + + +
+
+ 进度 + {progress}% +
+
+
+
+
+
    + {steps.map((s) => ( +
  • + {s.status === "done" ? ( + + ) : s.status === "active" ? ( + + ) : ( + + )} + {s.label} + + {s.status === "done" + ? "完成" + : s.status === "active" + ? "进行中" + : "等待"} + +
  • + ))} +
+ + {phase === "success" ? ( + + + + ) : null} + + ) : null} + + {phase === "error" ? ( + + +
+ +
+ 授权失败 + + {errorMessage} + +
+ +

+ 常见原因 +

+
    +
  • • Token 无效或已过期
  • +
  • • 账号未授权或未建档
  • +
  • • 会话校验失败
  • +
+
+ + {MAIN_SITE_URL ? ( + + 返回主站重新进入 + + ) : ( + + )} + + +
+ ) : null} +
+
+ +
+ + Secure · Trusted · Authorized access +
+
+ ); +} diff --git a/src/features/player/hydrate-player-auth.tsx b/src/features/player/hydrate-player-auth.tsx new file mode 100644 index 0000000..c88341d --- /dev/null +++ b/src/features/player/hydrate-player-auth.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect } from "react"; + +import { usePlayerSessionStore } from "@/stores/player-session-store"; + +/** 从 sessionStorage 恢复 Bearer,避免 `/hall` 等子路由刷新后丢失鉴权头 */ +export function HydratePlayerAuth(): null { + const restoreBearerToken = usePlayerSessionStore( + (state) => state.restoreBearerToken, + ); + + useEffect(() => { + restoreBearerToken(); + }, [restoreBearerToken]); + + return null; +} diff --git a/src/lib/lottery-auth.ts b/src/lib/lottery-auth.ts new file mode 100644 index 0000000..90279ae --- /dev/null +++ b/src/lib/lottery-auth.ts @@ -0,0 +1,36 @@ +import { AxiosHeaders, type AxiosRequestConfig } from "axios"; + +/** `Bearer ` 后的原始串:`dev:1`、JWT 等 */ +let playerBearerPayload: string | null = null; + +/** + * 设置玩家鉴权 Token(仅作用于彩票 API 请求)。 + * 传入 `null` 或 trim 后空串则清除。 + */ +export function setPlayerBearerToken(token: string | null): void { + if (token === null || token.trim() === "") { + playerBearerPayload = null; + return; + } + const t = token.trim(); + playerBearerPayload = t.startsWith("Bearer ") ? t.slice(7).trim() : t; +} + +export function getPlayerBearerTokenPayload(): string | null { + return playerBearerPayload; +} + +export function withPlayerAuthHeader( + config: AxiosRequestConfig, +): AxiosRequestConfig { + if (!playerBearerPayload) { + return config; + } + const merged: AxiosRequestConfig = { ...config }; + const headers = AxiosHeaders.concat( + merged.headers as Parameters[0], + ); + headers.set("Authorization", `Bearer ${playerBearerPayload}`); + merged.headers = headers; + return merged; +} \ No newline at end of file diff --git a/src/lib/lottery-http.ts b/src/lib/lottery-http.ts index 818c4fd..d3ca12b 100644 --- a/src/lib/lottery-http.ts +++ b/src/lib/lottery-http.ts @@ -1,52 +1,17 @@ import axios, { - AxiosHeaders, isAxiosError, type AxiosRequestConfig, type AxiosResponse, } from "axios"; +import { withPlayerAuthHeader } from "@/lib/lottery-auth"; +import { withLotteryLocaleHeaders } from "@/lib/lottery-locale"; import { LotteryApiBizError, LotteryApiEnvelopeError, } from "@/types/api/errors"; import { isApiEnvelope } from "@/types/api/envelope"; -export function setLotteryRequestLocale(locale: string | null): void { - if (locale === null) { - overrideLocale = null; - return; - } - const p = locale.trim().toLowerCase().split("-")[0] ?? ""; - overrideLocale = - p === "zh" || p === "en" || p === "ne" ? (p as "zh" | "en" | "ne") : null; -} - -let overrideLocale: "zh" | "en" | "ne" | null = null; - -function requestLocale(): "zh" | "en" | "ne" { - if (overrideLocale) { - return overrideLocale; - } - if (typeof document !== "undefined") { - const tag = document.documentElement.lang.trim().toLowerCase(); - const primary = tag.split("-")[0] ?? tag; - if (primary === "zh" || primary === "en" || primary === "ne") { - return primary; - } - } - return "en"; -} - -function acceptLanguage(loc: ReturnType): string { - if (loc === "zh") { - return "zh-CN,zh;q=0.9,en;q=0.8"; - } - if (loc === "ne") { - return "ne,ne-NP;q=0.9,en;q=0.8"; - } - return "en-US,en;q=0.9"; -} - const baseURL = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim(); /** @@ -58,21 +23,6 @@ export const lotteryHttp = axios.create({ headers: { Accept: "application/json" }, }); -function mergeLocaleHeaders( - config: AxiosRequestConfig, -): AxiosRequestConfig { - const loc = requestLocale(); - const merged: AxiosRequestConfig = { ...config }; - // Axios 的 RequestConfig.headers 类型比 concat 能接受的头对象更窄 - const headers = AxiosHeaders.concat( - merged.headers as Parameters[0], - ); - headers.set("X-Locale", loc); - headers.set("Accept-Language", acceptLanguage(loc)); - merged.headers = headers; - return merged; -} - /** * 对 **payload**(通常是 `response.data`)校验信封并成功时返回 `data`; * `code !== 0` 抛 {@link LotteryApiBizError}。 @@ -93,11 +43,11 @@ export function unwrapResponse(res: AxiosResponse): T { } /** - * **第二层**:自动带语言头,用 `lotteryHttp` 发请求,再 `unwrapResponse`。 + * **第二层**:补齐玩家鉴权与语言头,用 `lotteryHttp` 发请求,再 `unwrapResponse`。 * 是否提示用户由各页面/特性自己 `catch` 决定。 */ export async function request(config: AxiosRequestConfig): Promise { - const merged = mergeLocaleHeaders(config); + const merged = withPlayerAuthHeader(withLotteryLocaleHeaders(config)); try { const res = await lotteryHttp.request(merged); return unwrapResponse(res); @@ -139,4 +89,4 @@ export const lotteryRequest = { data?: unknown, config?: Omit, ) => request({ ...config, url, method: "PATCH", data }), -}; +}; \ No newline at end of file diff --git a/src/lib/lottery-locale.ts b/src/lib/lottery-locale.ts new file mode 100644 index 0000000..7cccb5e --- /dev/null +++ b/src/lib/lottery-locale.ts @@ -0,0 +1,57 @@ +import { AxiosHeaders, type AxiosRequestConfig } from "axios"; + +type LotteryLocale = "zh" | "en" | "ne"; + +let overrideLocale: LotteryLocale | null = null; + +export function setLotteryRequestLocale(locale: string | null): void { + if (locale === null) { + overrideLocale = null; + return; + } + const p = locale.trim().toLowerCase().split("-")[0] ?? ""; + overrideLocale = isLotteryLocale(p) ? p : null; +} + +function isLotteryLocale(value: string): value is LotteryLocale { + return value === "zh" || value === "en" || value === "ne"; +} + +function requestLocale(): LotteryLocale { + if (overrideLocale) { + return overrideLocale; + } + if (typeof document !== "undefined") { + const tag = document.documentElement.lang.trim().toLowerCase(); + const primary = tag.split("-")[0] ?? tag; + if (isLotteryLocale(primary)) { + return primary; + } + } + return "en"; +} + +function acceptLanguage(loc: LotteryLocale): string { + if (loc === "zh") { + return "zh-CN,zh;q=0.9,en;q=0.8"; + } + if (loc === "ne") { + return "ne,ne-NP;q=0.9,en;q=0.8"; + } + return "en-US,en;q=0.9"; +} + +export function withLotteryLocaleHeaders( + config: AxiosRequestConfig, +): AxiosRequestConfig { + const loc = requestLocale(); + const merged: AxiosRequestConfig = { ...config }; + // Axios 的 RequestConfig.headers 类型比 concat 能接受的头对象更窄 + const headers = AxiosHeaders.concat( + merged.headers as Parameters[0], + ); + headers.set("X-Locale", loc); + headers.set("Accept-Language", acceptLanguage(loc)); + merged.headers = headers; + return merged; +} \ No newline at end of file diff --git a/src/lib/player-session.ts b/src/lib/player-session.ts new file mode 100644 index 0000000..91627ba --- /dev/null +++ b/src/lib/player-session.ts @@ -0,0 +1,26 @@ +const STORAGE_KEY = "lottery.player.bearer"; + +/** sessionStorage:标签页关闭即清空,符合 H5 嵌入主站场景 */ +export function persistPlayerBearerToken(raw: string): void { + try { + sessionStorage.setItem(STORAGE_KEY, raw.trim()); + } catch { + /* ignore quota / private mode */ + } +} + +export function readPersistedPlayerBearerToken(): string | null { + try { + return sessionStorage.getItem(STORAGE_KEY); + } catch { + return null; + } +} + +export function clearPersistedPlayerBearerToken(): void { + try { + sessionStorage.removeItem(STORAGE_KEY); + } catch { + /* ignore */ + } +} diff --git a/src/stores/player-session-store.ts b/src/stores/player-session-store.ts new file mode 100644 index 0000000..af9e61e --- /dev/null +++ b/src/stores/player-session-store.ts @@ -0,0 +1,102 @@ +import { create } from "zustand"; + +import { setPlayerBearerToken } from "@/lib/lottery-auth"; +import { + clearPersistedPlayerBearerToken, + persistPlayerBearerToken, + readPersistedPlayerBearerToken, +} from "@/lib/player-session"; +import type { PlayerMeData } from "@/types/api/player-me"; + +export type PlayerEntryPhase = "loading" | "error" | "success"; +export type PlayerEntryStepId = "token" | "account" | "hall"; +export type PlayerEntryStepStatus = "pending" | "active" | "done"; + +export type PlayerEntryStep = { + id: PlayerEntryStepId; + label: string; + status: PlayerEntryStepStatus; +}; + +type PlayerSessionState = { + bearerToken: string | null; + profile: PlayerMeData | null; + phase: PlayerEntryPhase; + progress: number; + errorMessage: string | null; + steps: PlayerEntryStep[]; + setBearerToken: (token: string | null) => void; + restoreBearerToken: () => string | null; + clearBearerToken: () => void; + setProfile: (profile: PlayerMeData | null) => void; + setPhase: (phase: PlayerEntryPhase) => void; + setProgress: (progress: number) => void; + setErrorMessage: (message: string | null) => void; + updateStep: (id: PlayerEntryStepId, status: PlayerEntryStepStatus) => void; + resetEntryFlow: () => void; +}; + +function initialSteps(): PlayerEntryStep[] { + return [ + { id: "token", label: "校验登录凭证", status: "active" }, + { id: "account", label: "同步玩家账号", status: "pending" }, + { id: "hall", label: "加载彩票大厅", status: "pending" }, + ]; +} + +export const usePlayerSessionStore = create((set) => ({ + bearerToken: null, + profile: null, + phase: "loading", + progress: 0, + errorMessage: null, + steps: initialSteps(), + + setBearerToken: (token) => { + const normalized = token?.trim() || null; + setPlayerBearerToken(normalized); + if (normalized) { + persistPlayerBearerToken(normalized); + } else { + clearPersistedPlayerBearerToken(); + } + set({ bearerToken: normalized }); + }, + + restoreBearerToken: () => { + const raw = readPersistedPlayerBearerToken(); + const normalized = raw?.trim() || null; + if (normalized) { + setPlayerBearerToken(normalized); + set({ bearerToken: normalized }); + return normalized; + } + setPlayerBearerToken(null); + set({ bearerToken: null }); + return null; + }, + + clearBearerToken: () => { + setPlayerBearerToken(null); + clearPersistedPlayerBearerToken(); + set({ bearerToken: null, profile: null }); + }, + + setProfile: (profile) => set({ profile }), + setPhase: (phase) => set({ phase }), + setProgress: (progress) => set({ progress: Math.max(0, Math.min(100, progress)) }), + setErrorMessage: (errorMessage) => set({ errorMessage }), + updateStep: (id, status) => + set((state) => ({ + steps: state.steps.map((step) => + step.id === id ? { ...step, status } : step, + ), + })), + resetEntryFlow: () => + set({ + phase: "loading", + progress: 0, + errorMessage: null, + steps: initialSteps(), + }), +})); \ No newline at end of file