From f7009b11e8cface599f38ac25db16d0812cf3181 Mon Sep 17 00:00:00 2001 From: AliReza Date: Sat, 17 Oct 2026 19:57:42 +0330 Subject: [PATCH] Instant home on reopen: snapshot of profile/grades/map/stats on the device, refreshed behind the scenes A signed-in user with a snapshot goes straight to home; the same boot requests run in the background and replace it. Snapshot is cleared whenever tokens are cleared and on a new login, ignored from another build or when older than 7 days, and dropped when the fresh profile is a different user (reboot remounts MainApp via a generation key; AnimatePresence otherwise kept the exiting one). Notifications/characters wait for the profile answer so an expired access token costs the same requests as before. refreshHome applies only the latest answer. Home shows a "saved data" strip when the map could not be refreshed. Measured locally (old vs new build, same backend, Chrome throttling, median of 3, time to map): Fast 3G 2.46s -> 1.80s (expired token 3.63s -> 1.80s); Slow 3G 8.52s -> 6.14s (expired 12.6s -> 6.13s); API requests per open unchanged (6 valid, 11 expired). 12/12 behaviour checks passed. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++ src/api/index.ts | 19 +++++++- src/app/App.tsx | 34 +++++++++++-- src/app/MainApp.tsx | 13 ++++- src/lib/storage.ts | 2 + src/screens/HomeScreen.tsx | 23 +++++++-- src/state/AppData.tsx | 98 ++++++++++++++++++++++++++++++++------ src/state/homeSnapshot.ts | 63 ++++++++++++++++++++++++ src/vite-env.d.ts | 3 ++ vite.config.ts | 3 ++ 10 files changed, 238 insertions(+), 24 deletions(-) create mode 100644 src/state/homeSnapshot.ts diff --git a/README.md b/README.md index d69ff08..a9f99ce 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ pay.html · src/pay.ts صفحه‌ی واسطِ پرداختِ زرین‌پا - **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ `۰-۹` می‌فرستد و `\d` در JS آن‌ها را نمی‌شناسد). - **رنگ‌ها** در `src/styles/index.css` (`@theme`) تعریف شده‌اند: `bg-coral`، `text-ink`، … - **خطا:** پیامِ کاربر همیشه از `friendlyError` می‌آید، نه متنِ خامِ سرور. +- **بازشدنِ فوری:** کاربرِ واردشده خانه را از عکسِ روی گوشی می‌بیند (`state/homeSnapshot.ts`: پروفایل، پایه‌ها، نقشه، + آمار) و همان درخواست‌های بوت پشتِ صحنه تازه‌اش می‌کنند. عکس با پاک شدنِ توکن و ورودِ تازه پاک می‌شود، با بیلدِ دیگر + یا کهنه‌تر از ۷ روز خوانده نمی‌شود، و درستی (قفل، اشتراک، قلب) همیشه با سرور است. داده‌ی تازه‌ای که در خانه دیده + می‌شود باید در `AppData` بماند تا در عکس هم برود. - **متنِ فارسی** بی‌اعراب و بی «ی»ِ اضافه‌ی نوشته‌شده («پایه‌ی سوم») است (تصمیمِ شرکت)؛ تنوین («قبلاً») می‌ماند. متنِ تازه را همین‌طور بنویسید. متنِ سرور را بک‌اند هنگامِ ساختنِ پاسخ پاک می‌کند و `plainPersian` در `MathText` همان کار را برای پاسخِ زنده‌ی دستیار. `lib/plainMath.ts` هر LaTeX یا markdownی را که برسد ساده می‌کند و باید با `content/plain_math.py`ِ diff --git a/src/api/index.ts b/src/api/index.ts index 207fd50..d68fd9d 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -4,13 +4,26 @@ * هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است. */ import { resolveApiBaseUrl } from '@/config'; -import { ApiError, createHttpClient, newIdempotencyKey } from './http'; +import { homeSnapshot } from '@/state/homeSnapshot'; +import { ApiError, createHttpClient, localTokenStorage, newIdempotencyKey, type TokenStorage } from './http'; import type * as T from './types'; export { ApiError }; export type * from './types'; -export const http = createHttpClient({ baseUrl: resolveApiBaseUrl() }); +/** + * پاک شدنِ توکن (خروج، حذفِ حساب، نشستِ باطل در http.signOut) عکسِ خانه را هم پاک می‌کند؛ هر سه از همین + * `set(null)` می‌گذرند، پس راهی برای ماندنِ خانه‌ی کاربرِ قبلی روی گوشیِ مشترک نمی‌ماند. + */ +const tokenStorage: TokenStorage = { + get: () => localTokenStorage.get(), + set(tokens) { + if (!tokens) homeSnapshot.clear(); + localTokenStorage.set(tokens); + }, +}; + +export const http = createHttpClient({ baseUrl: resolveApiBaseUrl(), storage: tokenStorage }); const q = (params: Record) => { const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== ''); @@ -18,6 +31,8 @@ const q = (params: Record) => { }; function saveTokens(data: T.AuthResponse) { + // ورودِ تازه ممکن است کاربرِ دیگری باشد؛ عکسِ خانه فقط از پاسخِ سرورِ همین نشست ساخته می‌شود. + homeSnapshot.clear(); http.storage.set({ token: data.token, refreshToken: data.refreshToken ?? '' }); return data; } diff --git a/src/app/App.tsx b/src/app/App.tsx index 844268f..4869f57 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,6 +7,7 @@ import { ToastProvider } from '@/components/Toast'; import { ErrorState } from '@/components/ui'; import { KEYS, store } from '@/lib/storage'; import { askStore } from '@/state/askStore'; +import { homeSnapshot } from '@/state/homeSnapshot'; import { fetchHome, subjectStore } from '@/state/subject'; import { clearAsyncCache } from '@/lib/useAsync'; import { LoginScreen } from '@/screens/LoginScreen'; @@ -22,14 +23,18 @@ type Flow = | { kind: 'onboarding' } | { kind: 'login' } | { kind: 'setup'; profile: UserProfile; grades: Grade[] } - | { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch }; + | { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch; stale?: boolean }; /** * جریانِ ورود: اسپلشِ ثابت (تا بررسیِ نشست) → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ. - * خودِ اپ (تب‌ها و صفحه‌ها) در MainApp است. + * کاربرِ واردشده‌ای که عکسِ خانه روی گوشی دارد (state/homeSnapshot.ts) بی‌انتظار به خانه می‌رود و + * داده‌ها پشتِ صحنه تازه می‌شوند. خودِ اپ (تب‌ها و صفحه‌ها) در MainApp است. */ export function App() { const [flow, setFlow] = useState({ kind: 'boot' }); + // با هر rebootFresh یکی بالا می‌رود و جزوِ کلیدِ صفحه است. بی‌آن «main → boot → main» برای AnimatePresence + // همان کلید است و MainAppِ در حالِ خروج (با داده‌ی عکسِ غلط) به‌جای MainAppِ تازه سرِ جایش می‌ماند. + const [generation, setGeneration] = useState(0); /** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */ const enter = useCallback(async () => { @@ -46,6 +51,18 @@ export function App() { setFlow({ kind: 'boot' }); let next: Flow | null = null; if (api.auth.isSignedIn()) { + const snap = homeSnapshot.read(); + if (snap) { + setFlow({ + kind: 'main', + profile: snap.profile, + grades: snap.grades, + prefetch: { home: snap.home, summary: snap.summary }, + stale: true, + }); + void trackReferral(); + return; + } try { await enter(); } catch (error) { @@ -83,6 +100,15 @@ export function App() { [], ); + // عکسِ خانه مالِ کسِ دیگری بود یا پروفایل حالا ناقص است: عکس کنار می‌رود و بوتِ معمولی تصمیم می‌گیرد. + const rebootFresh = useCallback(() => { + homeSnapshot.clear(); + askStore.clear(); + clearAsyncCache(); + setGeneration((g) => g + 1); + void boot(); + }, [boot]); + const afterLogin = async () => { try { await enter(); @@ -98,7 +124,7 @@ export function App() { { clearAsyncCache(); diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 2caf221..e2c327f 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -52,15 +52,24 @@ interface Props { profile: UserProfile; grades: Grade[]; prefetch?: Prefetch; + /** داده‌ها از عکسِ خانه روی گوشی آمده‌اند و پشتِ صحنه تازه می‌شوند (AppData). */ + stale?: boolean; + onIdentityChanged?: () => void; payment: PaymentReturn | null; onSignedOut: () => void; } /** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */ -export function MainApp({ profile, grades, prefetch, payment, onSignedOut }: Props) { +export function MainApp({ profile, grades, prefetch, stale, onIdentityChanged, payment, onSignedOut }: Props) { useEffect(preloadScreens, []); return ( - + {payment && } diff --git a/src/lib/storage.ts b/src/lib/storage.ts index cd19416..546acc8 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -31,4 +31,6 @@ export const KEYS = { companion: 'tadrisino.companion', /** {gradeId: subjectKey}؛ کتابی که کاربر در هر پایه آخر انتخاب کرده. */ subjects: 'tadrisino.subjects', + /** عکسِ صفحه‌ی خانه برای بازشدنِ فوری (state/homeSnapshot.ts). */ + homeSnapshot: 'tadrisino.homeSnapshot', } as const; diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index c06c7bb..b2888e5 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; -import { Bell, ChevronLeft, Coins, Crown, Flame, Star } from 'lucide-react'; -import type { HomeNode } from '@/api'; +import { Bell, ChevronLeft, Coins, Crown, Flame, RotateCw, Star } from 'lucide-react'; +import { ApiError, type HomeNode } from '@/api'; import { useAppData } from '@/state/AppData'; import { findContinue, unitColor, unitProgress } from '@/state/curriculum'; import { useNav } from '@/nav/Navigator'; @@ -20,7 +20,7 @@ import { SubjectBar } from '@/components/SubjectBar'; * دستیار، فصل‌ها، چالش) و بچه نمی‌دانست کدام را بزند؛ حالا یک کارِ واضح دارد. */ export function HomeScreen() { - const { gradeTitle, summary, home, homeError, unreadCount, refreshHome } = useAppData(); + const { gradeTitle, summary, home, homeError, unreadCount, refreshHome, refreshSummary } = useAppData(); const nav = useNav(); // فصلِ غیرِ اول بی‌اشتراک: زدنِ جزیره یک‌راست فهرستِ اشتراک‌ها را باز می‌کند. const paywall = () => nav.push({ name: 'subscription' }); @@ -91,6 +91,23 @@ export function HomeScreen() { ) : (
+ {homeError != null && ( + // نقشه هست ولی تازه نشد (معمولاً عکسِ ذخیره‌شده روی گوشی و اینترنتِ قطع): بچه باید بداند + // عددها و قفل‌ها ممکن است مالِ دفعه‌ی قبل باشند. +
+ + {homeError instanceof ApiError && homeError.isNetwork ? 'اینترنت وصل نیست یا کنده' : 'اطلاعات تازه نشد'}؛ + آخرین اطلاعات ذخیره‌شده رو می‌بینی. + + +
+ )} {home.units.map((unit, i) => { const color = unitColor(i); const p = unitProgress(unit); diff --git a/src/state/AppData.tsx b/src/state/AppData.tsx index 95c486e..4723baa 100644 --- a/src/state/AppData.tsx +++ b/src/state/AppData.tsx @@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import { api, type Character, type Grade, type HomeResponse, type UserProfile, type UserSummary } from '@/api'; import { seyarFromSlug, type SeyarChar } from '@/mascots/seyar'; import { companionStore, useCompanion } from '@/state/companion'; +import { homeSnapshot } from '@/state/homeSnapshot'; import { fetchHome, subjectStore } from '@/state/subject'; /** @@ -42,16 +43,26 @@ export interface Prefetch { export function AppDataProvider({ initialProfile, - grades, + grades: initialGrades, prefetch, + stale = false, + onIdentityChanged, children, }: { initialProfile: UserProfile; grades: Grade[]; prefetch?: Prefetch; + /** + * داده‌ی اولیه عکسِ ذخیره‌شده روی گوشی است (state/homeSnapshot.ts)، نه پاسخِ همین حالا: همه‌چیز پشتِ صحنه + * دوباره گرفته می‌شود. همان چهار درخواستِ بوت، پس بارِ سرور بیشتر نمی‌شود؛ فقط کاربر منتظرشان نمی‌ماند. + */ + stale?: boolean; + /** پروفایلِ تازه با عکس نمی‌خواند (کاربرِ دیگر، یا بی‌نام/بی‌پایه که باید به تکمیلِ پروفایل برود). */ + onIdentityChanged?: () => void; children: ReactNode; }) { const [profile, setProfile] = useState(initialProfile); + const [grades, setGrades] = useState(initialGrades); const [summary, setSummary] = useState(prefetch?.summary ?? null); const [home, setHome] = useState(prefetch?.home ?? null); const [homeError, setHomeError] = useState(null); @@ -63,16 +74,21 @@ export function AppDataProvider({ const gradeId = profile.gradeId; // نقشه‌ی کتاب‌هایی که دیده شده؛ جابه‌جایی به کتابِ دیده‌شده فوری است و پشت‌صحنه تازه می‌شود. const homeCache = useRef(new Map()); + // فقط پاسخِ آخرین درخواست روی صفحه می‌نشیند: وقتی پایه یا کتاب وسطِ راه عوض شود (مثلاً پروفایلِ تازه‌ی + // پشتِ صحنه پایه‌ی دیگری گفت)، پاسخِ کندترِ درخواستِ قبلی نباید نقشه‌ی تازه را بپوشاند. + const homeSeq = useRef(0); const refreshHome = useCallback(async (forGrade?: number) => { const target = forGrade ?? gradeId; + const seq = ++homeSeq.current; try { const fresh = await fetchHome(target); if (fresh.subject) homeCache.current.set(`${target}:${fresh.subject.key}`, fresh); + if (seq !== homeSeq.current) return; setHome(fresh); setHomeError(null); } catch (err) { - setHomeError(err); + if (seq === homeSeq.current) setHomeError(err); } }, [gradeId]); @@ -106,22 +122,76 @@ export function AppDataProvider({ } }, []); + // بارِ اول فقط یک بار؛ عوض شدنِ پایه refreshHome را تازه می‌سازد و نباید همه‌ی این‌ها دوباره گرفته شوند. + const started = useRef(false); + // عکس مالِ این کاربر نبود: این AppData تا برداشته شدن دیگر عکس نمی‌نویسد (پاسخِ دیرِ نقشه با پروفایلِ غلط). + const identityLost = useRef(false); useEffect(() => { - // آنچه در اسپلش رسیده دوباره گرفته نمی‌شود؛ فقط کم‌ها. - if (!prefetch?.home) void refreshHome(); - if (!prefetch?.summary) void refreshSummary(); - void refreshUnread(); - api.characters - .list() - .then((list) => { - // بی‌انتخاب یعنی پیش‌فرضِ سرور (سیگما)؛ همراهِ کاربرِ قبلی روی این دستگاه نباید بماند. - setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma'); - }) - .catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err)); - // فقط بارِ اول؛ prefetch بعد از mount عوض نمی‌شود. + if (started.current) return; + started.current = true; + // اعلان‌ها و همراه برای صفحه‌ی اول لازم نیستند. + const loadExtras = () => { + if (!api.auth.isSignedIn()) return; + void refreshUnread(); + api.characters + .list() + .then((list) => { + // بی‌انتخاب یعنی پیش‌فرضِ سرور (سیگما)؛ همراهِ کاربرِ قبلی روی این دستگاه نباید بماند. + setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma'); + }) + .catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err)); + }; + if (stale) { + api.profile + .get() + .then((fresh) => { + if (fresh.userId !== initialProfile.userId || !fresh.name || fresh.gradeId == null) { + identityLost.current = true; + onIdentityChanged?.(); + return false; + } + setProfile(fresh); + // پایه در دستگاهِ دیگری عوض شده؛ نقشه‌ای که هم‌زمان با پایه‌ی عکس گرفته شد به کار نمی‌آید. + if (fresh.gradeId !== initialProfile.gradeId) void refreshHome(fresh.gradeId); + return true; + }) + // ۴۰۱ را http خودش به خروج می‌برد. بقیه (اینترنتِ قطع، ۵xx): پروفایلِ عکس می‌ماند و نوارِ «اطلاعاتِ + // ذخیره‌شده» در خانه (homeError) خبر می‌دهد؛ نقشه هم‌زمان همین درخواست‌ها را می‌زند و همان‌جا شکست می‌خورد. + .catch((err: unknown) => { + console.warn('profile refresh failed; showing the stored one', err); + return true; + }) + // بعد از پروفایل، نه هم‌زمان: با توکنِ یک‌ساعته‌ی منقضی هر درخواستِ هم‌زمان یک ۴۰۱ و یک تکرار می‌شود؛ + // این‌طور تعدادِ درخواست‌ها همان بوتِ قبلی است (۴ + refresh + ۴ تکرار + ۲). + .then((stays) => stays && loadExtras()); + api + .grades() + .then(setGrades) + .catch((err: unknown) => console.warn('grades refresh failed; keeping the stored list', err)); + void refreshHome(); + void refreshSummary(); + } else { + // آنچه در اسپلش رسیده دوباره گرفته نمی‌شود؛ فقط کم‌ها. + if (!prefetch?.home) void refreshHome(); + if (!prefetch?.summary) void refreshSummary(); + loadExtras(); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [refreshHome, refreshSummary, refreshUnread]); + // هر داده‌ی تازه عکسِ خانه را به‌روز می‌کند. بارِ اولِ حالتِ stale همان عکسِ خوانده‌شده است و نوشته نمی‌شود، + // تا savedAt فقط با پاسخِ سرور جلو برود. + const skipSnapshotWrite = useRef(stale); + useEffect(() => { + if (skipSnapshotWrite.current) { + skipSnapshotWrite.current = false; + return; + } + // پاسخی که بعد از خروج رسید (توکن پاک شده) نباید عکسِ تازه بسازد. + if (!home || !summary || identityLost.current || !api.auth.isSignedIn()) return; + homeSnapshot.save({ profile, grades, home, summary }); + }, [profile, grades, home, summary]); + const value = useMemo( () => ({ profile, diff --git a/src/state/homeSnapshot.ts b/src/state/homeSnapshot.ts new file mode 100644 index 0000000..81c9cac --- /dev/null +++ b/src/state/homeSnapshot.ts @@ -0,0 +1,63 @@ +import type { Grade, HomeResponse, UserProfile, UserSummary } from '@/api'; +import { KEYS, store } from '@/lib/storage'; + +/** + * عکسِ صفحه‌ی خانه روی گوشی: آخرین پروفایل، پایه‌ها، نقشه و آمار. اپ با آن فوراً خانه را نشان می‌دهد و + * همان درخواست‌های همیشگی را پشتِ صحنه می‌زند (AppData، `stale`)؛ بدونِ آن اسپلش تا رسیدنِ هر چهار پاسخ + * می‌ماند، که روی اینترنتِ ضعیف و با توکنِ یک‌ساعته‌ی منقضی (۴۰۱ ← refresh ← تکرار) چند رفت‌وبرگشتِ پشتِ هم است. + * + * درستی را سرور نگه می‌دارد، نه این عکس: جزیره‌ی قفل (۴۰۳)، اشتراکِ تمام‌شده (۴۰۲) و قلبِ صفر (۴۰۳) همان‌جا + * رد می‌شوند؛ عکسِ قدیمی فقط ممکن است یکی دو ثانیه عددِ کهنه نشان دهد. + * + * با پاک شدنِ توکن (خروج، حذفِ حساب، نشستِ باطل) و با ورودِ تازه پاک می‌شود (api/index.ts)، تا خانه‌ی یک + * کاربر روی گوشیِ مشترک به کاربرِ بعدی نرسد. + */ +export interface HomeSnapshot { + build: string; + savedAt: number; + profile: UserProfile; + grades: Grade[]; + home: HomeResponse; + summary: UserSummary; +} + +/** + * عکسِ کهنه‌تر از این کنار گذاشته می‌شود و اپ مثلِ قبل منتظرِ سرور می‌ماند. انتخابِ محصولی: کسی که یک هفته + * نیامده، آمدنِ یک‌باره‌ی نقشه‌ی تازه را به دیدنِ نقشه‌ی یک هفته پیش ترجیح می‌دهد. + */ +const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +export const homeSnapshot = { + /** عکسِ قابلِ استفاده، یا null: نبود، مالِ بیلدِ دیگری بود (شکلِ داده ممکن است عوض شده باشد)، کهنه یا ناقص بود. */ + read(): HomeSnapshot | null { + const raw = store.get(KEYS.homeSnapshot); + if (!raw) return null; + let snap: Partial; + try { + snap = JSON.parse(raw) as Partial; + } catch (err) { + console.warn('home snapshot unreadable; dropped', err); + store.remove(KEYS.homeSnapshot); + return null; + } + const usable = + snap.build === __APP_BUILD__ && + typeof snap.savedAt === 'number' && + Date.now() - snap.savedAt < MAX_AGE_MS && + // بی‌نام یا بی‌پایه یعنی کاربر باید به «تکمیلِ پروفایل» برود؛ آن تصمیم با پاسخِ سرور است. + Boolean(snap.profile?.name) && + snap.profile?.gradeId != null && + Array.isArray(snap.grades) && + Array.isArray(snap.home?.units) && + snap.summary != null; + return usable ? (snap as HomeSnapshot) : null; + }, + + save(data: Omit) { + store.set(KEYS.homeSnapshot, JSON.stringify({ ...data, build: __APP_BUILD__, savedAt: Date.now() })); + }, + + clear() { + store.remove(KEYS.homeSnapshot); + }, +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 7ff9970..55ae1cb 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,8 @@ /// +/** شناسه‌ی هر بیلد (vite.config.ts)؛ عکسِ خانه‌ی بیلدِ دیگر خوانده نمی‌شود. */ +declare const __APP_BUILD__: string; + interface ImportMetaEnv { readonly VITE_API_BASE_URL?: string; } diff --git a/vite.config.ts b/vite.config.ts index b3bc678..996d318 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,9 @@ export default defineConfig(({ mode }) => { return { plugins: [react(), tailwindcss()], + // عکسِ خانه (src/state/homeSnapshot.ts) فقط با همان بیلدی خوانده می‌شود که نوشته‌اش؛ بعد از هر دیپلوی + // بارِ اول مثلِ قبل از سرور می‌آید، تا داده‌ای با شکلِ قدیمی به کدِ تازه نرسد. + define: { __APP_BUILD__: JSON.stringify(Date.now().toString(36)) }, resolve: { alias: { '@': path.resolve(__dirname, './src') }, },