auth-provider.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. "use client";
  2. import {
  3. createContext,
  4. useCallback,
  5. useContext,
  6. useEffect,
  7. useMemo,
  8. useState,
  9. type ReactNode,
  10. } from "react";
  11. import {
  12. clearUser,
  13. ensureThresholdReachedAt,
  14. loadUser,
  15. saveUser,
  16. sumOrders,
  17. type MockOrder,
  18. type UserSession,
  19. } from "@/lib/auth-types";
  20. import { SPEND_THRESHOLD_USD } from "@/lib/quiz-rules";
  21. import { ApiError, setApiAuthToken } from "@/lib/api";
  22. import { loginWithPassword } from "@/lib/auth-api";
  23. import { isRegisterPasswordValid } from "@/lib/password-rules";
  24. import { registerWithEmail } from "@/lib/register-api";
  25. import { fetchCustomUserInfo } from "@/lib/user-info-api";
  26. type AuthContextValue = {
  27. user: UserSession | null;
  28. isReady: boolean;
  29. login: (
  30. email: string,
  31. password: string,
  32. ) => Promise<{ ok: boolean; error?: string; message?: string }>;
  33. register: (input: {
  34. country: string;
  35. email: string;
  36. password: string;
  37. name: string;
  38. code: string;
  39. }) => Promise<{ ok: boolean; error?: string; message?: string }>;
  40. logout: () => void;
  41. updateProfile: (
  42. patch: Partial<Pick<UserSession, "name" | "phone" | "identity" | "scholarship">>,
  43. ) => void;
  44. addMockOrder: (order: Omit<MockOrder, "id" | "createdAt">) => void;
  45. /** 本地演示:将门槛达成时间设为 181 天前,用于验证问答解锁 */
  46. demoUnlockQuizCountdown: () => void;
  47. };
  48. const AuthContext = createContext<AuthContextValue | null>(null);
  49. function newUserSession(
  50. email: string,
  51. name: string,
  52. seedOrders: MockOrder[] = [],
  53. ): UserSession {
  54. const thresholdReachedAt = ensureThresholdReachedAt(seedOrders, null);
  55. return {
  56. email,
  57. name,
  58. phone: "",
  59. identity: "",
  60. thresholdReachedAt,
  61. orders: seedOrders,
  62. scholarship: {},
  63. };
  64. }
  65. export function AuthProvider({ children }: { children: ReactNode }) {
  66. const [user, setUser] = useState<UserSession | null>(null);
  67. const [isReady, setIsReady] = useState(false);
  68. useEffect(() => {
  69. /* eslint-disable react-hooks/set-state-in-effect -- hydrate once from localStorage after mount */
  70. setUser(loadUser());
  71. setIsReady(true);
  72. /* eslint-enable react-hooks/set-state-in-effect */
  73. }, []);
  74. const persist = useCallback((next: UserSession | null) => {
  75. setUser(next);
  76. if (next) saveUser(next);
  77. else clearUser();
  78. }, []);
  79. const login = useCallback(
  80. async (email: string, password: string) => {
  81. if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
  82. return { ok: false, error: "invalid_email" };
  83. }
  84. try {
  85. const { token, displayName } = await loginWithPassword(
  86. email.trim(),
  87. password,
  88. );
  89. if (token) setApiAuthToken(token);
  90. const base = newUserSession(email.trim(), displayName);
  91. try {
  92. const info = await fetchCustomUserInfo();
  93. persist({
  94. ...base,
  95. email: info.email || base.email,
  96. name: info.name || base.name,
  97. phone: info.phone || base.phone,
  98. identity: info.identity || base.identity,
  99. });
  100. } catch {
  101. persist(base);
  102. }
  103. return { ok: true };
  104. } catch (e) {
  105. const message = e instanceof ApiError ? e.message : "登录失败";
  106. return { ok: false, error: "api", message };
  107. }
  108. },
  109. [persist],
  110. );
  111. const register = useCallback(
  112. async (input: {
  113. country: string;
  114. email: string;
  115. password: string;
  116. name: string;
  117. code: string;
  118. }) => {
  119. if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
  120. return { ok: false, error: "invalid_email" };
  121. }
  122. if (!isRegisterPasswordValid(input.password)) {
  123. return { ok: false, error: "weak_password" };
  124. }
  125. if (!input.code.trim()) {
  126. return { ok: false, error: "invalid_code" };
  127. }
  128. if (!input.country.trim()) {
  129. return { ok: false, error: "country_required" };
  130. }
  131. try {
  132. const { token } = await registerWithEmail({
  133. country: input.country.trim(),
  134. name: input.name.trim() || "学员",
  135. email: input.email.trim(),
  136. emailCode: input.code.trim(),
  137. password: input.password,
  138. });
  139. if (token) setApiAuthToken(token);
  140. const base = newUserSession(input.email.trim(), input.name.trim() || "学员");
  141. try {
  142. const info = await fetchCustomUserInfo();
  143. persist({
  144. ...base,
  145. email: info.email || base.email,
  146. name: info.name || base.name,
  147. phone: info.phone || base.phone,
  148. identity: info.identity || base.identity,
  149. });
  150. } catch {
  151. persist(base);
  152. }
  153. return { ok: true };
  154. } catch (e) {
  155. const message = e instanceof ApiError ? e.message : "注册失败";
  156. return { ok: false, error: "api", message };
  157. }
  158. },
  159. [persist],
  160. );
  161. const logout = useCallback(() => {
  162. setApiAuthToken(null);
  163. persist(null);
  164. }, [persist]);
  165. const updateProfile = useCallback(
  166. (
  167. patch: Partial<
  168. Pick<UserSession, "name" | "phone" | "identity" | "scholarship">
  169. >,
  170. ) => {
  171. if (!user) return;
  172. persist({
  173. ...user,
  174. ...patch,
  175. name: patch.name ?? user.name,
  176. phone: patch.phone ?? user.phone,
  177. identity: patch.identity ?? user.identity,
  178. scholarship: { ...user.scholarship, ...patch.scholarship },
  179. });
  180. },
  181. [user, persist],
  182. );
  183. const addMockOrder = useCallback(
  184. (order: Omit<MockOrder, "id" | "createdAt">) => {
  185. if (!user) return;
  186. const nextOrder: MockOrder = {
  187. ...order,
  188. id: `ord_${Date.now()}`,
  189. createdAt: new Date().toISOString(),
  190. };
  191. const orders = [...user.orders, nextOrder];
  192. const thresholdReachedAt = ensureThresholdReachedAt(
  193. orders,
  194. user.thresholdReachedAt,
  195. );
  196. persist({
  197. ...user,
  198. orders,
  199. thresholdReachedAt,
  200. });
  201. },
  202. [user, persist],
  203. );
  204. const demoUnlockQuizCountdown = useCallback(() => {
  205. if (!user) return;
  206. let orders = user.orders;
  207. if (sumOrders(orders) < SPEND_THRESHOLD_USD) {
  208. orders = [
  209. ...orders,
  210. {
  211. id: `ord_demo_${Date.now()}`,
  212. amount: SPEND_THRESHOLD_USD,
  213. createdAt: new Date().toISOString(),
  214. title: "演示订单(验证问答解锁)",
  215. },
  216. ];
  217. }
  218. const past = new Date();
  219. past.setDate(past.getDate() - 181);
  220. persist({
  221. ...user,
  222. orders,
  223. thresholdReachedAt: past.toISOString(),
  224. });
  225. }, [user, persist]);
  226. const value = useMemo<AuthContextValue>(
  227. () => ({
  228. user,
  229. isReady,
  230. login,
  231. register,
  232. logout,
  233. updateProfile,
  234. addMockOrder,
  235. demoUnlockQuizCountdown,
  236. }),
  237. [
  238. user,
  239. isReady,
  240. login,
  241. register,
  242. logout,
  243. updateProfile,
  244. addMockOrder,
  245. demoUnlockQuizCountdown,
  246. ],
  247. );
  248. return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
  249. }
  250. export function useAuth() {
  251. const ctx = useContext(AuthContext);
  252. if (!ctx) throw new Error("useAuth must be used within AuthProvider");
  253. return ctx;
  254. }