diff --git a/maeil-mail/src/constants/urls.ts b/maeil-mail/src/constants/urls.ts index acc9c22f2..2221d104d 100644 --- a/maeil-mail/src/constants/urls.ts +++ b/maeil-mail/src/constants/urls.ts @@ -1 +1,2 @@ export const MAEIL_MAIL_URL = 'https://maeilmail.bombom.news'; +export const WEB_URL = 'https://bombom.news'; diff --git a/maeil-mail/src/routeTree.gen.ts b/maeil-mail/src/routeTree.gen.ts index ac3293457..e9592369b 100644 --- a/maeil-mail/src/routeTree.gen.ts +++ b/maeil-mail/src/routeTree.gen.ts @@ -9,9 +9,15 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root'; +import { Route as SignupRouteImport } from './routes/signup'; import { Route as IndexRouteImport } from './routes/index'; import { Route as ContentsContentIdAnswerRouteImport } from './routes/contents/$contentId/answer'; +const SignupRoute = SignupRouteImport.update({ + id: '/signup', + path: '/signup', + getParentRoute: () => rootRouteImport, +} as any); const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -25,32 +31,43 @@ const ContentsContentIdAnswerRoute = ContentsContentIdAnswerRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute; + '/signup': typeof SignupRoute; '/contents/$contentId/answer': typeof ContentsContentIdAnswerRoute; } export interface FileRoutesByTo { '/': typeof IndexRoute; + '/signup': typeof SignupRoute; '/contents/$contentId/answer': typeof ContentsContentIdAnswerRoute; } export interface FileRoutesById { __root__: typeof rootRouteImport; '/': typeof IndexRoute; + '/signup': typeof SignupRoute; '/contents/$contentId/answer': typeof ContentsContentIdAnswerRoute; } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath; - fullPaths: '/' | '/contents/$contentId/answer'; + fullPaths: '/' | '/signup' | '/contents/$contentId/answer'; fileRoutesByTo: FileRoutesByTo; - to: '/' | '/contents/$contentId/answer'; - id: '__root__' | '/' | '/contents/$contentId/answer'; + to: '/' | '/signup' | '/contents/$contentId/answer'; + id: '__root__' | '/' | '/signup' | '/contents/$contentId/answer'; fileRoutesById: FileRoutesById; } export interface RootRouteChildren { IndexRoute: typeof IndexRoute; + SignupRoute: typeof SignupRoute; ContentsContentIdAnswerRoute: typeof ContentsContentIdAnswerRoute; } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/signup': { + id: '/signup'; + path: '/signup'; + fullPath: '/signup'; + preLoaderRoute: typeof SignupRouteImport; + parentRoute: typeof rootRouteImport; + }; '/': { id: '/'; path: '/'; @@ -70,6 +87,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + SignupRoute: SignupRoute, ContentsContentIdAnswerRoute: ContentsContentIdAnswerRoute, }; export const routeTree = rootRouteImport diff --git a/maeil-mail/src/routes/signup.tsx b/maeil-mail/src/routes/signup.tsx new file mode 100644 index 000000000..a28666f94 --- /dev/null +++ b/maeil-mail/src/routes/signup.tsx @@ -0,0 +1,27 @@ +import { createFileRoute, useSearch } from '@tanstack/react-router'; +import { useEffect } from 'react'; +import { WEB_URL } from '@/constants/urls'; + +export const Route = createFileRoute('/signup')({ + head: () => ({ + meta: [{ name: 'robots', content: 'noindex, nofollow' }], + }), + component: RouteComponent, + validateSearch: (search: { email?: string; name?: string }) => ({ + email: search.email, + name: search.name, + }), +}); + +function RouteComponent() { + const { email, name } = useSearch({ from: '/signup' }); + + useEffect(() => { + const params = new URLSearchParams(); + if (email) params.set('email', email); + if (name) params.set('name', name); + window.location.replace(`${WEB_URL}/signup?${params.toString()}`); + }, [email, name]); + + return null; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 1bcf5b6dd..a319c7bab 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -97,6 +97,7 @@ const router = createRouter({ }, scrollRestoration: true, defaultNotFoundComponent: NotFound, + defaultErrorComponent: PageErrorFallback, }); if (isProduction) { diff --git a/web/src/pages/landing/constants/localStorage.ts b/web/src/pages/landing/constants/localStorage.ts index 5455d06a3..49f342723 100644 --- a/web/src/pages/landing/constants/localStorage.ts +++ b/web/src/pages/landing/constants/localStorage.ts @@ -1 +1,8 @@ +import { createStorage } from '@/utils/localStorage'; + export const LANDING_VISITED_KEY = 'hasVisitedLanding'; + +export const landingVisitedStorage = createStorage( + LANDING_VISITED_KEY, + false, +); diff --git a/web/src/routes/landing.tsx b/web/src/routes/landing.tsx index da5fcc9f1..8060c1b20 100644 --- a/web/src/routes/landing.tsx +++ b/web/src/routes/landing.tsx @@ -9,7 +9,7 @@ import LandingHeader from '@/pages/landing/components/LandingHeader'; import LandingHero from '@/pages/landing/components/LandingHero'; import LandingPopularNewsletters from '@/pages/landing/components/LandingPopularNewsletters'; import PainPoint from '@/pages/landing/components/PainPoint'; -import { LANDING_VISITED_KEY } from '@/pages/landing/constants/localStorage'; +import { landingVisitedStorage } from '@/pages/landing/constants/localStorage'; import type { Device } from '@/hooks/useDevice'; export const Route = createFileRoute('/landing')({ @@ -32,7 +32,7 @@ function LandingPage() { const device = useDevice(); useEffect(() => { - localStorage.setItem(LANDING_VISITED_KEY, 'true'); + landingVisitedStorage.set(true); }, []); return ( diff --git a/web/src/utils/localStorage.test.ts b/web/src/utils/localStorage.test.ts index 5d391ddc0..1b21c604b 100644 --- a/web/src/utils/localStorage.test.ts +++ b/web/src/utils/localStorage.test.ts @@ -51,4 +51,29 @@ describe('createStorage', () => { expect(window.localStorage.removeItem).toHaveBeenCalledWith(KEY); expect(window.localStorage.getItem(KEY)).toBeNull(); }); + + it('get: storage 접근이 예외를 던지면 defaultData를 반환한다.', () => { + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('Access is denied.', 'SecurityError'); + }); + + expect(() => storage.get()).not.toThrow(); + expect(storage.get()).toEqual(DEFAULT_DATA); + }); + + it('set: storage 접근이 예외를 던져도 throw하지 않는다.', () => { + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('Access is denied.', 'SecurityError'); + }); + + expect(() => storage.set({ count: 1 })).not.toThrow(); + }); + + it('remove: storage 접근이 예외를 던져도 throw하지 않는다.', () => { + jest.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new DOMException('Access is denied.', 'SecurityError'); + }); + + expect(() => storage.remove()).not.toThrow(); + }); }); diff --git a/web/src/utils/localStorage.ts b/web/src/utils/localStorage.ts index 402b435ff..226364954 100644 --- a/web/src/utils/localStorage.ts +++ b/web/src/utils/localStorage.ts @@ -12,23 +12,32 @@ interface StorageType { remove: () => void; } -const storage = window.localStorage; - export const createStorage = ( key: string, defaultData?: T, ): StorageType => ({ get() { - const data = storage.getItem(key); - return data ? JSON.parse(data) : defaultData; + try { + const data = window.localStorage.getItem(key); + return data ? JSON.parse(data) : defaultData; + } catch { + return defaultData; + } }, set(data) { - const stringifyData = JSON.stringify(data); - storage.setItem(key, stringifyData); + try { + window.localStorage.setItem(key, JSON.stringify(data)); + } catch { + // noop + } }, remove() { - storage.removeItem(key); + try { + window.localStorage.removeItem(key); + } catch { + // noop + } }, });