diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml new file mode 100644 index 0000000..05a4742 --- /dev/null +++ b/.github/workflows/update-translations.yml @@ -0,0 +1,37 @@ +name: Update Translations + +on: + push: + paths: + - 'translations/**' + workflow_dispatch: + +jobs: + update-translations: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Install pnpm + run: npm install -g pnpm + + - name: Install dependencies + run: pnpm install + + - name: Generate translation JSON + run: pnpm exec ts-node --transpile-only scripts/generateTranslations.js + + - name: Commit updated translations + run: | + git config --global user.name "github-actions" + git config --global user.email "github-actions@github.com" + git add locales + git diff --cached --quiet || (git commit -m "chore: update translations" && git push) diff --git a/i18n/i18n.ts b/i18n/i18n.ts new file mode 100644 index 0000000..278ba0c --- /dev/null +++ b/i18n/i18n.ts @@ -0,0 +1,21 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import enCommon from '../locales/en/common.json'; +import koCommon from '../locales/ko/common.json'; + +const resources = { + en: { common: enCommon }, + ko: { common: koCommon }, +}; + +i18n.use(initReactI18next).init({ + resources, + lng: 'ko', + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, +}); + +export default i18n; diff --git a/locales/en/common.json b/locales/en/common.json new file mode 100644 index 0000000..1314673 --- /dev/null +++ b/locales/en/common.json @@ -0,0 +1,13 @@ +{ + "signUp": "Sign up", + "login": "Login", + "searchRecruitment": "Search for job postings", + "recruitmentInfo": "recruitment", + "companiesInfo": "Companies", + "nearBy": "Surrounding", + "community": "Community", + "mainBannerTitle1": "For foreigners", + "mainBannerTitle2": "recruitment platform", + "mainBannerSubtitle": "It provides customized employment information and services for foreigners looking for a job in Korea", + "viewRecruitment": "View Recruitment" +} \ No newline at end of file diff --git a/locales/ko/common.json b/locales/ko/common.json new file mode 100644 index 0000000..29da76e --- /dev/null +++ b/locales/ko/common.json @@ -0,0 +1,13 @@ +{ + "signUp": "회원가입", + "login": "로그인", + "searchRecruitment": "채용 공고 검색하기", + "recruitmentInfo": "채용정보", + "companiesInfo": "기업정보", + "nearBy": "주변기업찾기", + "community": "커뮤니티", + "mainBannerTitle1": "외국인을 위한", + "mainBannerTitle2": "채용 플랫폼", + "mainBannerSubtitle": "한국에서 일자리를 찾고 있는 외국인을 위한 맞춤형 취업 정보와 서비스를 제공합니다", + "viewRecruitment": "채용정보 보기" +} \ No newline at end of file diff --git a/package.json b/package.json index 9a74f6e..c84d0de 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,11 @@ }, "dependencies": { "@hookform/resolvers": "^4.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-radio-group": "^1.2.3", "clsx": "^2.1.1", + "csv-parser": "^3.2.0", + "i18next": "^24.2.3", "lucide-react": "^0.471.1", "path": "^0.12.7", "react": "^18.3.1", @@ -19,6 +23,7 @@ "react-dropzone": "^14.3.8", "react-error-boundary": "^5.0.0", "react-hook-form": "^7.54.2", + "react-i18next": "^15.4.1", "react-router-dom": "^7.1.1", "recharts": "^2.15.1", "sass": "^1.83.4", @@ -26,6 +31,7 @@ }, "devDependencies": { "@eslint/js": "^9.17.0", + "@types/node": "^22.13.14", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", @@ -33,7 +39,8 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", - "typescript": "~5.6.2", + "ts-node": "^10.9.2", + "typescript": "~5.6.3", "typescript-eslint": "^8.18.2", "vite": "^6.0.5" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05b7404..44839f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,21 @@ importers: '@hookform/resolvers': specifier: ^4.1.3 version: 4.1.3(react-hook-form@7.54.2(react@18.3.1)) + '@radix-ui/react-checkbox': + specifier: ^1.1.4 + version: 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-radio-group': + specifier: ^1.2.3 + version: 1.2.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) clsx: specifier: ^2.1.1 version: 2.1.1 + csv-parser: + specifier: ^3.2.0 + version: 3.2.0 + i18next: + specifier: ^24.2.3 + version: 24.2.3(typescript@5.6.3) lucide-react: specifier: ^0.471.1 version: 0.471.1(react@18.3.1) @@ -35,6 +47,9 @@ importers: react-hook-form: specifier: ^7.54.2 version: 7.54.2(react@18.3.1) + react-i18next: + specifier: ^15.4.1 + version: 15.4.1(i18next@24.2.3(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router-dom: specifier: ^7.1.1 version: 7.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -51,6 +66,9 @@ importers: '@eslint/js': specifier: ^9.17.0 version: 9.17.0 + '@types/node': + specifier: ^22.13.14 + version: 22.13.14 '@types/react': specifier: ^18.3.18 version: 18.3.18 @@ -59,7 +77,7 @@ importers: version: 18.3.5(@types/react@18.3.18) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.3.4(vite@6.0.7(sass@1.83.4)) + version: 4.3.4(vite@6.0.7(@types/node@22.13.14)(sass@1.83.4)) eslint: specifier: ^9.17.0 version: 9.17.0 @@ -72,15 +90,18 @@ importers: globals: specifier: ^15.14.0 version: 15.14.0 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.13.14)(typescript@5.6.3) typescript: - specifier: ~5.6.2 + specifier: ~5.6.3 version: 5.6.3 typescript-eslint: specifier: ^8.18.2 version: 8.19.0(eslint@9.17.0)(typescript@5.6.3) vite: specifier: ^6.0.5 - version: 6.0.7(sass@1.83.4) + version: 6.0.7(@types/node@22.13.14)(sass@1.83.4) packages: @@ -159,6 +180,10 @@ packages: resolution: {integrity: sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.27.0': + resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.25.9': resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} engines: {node: '>=6.9.0'} @@ -171,6 +196,10 @@ packages: resolution: {integrity: sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==} engines: {node: '>=6.9.0'} + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.24.2': resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} engines: {node: '>=18'} @@ -398,6 +427,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -492,6 +524,177 @@ packages: resolution: {integrity: sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==} engines: {node: '>= 10.0.0'} + '@radix-ui/primitive@1.1.1': + resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} + + '@radix-ui/react-checkbox@1.1.4': + resolution: {integrity: sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.2': + resolution: {integrity: sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.1': + resolution: {integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.1': + resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.1.0': + resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-id@1.1.0': + resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-presence@1.1.2': + resolution: {integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.0.2': + resolution: {integrity: sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.2.3': + resolution: {integrity: sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.2': + resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.1.2': + resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.0': + resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.1.0': + resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.0': + resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.0': + resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.0': + resolution: {integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@rollup/rollup-android-arm-eabi@4.29.2': resolution: {integrity: sha512-s/8RiF4bdmGnc/J0N7lHAr5ZFJj+NdJqJ/Hj29K+c4lEdoVlukzvWXB9XpWZCdakVT0YAw8iyIqUP2iFRz5/jA==} cpu: [arm] @@ -590,6 +793,18 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -638,6 +853,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@22.13.14': + resolution: {integrity: sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==} + '@types/prop-types@15.7.14': resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==} @@ -707,6 +925,10 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + acorn@8.14.0: resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} engines: {node: '>=0.4.0'} @@ -719,6 +941,9 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -780,6 +1005,9 @@ packages: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -787,6 +1015,11 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csv-parser@3.2.0: + resolution: {integrity: sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==} + engines: {node: '>= 10'} + hasBin: true + d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} @@ -851,6 +1084,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -1005,6 +1242,17 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + i18next@24.2.3: + resolution: {integrity: sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1097,6 +1345,9 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1211,15 +1462,25 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-i18next@15.4.1: + resolution: {integrity: sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw==} + peerDependencies: + i18next: '>= 23.2.3' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} -<<<<<<< HEAD react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} -======= ->>>>>>> origin/develop react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -1343,6 +1604,20 @@ packages: peerDependencies: typescript: '>=4.2.0' + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1365,6 +1640,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + update-browserslist-db@1.1.1: resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} hasBin: true @@ -1377,6 +1655,9 @@ packages: util@0.10.4: resolution: {integrity: sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==} + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} @@ -1420,6 +1701,10 @@ packages: yaml: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1432,6 +1717,10 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1537,6 +1826,10 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 + '@babel/runtime@7.27.0': + dependencies: + regenerator-runtime: 0.14.1 + '@babel/template@7.25.9': dependencies: '@babel/code-frame': 7.26.2 @@ -1560,6 +1853,10 @@ snapshots: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@esbuild/aix-ppc64@0.24.2': optional: true @@ -1711,6 +2008,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1784,6 +2086,154 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.0 optional: true + '@radix-ui/primitive@1.1.1': {} + + '@radix-ui/react-checkbox@1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-collection@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-compose-refs@1.1.1(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + + '@radix-ui/react-context@1.1.1(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + + '@radix-ui/react-direction@1.1.0(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + + '@radix-ui/react-id@1.1.0(@types/react@18.3.18)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + + '@radix-ui/react-presence@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-primitive@2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.1.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-radio-group@1.2.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-roving-focus@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-collection': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-slot@1.1.2(@types/react@18.3.18)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + + '@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.3.18)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + + '@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + + '@radix-ui/react-use-previous@1.1.0(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + + '@radix-ui/react-use-size@1.1.0(@types/react@18.3.18)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@rollup/rollup-android-arm-eabi@4.29.2': optional: true @@ -1843,6 +2293,14 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.26.3 @@ -1894,6 +2352,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@22.13.14': + dependencies: + undici-types: 6.20.0 + '@types/prop-types@15.7.14': {} '@types/react-dom@18.3.5(@types/react@18.3.18)': @@ -1982,14 +2444,14 @@ snapshots: '@typescript-eslint/types': 8.19.0 eslint-visitor-keys: 4.2.0 - '@vitejs/plugin-react@4.3.4(vite@6.0.7(sass@1.83.4))': + '@vitejs/plugin-react@4.3.4(vite@6.0.7(@types/node@22.13.14)(sass@1.83.4))': dependencies: '@babel/core': 7.26.0 '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.0) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 6.0.7(sass@1.83.4) + vite: 6.0.7(@types/node@22.13.14)(sass@1.83.4) transitivePeerDependencies: - supports-color @@ -1997,6 +2459,10 @@ snapshots: dependencies: acorn: 8.14.0 + acorn-walk@8.3.4: + dependencies: + acorn: 8.14.0 + acorn@8.14.0: {} ajv@6.12.6: @@ -2010,6 +2476,8 @@ snapshots: dependencies: color-convert: 2.0.1 + arg@4.1.3: {} + argparse@2.0.1: {} attr-accept@2.2.5: {} @@ -2063,6 +2531,8 @@ snapshots: cookie@1.0.2: {} + create-require@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2071,6 +2541,8 @@ snapshots: csstype@3.1.3: {} + csv-parser@3.2.0: {} + d3-array@3.2.4: dependencies: internmap: 2.0.3 @@ -2120,6 +2592,8 @@ snapshots: detect-libc@1.0.3: optional: true + diff@4.0.2: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.26.9 @@ -2302,6 +2776,16 @@ snapshots: has-flag@4.0.0: {} + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + i18next@24.2.3(typescript@5.6.3): + dependencies: + '@babel/runtime': 7.27.0 + optionalDependencies: + typescript: 5.6.3 + ignore@5.3.2: {} immutable@5.0.3: {} @@ -2372,6 +2856,8 @@ snapshots: dependencies: react: 18.3.1 + make-error@1.3.6: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -2476,13 +2962,19 @@ snapshots: dependencies: react: 18.3.1 + react-i18next@15.4.1(i18next@24.2.3(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.26.9 + html-parse-stringify: 3.0.1 + i18next: 24.2.3(typescript@5.6.3) + react: 18.3.1 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + react-is@16.13.1: {} -<<<<<<< HEAD react-is@18.3.1: {} -======= ->>>>>>> origin/develop react-refresh@0.14.2: {} react-router-dom@7.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -2618,6 +3110,24 @@ snapshots: dependencies: typescript: 5.6.3 + ts-node@10.9.2(@types/node@22.13.14)(typescript@5.6.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.13.14 + acorn: 8.14.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.6.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + tslib@2.8.1: {} turbo-stream@2.4.0: {} @@ -2638,6 +3148,8 @@ snapshots: typescript@5.6.3: {} + undici-types@6.20.0: {} + update-browserslist-db@1.1.1(browserslist@4.24.3): dependencies: browserslist: 4.24.3 @@ -2652,6 +3164,8 @@ snapshots: dependencies: inherits: 2.0.3 + v8-compile-cache-lib@3.0.1: {} + victory-vendor@36.9.2: dependencies: '@types/d3-array': 3.2.1 @@ -2669,15 +3183,18 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@6.0.7(sass@1.83.4): + vite@6.0.7(@types/node@22.13.14)(sass@1.83.4): dependencies: esbuild: 0.24.2 postcss: 8.4.49 rollup: 4.29.2 optionalDependencies: + '@types/node': 22.13.14 fsevents: 2.3.3 sass: 1.83.4 + void-elements@3.1.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -2686,6 +3203,8 @@ snapshots: yallist@3.1.1: {} + yn@3.1.1: {} + yocto-queue@0.1.0: {} zod@3.24.2: {} diff --git a/scripts/generateTranslations.js b/scripts/generateTranslations.js new file mode 100644 index 0000000..b799635 --- /dev/null +++ b/scripts/generateTranslations.js @@ -0,0 +1,49 @@ +// scripts/generateTranslations.js + +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import fs from 'fs'; +import csv from 'csv-parser'; + +// __filename, __dirname 대신 사용할 수 있는 ESM 전용 코드 +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// CSV 파일 경로 및 출력 디렉터리 설정 +const csvFilePath = join(__dirname, '..', 'translations', 'translations.csv'); +const localesPath = join(__dirname, '..', 'locales'); + +// 언어별 번역 데이터를 담을 객체 +const translations = {}; + +// CSV 파일을 스트림으로 읽고, 각 행(row)을 처리 +fs.createReadStream(csvFilePath) + .pipe(csv()) + .on('data', row => { + const translationKey = row.key; + for (const lang in row) { + if (lang === 'key') continue; + if (!translations[lang]) { + translations[lang] = {}; + } + translations[lang][translationKey] = row[lang]; + } + }) + .on('end', () => { + console.log('CSV 파일 읽기 완료!'); + + // 언어별로 common.json 파일 생성 + for (const lang of Object.keys(translations)) { + const langDir = join(localesPath, lang); + if (!fs.existsSync(langDir)) { + fs.mkdirSync(langDir, { recursive: true }); + } + const jsonFilePath = join(langDir, 'common.json'); + fs.writeFileSync( + jsonFilePath, + JSON.stringify(translations[lang], null, 2), + 'utf8', + ); + console.log(`${lang} -> ${jsonFilePath} 생성 완료`); + } + }); diff --git a/src/components/common/button/Button.tsx b/src/components/common/button/Button.tsx index 317005e..e6eee37 100644 --- a/src/components/common/button/Button.tsx +++ b/src/components/common/button/Button.tsx @@ -11,8 +11,15 @@ interface ButtonProps extends React.ButtonHTMLAttributes { } /** - * - variant: "default" | "outline" - * - 나머지 props: (onClick, children 등) ButtonHTMLAttributes + * - variant: 버튼 버전 "default" | "outline" + * - default: 기본 버튼 + * - outline: 테두리 버튼 + * - size: 버튼 사이즈 설정 "small" | "medium" | "large" + * - small: 작은 버튼 + * - medium: 중간 버튼 + * - large: 큰 버튼 + * - children: 버튼 내용 + * - 나머지 props: (onClick, children 등) ButtonHTMLAttributes(button 태그의 속성) */ export default function Button({ variant = 'default', diff --git a/src/components/common/card/Card.tsx b/src/components/common/card/Card.tsx new file mode 100644 index 0000000..b01583c --- /dev/null +++ b/src/components/common/card/Card.tsx @@ -0,0 +1,19 @@ +import styles from './card.module.scss'; + +interface CardProps extends React.HTMLAttributes { + children: React.ReactNode; +} + +/** + * - 카드 컴포넌트 + * - 카드 모양으로 특정 컴포넌트를 감쌀 때 사용 + * - children: 카드 내용 + * - 나머지 props: (onClick, children 등) HTMLAttributes(div 태그의 속성) + */ +export default function Card({ children, ...props }: CardProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/common/card/card.module.scss b/src/components/common/card/card.module.scss new file mode 100644 index 0000000..f749152 --- /dev/null +++ b/src/components/common/card/card.module.scss @@ -0,0 +1,8 @@ +.card { + position: relative; + border: 1px solid var(--color-gray-200); + border-radius: 12px; + background-color: #fff; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); + padding: 24px; +} diff --git a/src/components/common/checkbox/Checkbox.tsx b/src/components/common/checkbox/Checkbox.tsx new file mode 100644 index 0000000..1de51c6 --- /dev/null +++ b/src/components/common/checkbox/Checkbox.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { Check } from 'lucide-react'; +import styles from './checkbox.module.scss'; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( +
+ + + + + + {children && } +
+)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export default Checkbox; diff --git a/src/components/common/checkbox/checkbox.module.scss b/src/components/common/checkbox/checkbox.module.scss new file mode 100644 index 0000000..50f0529 --- /dev/null +++ b/src/components/common/checkbox/checkbox.module.scss @@ -0,0 +1,50 @@ +$primary: var(--color-purple-500); +$primary-foreground: #ffffff; +$ring: var(--color-purple-500); +$ring-offset: #ffffff; + +.checkboxContainer { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.checkbox { + height: 1.25rem; + width: 1.25rem; + flex-shrink: 0; + border: 1px solid $primary; + border-radius: 0.25rem; + background-color: transparent; + transition: background-color 0.2s, color 0.2s; + cursor: pointer; + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 2px $ring, 0 0 0 2px $ring-offset; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + &[data-state='checked'] { + background-color: $primary; + color: $primary-foreground; + } +} + +.checkboxIndicator { + display: flex; + align-items: center; + justify-content: center; + color: currentColor; + height: 100%; + width: 100%; +} + +.checkIcon { + height: 0.75rem; + width: 0.75rem; +} diff --git a/src/components/common/field/CheckboxField.tsx b/src/components/common/field/CheckboxField.tsx new file mode 100644 index 0000000..52d1404 --- /dev/null +++ b/src/components/common/field/CheckboxField.tsx @@ -0,0 +1,99 @@ +import { Control } from 'react-hook-form'; +import FormField from '../form/FormField'; +import FormItem from '../form/FormItem'; +import FormLabel from '../form/FormLabel'; +import Checkbox from '../checkbox/Checkbox'; +import FormMessage from '../form/FormMessage'; + +type CheckboxFieldProps = { + control: Control; + name: string; + label: string; + required?: boolean; + options: { + value: string; + label: string; + }[][]; +}; + +/** + * React Hook Form 체크박스 필드 + * @param control - React Hook Form 컨트롤 + * @param name - 필드 이름 + * @param label - 필드 레이블 + * @param required - 필수 여부 + * @param options - 옵션 목록 + * 예시: + * options = [ + * [ + * { value: 'option1', label: '옵션1' }, + * { value: 'option2', label: '옵션2' }, + * ], + * ] + * + */ +export default function CheckboxField({ + control, + name, + label, + required = false, + options, +}: CheckboxFieldProps) { + return ( + { + const selectedValues = Array.isArray(field.value) ? field.value : []; + return ( + + + {label}{' '} + {required && ( + + * + + )} + + {options.map((optionRow, index) => ( +
+ {optionRow.map(option => ( + selected.value === option.value, + )} + onCheckedChange={checked => { + if (checked) { + field.onChange([...selectedValues, option]); + } else { + field.onChange( + selectedValues.filter( + selected => selected.value !== option.value, + ), + ); + } + }} + > + {option.label} + + ))} +
+ ))} + +
+ ); + }} + /> + ); +} diff --git a/src/components/common/field/InputField.tsx b/src/components/common/field/InputField.tsx index c597504..9e655da 100644 --- a/src/components/common/field/InputField.tsx +++ b/src/components/common/field/InputField.tsx @@ -9,65 +9,99 @@ import type { ControllerRenderProps, FieldValues, } from 'react-hook-form'; +import { forwardRef } from 'react'; type InputFieldProps = { control: Control; name: string; label: string; - type?: 'text' | 'phone' | 'number' | 'date' | 'datetime-local'; + type?: 'text' | 'password' | 'phone' | 'number' | 'date' | 'datetime-local'; placeholder?: string; + icon?: string; required?: boolean; + ref?: React.RefObject; } & React.InputHTMLAttributes; -const InputField = ({ - control, - name, - label, - type = 'text', - placeholder = '필수 입력 칸 입니다.', - required = false, -}: InputFieldProps) => { - const handleInput = ( - e: React.FormEvent, - field: ControllerRenderProps, +/** + * - React Hook Form 일반 입력 필드 컴포넌트 + * - 텍스트, 전화번호, 숫자, 날짜, 시간 입력 필드 타입 지원 + * - 전화번호 입력 시 자동 포맷팅 지원 + * - 필수 입력 칸 입니다. 메시지 표시 지원 + * - control: 폼 제어 객체 + * - name: 필드 이름 + * - label: 필드 레이블 + * - type: 필드 타입 "text" | "phone" | "number" | "date" | "datetime-local" + * - text: 텍스트 입력 필드 + * - password: 비밀번호 입력 필드 + * - phone: 전화번호 입력 필드 + * - number: 숫자 입력 필드 + * - date: 날짜 입력 필드 + * - datetime-local: 날짜 및 시간 입력 필드 + * - placeholder: 필드 플레이스홀더 + * - icon: 필드 아이콘 + * - required: 필수 입력 여부 + * - 나머지 props: (onChange, onKeyDown 등) InputHTMLAttributes(input 태그의 속성) + */ +const InputField = forwardRef( + ( + { + control, + name, + label, + type = 'text', + placeholder = '필수 입력 칸 입니다.', + required = false, + icon, + }: InputFieldProps, + ref, ) => { - const target = e.currentTarget; + const handleInput = ( + e: React.FormEvent, + field: ControllerRenderProps, + ) => { + const target = e.currentTarget; - if (type === 'phone') { - target.value = formatPhoneNumber(target.value); - field.onChange(target.value); - return; - } + if (type === 'phone') { + target.value = formatPhoneNumber(target.value); + field.onChange(target.value); + return; + } - field.onChange(target.value); - }; + field.onChange(target.value); + }; - return ( - ( - - - {label}  {' '} - {required && ( - - * - - )} - - handleInput(e, field)} - onKeyDown={e => e.key === 'Enter' && e.preventDefault()} - type={type} - /> - - - )} - /> - ); -}; + return ( + ( + + + {label}  {' '} + {required && ( + + * + + )} + + handleInput(e, field)} + onKeyDown={e => e.key === 'Enter' && e.preventDefault()} + type={type} + icon={icon} + ref={ref} + /> + + + )} + /> + ); + }, +); export default InputField; diff --git a/src/components/common/field/RadioField.tsx b/src/components/common/field/RadioField.tsx new file mode 100644 index 0000000..01abee5 --- /dev/null +++ b/src/components/common/field/RadioField.tsx @@ -0,0 +1,91 @@ +import { Control } from 'react-hook-form'; +import FormField from '../form/FormField'; +import FormItem from '../form/FormItem'; +import FormLabel from '../form/FormLabel'; +import { RadioGroup, RadioGroupItem } from '../radio/Radio'; +import FormMessage from '../form/FormMessage'; +import styles from './radioField.module.scss'; + +type RadioFieldProps = { + control: Control; + name: string; + label: string; + required?: boolean; + options: { + value: string; + label: string; + }[][]; +}; + +/** + * React Hook Form 라디오 필드 + * @param control - React Hook Form 컨트롤 + * @param name - 필드 이름 + * @param label - 필드 레이블 + * @param required - 필수 여부 + * @param options - 옵션 목록 + * 예시: + * options = [ + * [ + * { value: 'option1', label: '옵션1' }, + * { value: 'option2', label: '옵션2' }, + * ], + */ +export default function RadioField({ + control, + name, + label, + required = false, + options, +}: RadioFieldProps) { + return ( + ( + + + {label}{' '} + {required && ( + + * + + )} + + + {options.map((optionRow, index) => ( +
+ {optionRow.map(option => ( +
+ + +
+ ))} +
+ ))} +
+ +
+ )} + /> + ); +} diff --git a/src/components/common/field/SelectField.tsx b/src/components/common/field/SelectField.tsx index 41b0332..1198b56 100644 --- a/src/components/common/field/SelectField.tsx +++ b/src/components/common/field/SelectField.tsx @@ -3,6 +3,7 @@ import FormField from '../form/FormField'; import FormItem from '../form/FormItem'; import FormLabel from '../form/FormLabel'; import Select from '../select/Select'; +import FormMessage from '../form/FormMessage'; type Props = { control: Control; @@ -15,6 +16,18 @@ type Props = { }[]; }; +/** + * - React Hook Form 선택 필드 컴포넌트 + * - 선택 필드 타입 지원 + * - 필수 입력 칸 입니다. 메시지 표시 지원 + * - control: 폼 제어 객체 + * - name: 필드 이름 + * - label: 필드 레이블 + * - required: 필수 입력 여부 + * - options: 선택 필드 옵션 배열({value: string, label: string}[]) + * - value: 옵션 값 + * - label: 옵션 레이블 + */ export default function SelectField({ control, name, @@ -36,6 +49,7 @@ export default function SelectField({ )} + ); } diff --git a/src/components/common/header/languageButton.module.scss b/src/components/common/header/languageButton.module.scss index 6952db1..b0e0385 100644 --- a/src/components/common/header/languageButton.module.scss +++ b/src/components/common/header/languageButton.module.scss @@ -1,3 +1,7 @@ +.wrapper { + position: relative; +} + .languageButton { background: none; border: none; diff --git a/src/components/common/header/languageSwitcher.module.scss b/src/components/common/header/languageSwitcher.module.scss new file mode 100644 index 0000000..60ccc3f --- /dev/null +++ b/src/components/common/header/languageSwitcher.module.scss @@ -0,0 +1,20 @@ +.wrapper { + position: absolute; + width: 80px; + left: -30px; + background-color: #fff; + border: 1px solid var(--color-gray-200); + border-radius: 5px; +} + +.item { + width: 100%; + padding: 0.125rem; + font-size: 1rem; + text-align: center; + cursor: pointer; + + &:hover { + background-color: var(--color-gray-50); + } +} diff --git a/src/components/common/input/Input.tsx b/src/components/common/input/Input.tsx index 42fb78c..da7d6bc 100644 --- a/src/components/common/input/Input.tsx +++ b/src/components/common/input/Input.tsx @@ -1,4 +1,4 @@ -import { MapPin, Search } from 'lucide-react'; +import { Mail, MapPin, Search, Lock } from 'lucide-react'; import styles from './input.module.scss'; import clsx from 'clsx'; import { forwardRef } from 'react'; @@ -9,6 +9,10 @@ const getIcon = (icon?: string) => { return ; case 'map-pin': return ; + case 'email': + return ; + case 'password': + return ; default: return null; } diff --git a/src/components/common/progress/Progress.tsx b/src/components/common/progress/Progress.tsx index e9d6e4c..1cc1bc7 100644 --- a/src/components/common/progress/Progress.tsx +++ b/src/components/common/progress/Progress.tsx @@ -6,6 +6,12 @@ interface ProgressProps { className?: string; } +/** + * - 진행 바 컴포넌트 + * - 진행 바 모양으로 특정 컴포넌트를 감쌀 때 사용 + * - value: 진행 바 값 + * - className: 진행 바 클래스 이름 + */ export default function Progress({ value, className }: ProgressProps) { const safeValue = Math.min(Math.max(value, 0), 100); diff --git a/src/components/common/progress/progress.module.scss b/src/components/common/progress/progress.module.scss index d14f91f..ee4de46 100644 --- a/src/components/common/progress/progress.module.scss +++ b/src/components/common/progress/progress.module.scss @@ -1,7 +1,7 @@ .progress { position: relative; width: 100%; - height: 0.5rem; + height: 0.75rem; background-color: var(--color-gray-200); border-radius: 9999px; overflow: hidden; @@ -12,7 +12,7 @@ left: 0; top: 0; bottom: 0; - background-color: var(--color-indigo-500); + background-color: var(--color-purple-500); border-radius: inherit; transition: width 0.3s ease; width: 0%; diff --git a/src/components/common/radio/Radio.tsx b/src/components/common/radio/Radio.tsx new file mode 100644 index 0000000..1a242ba --- /dev/null +++ b/src/components/common/radio/Radio.tsx @@ -0,0 +1,38 @@ +import styles from './radio.module.scss'; +import * as React from 'react'; +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'; +import { Circle } from 'lucide-react'; + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; diff --git a/src/components/common/radio/radio.module.scss b/src/components/common/radio/radio.module.scss new file mode 100644 index 0000000..88d3d95 --- /dev/null +++ b/src/components/common/radio/radio.module.scss @@ -0,0 +1,52 @@ +$primary: var(--color-purple-500); +$background: #ffffff; +$ring: var(--color-purple-500); + +.radioGroup { + display: grid; + gap: 0.5rem; +} + +.radioItem { + display: inline-flex; + align-items: center; + justify-content: center; + aspect-ratio: 1 / 1; + height: 1.25rem; + width: 1.25rem; + border: 1px solid $primary; + border-radius: 50%; + color: $primary; + background-color: transparent; + transition: background-color 0.2s, color 0.2s, box-shadow 0.2s; + cursor: pointer; + + &:focus { + outline: none; + } + + &:focus-visible { + box-shadow: 0 0 0 2px $ring, 0 0 0 2px $background; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.radioIndicator { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; +} + +.radioIcon { + display: block; + height: 0.75rem; + width: 0.75rem; + fill: currentColor; + color: currentColor; +} diff --git a/src/components/login/LoginSection.tsx b/src/components/login/LoginSection.tsx new file mode 100644 index 0000000..67d8d96 --- /dev/null +++ b/src/components/login/LoginSection.tsx @@ -0,0 +1,65 @@ +import { useFormContext } from 'react-hook-form'; +import styles from './loginSection.module.scss'; +import InputField from '../common/field/InputField'; +import Button from '../common/button/Button'; +import { Link } from 'react-router-dom'; +import { useRef, useState } from 'react'; +import { Eye, EyeOff } from 'lucide-react'; + +export default function LoginSection() { + const { control } = useFormContext(); + const passwordRef = useRef(null); + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + + const togglePasswordVisibility = () => { + setIsPasswordVisible(!isPasswordVisible); + + setTimeout(() => { + if (passwordRef.current) { + passwordRef.current.focus(); + const length = passwordRef.current.value.length; + passwordRef.current.setSelectionRange(length, length); + } + }, 0); + }; + + return ( +
+ +
+ 비밀번호 찾기 +
+ + {isPasswordVisible ? ( + + ) : ( + + )} +
+ +
+
+ ); +} diff --git a/src/components/login/loginSection.module.scss b/src/components/login/loginSection.module.scss new file mode 100644 index 0000000..ccee2f4 --- /dev/null +++ b/src/components/login/loginSection.module.scss @@ -0,0 +1,36 @@ +.container { + text-align: left; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.searchPasswordLink { + position: absolute; + right: 2em; + top: 9.5em; + font-size: 0.875rem; + + a { + color: var(--color-purple-500); + text-decoration: none; + + &:hover { + color: var(--color-purple-400); + } + } +} + +.passwordIcon { + position: absolute; + right: 3em; + top: 12.875em; + cursor: pointer; + color: var(--color-gray-500); +} + +.buttonContainer { + display: flex; + flex-direction: column; + margin-top: 2rem; +} diff --git a/src/components/main/Banner.tsx b/src/components/main/Banner.tsx index a9d7c07..c8efb8d 100644 --- a/src/components/main/Banner.tsx +++ b/src/components/main/Banner.tsx @@ -1,38 +1,38 @@ import { Link } from 'react-router-dom'; import styles from './banner.module.scss'; import { ChevronRight } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; export default function Banner() { + const { t } = useTranslation('common'); + return (

- 외국인을 위한{' '} - 취업 플랫폼 + {t('mainBannerTitle1')}{' '} + {t('mainBannerTitle2')}

-

- 한국에서 일자리를 찾고 있는 외국인을 위한 맞춤형 취업 정보와 - 서비스를 제공합니다. -

+

{t('mainBannerSubtitle')}

- - 채용정보 보기 + + {t('viewRecruitment')} - 회원가입 + {t('signUp')}
외국인 취업 지원
diff --git a/src/components/profile/applications/ApplicationInfo.tsx b/src/components/profile/applications/ApplicationInfo.tsx new file mode 100644 index 0000000..ae9e8a3 --- /dev/null +++ b/src/components/profile/applications/ApplicationInfo.tsx @@ -0,0 +1,89 @@ +import Card from '@/components/common/card/Card'; +import styles from './applicationInfo.module.scss'; +import { Application } from '@/lib/type/profile/application'; +import { Ban, Building2, Calendar, ChevronRight, FileText } from 'lucide-react'; +import clsx from 'clsx'; +import Button from '@/components/common/button/Button'; + +const parseStatus = (status: string) => { + if (status === 'reviewing') { + return '서류 검토중'; + } + if (status === 'interview') { + return '면접 예정'; + } + if (status === 'accepted') { + return '채용 완료'; + } + if (status === 'rejected') { + return '불합격'; + } + return status; +}; + +interface ApplicationInfoProps { + application: Application; + icon: React.ReactNode; +} + +export default function ApplicationInfo({ + application, + icon, +}: ApplicationInfoProps) { + return ( + +
+
+
+ {application.company} +
+
+

+ {application.title} +

+

+ {application.company} +

+

+ + + {application.location} + + + + + 지원일: {application.appliedAt} + + + + + {application.resumeTitle} + +

+
+
+
+ {icon} + {parseStatus(application.status)} +
+
+
+
+
+ + +
+
+
+ ); +} diff --git a/src/components/profile/applications/ApplicationsTabs.tsx b/src/components/profile/applications/ApplicationsTabs.tsx new file mode 100644 index 0000000..a594166 --- /dev/null +++ b/src/components/profile/applications/ApplicationsTabs.tsx @@ -0,0 +1,85 @@ +import { Application } from '@/lib/type/profile/application'; +import clsx from 'clsx'; +import styles from './applicationsTabs.module.scss'; + +interface ApplicationsTabsProps { + applications: Application[]; + reviewing: Application[]; + interviewing: Application[]; + accepted: Application[]; + selectedApplications: { status: string; applications: Application[] }; + setSelectedApplications: (applications: { + status: string; + applications: Application[]; + }) => void; +} + +export default function ApplicationsTabs({ + applications, + reviewing, + interviewing, + accepted, + selectedApplications, + setSelectedApplications, +}: ApplicationsTabsProps) { + return ( + <> + + + + + + ); +} diff --git a/src/components/profile/applications/StatusBox.tsx b/src/components/profile/applications/StatusBox.tsx new file mode 100644 index 0000000..66c89aa --- /dev/null +++ b/src/components/profile/applications/StatusBox.tsx @@ -0,0 +1,32 @@ +import Card from '@/components/common/card/Card'; +import styles from './statusBox.module.scss'; +import clsx from 'clsx'; + +const statusClassName: Record = { + '전체 지원': 'all', + '서류 검토중': 'reviewing', + '면접 예정': 'interview', + 완료: 'accepted', +}; + +type StatusBoxProps = { + icon: React.ReactNode; + title: string; + number: number; +}; + +export default function StatusBox({ icon, title, number }: StatusBoxProps) { + return ( + +
+
+
{title}
+
{number}
+
+
+ {icon} +
+
+
+ ); +} diff --git a/src/components/profile/applications/applicationInfo.module.scss b/src/components/profile/applications/applicationInfo.module.scss new file mode 100644 index 0000000..a576147 --- /dev/null +++ b/src/components/profile/applications/applicationInfo.module.scss @@ -0,0 +1,131 @@ +.applicationInfo { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.applicationInfoHeader { + display: flex; + gap: 1rem; +} + +.applicationInfoImage { + width: 4rem; + height: 4rem; + background-color: var(--color-gray-100); + border-radius: 0.5rem; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.applicationInfoHeaderText { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.applicationInfoHeaderTitle { + font-size: 1.375rem; + font-weight: 600; + color: var(--color-gray-900); +} + +.applicationInfoHeaderCompany { + font-size: 1.0625rem; + color: var(--color-gray-500); +} + +.applicationInfoHeaderDescription { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + color: var(--color-gray-500); + + span { + display: flex; + align-items: center; + gap: 0.25rem; + } + + svg { + width: 1rem; + height: 1rem; + color: var(--color-gray-500); + } +} + +.applicationInfoStatus { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.tag { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + background-color: var(--color-gray-100); + font-size: 0.875rem; + font-weight: 700; + + svg { + width: 1rem; + height: 1rem; + } + + &.reviewing { + background-color: var(--color-blue-100); + color: var(--color-blue-800); + } + + &.interview { + background-color: var(--color-yellow-100); + color: var(--color-yellow-800); + } + + &.accepted { + background-color: var(--color-green-100); + color: var(--color-green-800); + } + + &.rejected { + background-color: var(--color-red-100); + color: var(--color-red-800); + } +} + +.applicationInfoDivider { + border: none; + border-top: 1px solid var(--color-gray-200); +} + +.applicationInfoActions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + align-items: center; +} + +.buttonItem { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + + &.cancel { + color: var(--color-red-600); + } + + svg { + width: 1.375rem; + height: 1.375rem; + } +} diff --git a/src/components/profile/applications/applicationsTabs.module.scss b/src/components/profile/applications/applicationsTabs.module.scss new file mode 100644 index 0000000..35f1b24 --- /dev/null +++ b/src/components/profile/applications/applicationsTabs.module.scss @@ -0,0 +1,14 @@ +.tab { + border: none; + border-radius: 0.5rem; + background-color: none; + padding: 0.5rem 0.625rem; + font-size: 1.0625rem; + font-weight: 500; + color: var(--color-gray-900); + cursor: pointer; + + &.active { + background-color: #fff; + } +} diff --git a/src/components/profile/applications/statusBox.module.scss b/src/components/profile/applications/statusBox.module.scss new file mode 100644 index 0000000..9269759 --- /dev/null +++ b/src/components/profile/applications/statusBox.module.scss @@ -0,0 +1,50 @@ +.container { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.left { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.title { + font-size: 1rem; + color: var(--color-gray-500); +} + +.number { + font-size: 2rem; + font-weight: 600; +} + +.right { + display: flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + padding: 0.5rem; + + &.all { + background-color: var(--color-purple-50); + color: var(--color-purple-600); + } + + &.reviewing { + background-color: var(--color-blue-50); + color: var(--color-blue-600); + } + + &.interview { + background-color: var(--color-green-50); + color: var(--color-green-600); + } + + &.accepted { + background-color: var(--color-green-50); + color: var(--color-green-600); + } +} diff --git a/src/components/register/FirstSection.tsx b/src/components/register/FirstSection.tsx new file mode 100644 index 0000000..58feb5f --- /dev/null +++ b/src/components/register/FirstSection.tsx @@ -0,0 +1,68 @@ +import { SetStateAction } from 'react'; +import { Dispatch } from 'react'; +import styles from './firstSection.module.scss'; +import InputField from '../common/field/InputField'; +import { useFormContext } from 'react-hook-form'; +import Button from '../common/button/Button'; +import { ChevronRight } from 'lucide-react'; + +interface Props { + setProgress: Dispatch>; +} + +export default function FirstSection({ setProgress }: Props) { + const { control, trigger, watch } = useFormContext(); + + const email = watch('email'); + const password = watch('password'); + const passwordConfirm = watch('passwordConfirm'); + + const onClickNext = async () => { + const isValid = await trigger(['email', 'password', 'passwordConfirm']); + + if ( + isValid && + email && + password && + passwordConfirm && + password === passwordConfirm + ) { + setProgress(2); + } + }; + + return ( +
+

계정 정보

+ + + +
+ +
+
+ ); +} diff --git a/src/components/register/FourthSection.tsx b/src/components/register/FourthSection.tsx new file mode 100644 index 0000000..7ddd371 --- /dev/null +++ b/src/components/register/FourthSection.tsx @@ -0,0 +1,28 @@ +import { Dispatch } from 'react'; +import { SetStateAction } from 'react'; +import styles from './fourthSection.module.scss'; +import Button from '../common/button/Button'; +import { ChevronLeft } from 'lucide-react'; + +interface Props { + setProgress: Dispatch>; +} + +export default function FourthSection({ setProgress }: Props) { + const onClickPrevious = () => { + setProgress(3); + }; + + return ( +
+

약관 동의

+
+ + +
+
+ ); +} diff --git a/src/components/register/SecondSection.tsx b/src/components/register/SecondSection.tsx new file mode 100644 index 0000000..b6f2dff --- /dev/null +++ b/src/components/register/SecondSection.tsx @@ -0,0 +1,88 @@ +import { SetStateAction } from 'react'; +import { Dispatch } from 'react'; +import styles from './secondSection.module.scss'; +import InputField from '../common/field/InputField'; +import { useFormContext } from 'react-hook-form'; +import SelectField from '../common/field/SelectField'; +import { + nationalityValues, + sexValues, +} from '@/lib/constants/registerSelectForm'; +import Button from '../common/button/Button'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import RadioField from '../common/field/RadioField'; + +interface Props { + setProgress: Dispatch>; +} + +export default function SecondSection({ setProgress }: Props) { + const { control, trigger, watch } = useFormContext(); + + const name = watch('name'); + const sex = watch('sex'); + const phoneNumber = watch('phoneNumber'); + const nationality = watch('nationality'); + + const onClickPrevious = () => { + setProgress(1); + }; + + const onClickNext = async () => { + const isValid = await trigger([ + 'name', + 'sex', + 'phoneNumber', + 'nationality', + ]); + + if (isValid && name && sex && phoneNumber && nationality) { + setProgress(3); + } + }; + + return ( +
+

기본 정보

+ + + + +
+ + +
+
+ ); +} diff --git a/src/components/register/ThirdSections.tsx b/src/components/register/ThirdSections.tsx new file mode 100644 index 0000000..4cde84b --- /dev/null +++ b/src/components/register/ThirdSections.tsx @@ -0,0 +1,70 @@ +import { SetStateAction } from 'react'; +import { Dispatch } from 'react'; +import styles from './thirdSection.module.scss'; +import { useFormContext } from 'react-hook-form'; +import SelectField from '../common/field/SelectField'; +import { + languageAbilityValues, + visaStatusValues, + interestValues, +} from '@/lib/constants/registerSelectForm'; +import Button from '../common/button/Button'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import CheckboxField from '../common/field/CheckboxField'; + +interface Props { + setProgress: Dispatch>; +} + +export default function ThirdSection({ setProgress }: Props) { + const { control, trigger, watch } = useFormContext(); + + const visaStatus = watch('visaStatus'); + const languageAbility = watch('languageAbility'); + + const onClickPrevious = () => { + setProgress(2); + }; + + const onClickNext = async () => { + const isValid = await trigger(['visaStatus', 'languageAbility']); + + if (isValid && visaStatus && languageAbility) { + setProgress(4); + } + }; + + return ( +
+

추가 정보

+ + + +
+ + +
+
+ ); +} diff --git a/src/components/register/firstSection.module.scss b/src/components/register/firstSection.module.scss new file mode 100644 index 0000000..f3a41a9 --- /dev/null +++ b/src/components/register/firstSection.module.scss @@ -0,0 +1,17 @@ +.container { + text-align: left; + display: flex; + flex-direction: column; + gap: 1.75rem; + + h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 1.75rem; + } +} + +.actions { + display: flex; + justify-content: flex-end; +} diff --git a/src/components/register/fourthSection.module.scss b/src/components/register/fourthSection.module.scss new file mode 100644 index 0000000..5a52cd0 --- /dev/null +++ b/src/components/register/fourthSection.module.scss @@ -0,0 +1,17 @@ +.container { + text-align: left; + display: flex; + flex-direction: column; + gap: 1.75rem; + + h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 1.75rem; + } +} + +.actions { + display: flex; + justify-content: space-between; +} diff --git a/src/components/register/secondSection.module.scss b/src/components/register/secondSection.module.scss new file mode 100644 index 0000000..5a52cd0 --- /dev/null +++ b/src/components/register/secondSection.module.scss @@ -0,0 +1,17 @@ +.container { + text-align: left; + display: flex; + flex-direction: column; + gap: 1.75rem; + + h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 1.75rem; + } +} + +.actions { + display: flex; + justify-content: space-between; +} diff --git a/src/components/register/thirdSection.module.scss b/src/components/register/thirdSection.module.scss new file mode 100644 index 0000000..5a52cd0 --- /dev/null +++ b/src/components/register/thirdSection.module.scss @@ -0,0 +1,17 @@ +.container { + text-align: left; + display: flex; + flex-direction: column; + gap: 1.75rem; + + h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 1.75rem; + } +} + +.actions { + display: flex; + justify-content: space-between; +} diff --git a/src/lib/constants/navItems.ts b/src/lib/constants/navItems.ts index 974ae86..a07505f 100644 --- a/src/lib/constants/navItems.ts +++ b/src/lib/constants/navItems.ts @@ -1,22 +1,22 @@ export const navItems = [ { id: 1, - name: '채용정보', + name: 'recruitmentInfo', link: './jobs', }, { id: 2, - name: '기업정보', + name: 'companiesInfo', link: './companies', }, { id: 3, - name: '주변기업찾기', + name: 'nearBy', link: './nearby-companies', }, { id: 4, - name: '커뮤니티', + name: 'community', link: './community', }, ]; diff --git a/src/lib/constants/registerSelectForm.ts b/src/lib/constants/registerSelectForm.ts new file mode 100644 index 0000000..f306fdc --- /dev/null +++ b/src/lib/constants/registerSelectForm.ts @@ -0,0 +1,144 @@ +export const sexValues = [ + [ + { + label: '남성', + value: 'male', + }, + { + label: '여성', + value: 'female', + }, + ], +]; + +export const nationalityValues = [ + { + label: '선택하세요', + value: '', + }, + { + label: '대한민국', + value: 'korea', + }, + { + label: '라오스', + value: 'laos', + }, + { + label: '몽골', + value: 'mongolia', + }, + { + label: '미국', + value: 'usa', + }, + { + label: '미얀마', + value: 'myanmar', + }, + { + label: '베트남', + value: 'vietnam', + }, + { + label: '중국', + value: 'china', + }, + { + label: '인도', + value: 'india', + }, + { + label: '인도네시아', + value: 'indonesia', + }, + { + label: '일본', + value: 'japan', + }, + { + label: '태국', + value: 'thailand', + }, + { + label: '필리핀', + value: 'philippines', + }, +]; + +export const visaStatusValues = [ + { + label: '비자 없음', + value: 'none', + }, + { + label: '비자 있음', + value: 'have', + }, +]; + +export const languageAbilityValues = [ + [ + { + label: '한국어', + value: 'korean', + }, + { + label: '영어', + value: 'english', + }, + ], + [ + { + label: '중국어', + value: 'chinese', + }, + { + label: '일본어', + value: 'japanese', + }, + ], + [ + { + label: '베트남어', + value: 'vietnamese', + }, + { + label: '태국어', + value: 'thai', + }, + ], +]; + +export const interestValues = [ + [ + { + label: 'IT/개발', + value: 'it', + }, + { + label: '서비스업', + value: 'service', + }, + ], + [ + { + label: '제조/생산', + value: 'manufacturing', + }, + { + label: '교육', + value: 'education', + }, + ], + [ + { + label: '사무/관리', + value: 'administration', + }, + { + label: '영업/마케팅', + value: 'sales', + }, + ], +]; diff --git a/src/lib/schemas/error.ts b/src/lib/schemas/error.ts new file mode 100644 index 0000000..0cda2e1 --- /dev/null +++ b/src/lib/schemas/error.ts @@ -0,0 +1,19 @@ +export const ERROR_MSG = { + required: '필수 입력 항목입니다.', + numberRequired: '1 이상의 숫자를 입력해주세요.', + password: { + min: '8자 이상으로 입력해주세요.', + confirm: '비밀번호가 일치하지 않습니다.', + }, + exceed: { + ten: '10자 이내로 입력해주세요.', + thirty: '30자 이내로 입력해주세요.', + fifty: '50자 이내로 입력해주세요.', + hundred: '100자 이내로 입력해주세요.', + thousand: '1000자 이내로 입력해주세요.', + twoThousand: '2000자 이내로 입력해주세요.', + fiveThousand: '5000자 이내로 입력해주세요.', + }, + email: '올바른 이메일 형식을 입력해주세요.', + phoneNumber: '올바른 전화번호를 입력해주세요.', +}; diff --git a/src/lib/schemas/loginSchema.ts b/src/lib/schemas/loginSchema.ts new file mode 100644 index 0000000..eacf50c --- /dev/null +++ b/src/lib/schemas/loginSchema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { ERROR_MSG } from './error'; + +export const loginSchema = z.object({ + email: z.string().email(ERROR_MSG.email).max(50, ERROR_MSG.exceed.fifty), + password: z.string().min(8, ERROR_MSG.password.min), +}); + +export type LoginValues = z.infer; + +export const validateLogin = (formData: FormData) => { + const formValues = Object.fromEntries(formData.entries()); + + return loginSchema.safeParse(formValues); +}; diff --git a/src/lib/schemas/regex.ts b/src/lib/schemas/regex.ts new file mode 100644 index 0000000..c0da3cd --- /dev/null +++ b/src/lib/schemas/regex.ts @@ -0,0 +1,3 @@ +export const REGEX = { + phoneNumber: /^\d{2,3}-\d{3,4}-\d{4}$/, +}; diff --git a/src/lib/schemas/registerSchema.ts b/src/lib/schemas/registerSchema.ts new file mode 100644 index 0000000..9c0d30d --- /dev/null +++ b/src/lib/schemas/registerSchema.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; +import { ERROR_MSG } from './error'; +import { REGEX } from './regex'; + +export const registerSchema = z.object({ + email: z.string().email(ERROR_MSG.email).max(50, ERROR_MSG.exceed.fifty), + password: z.string().min(8, ERROR_MSG.password.min), + name: z.string().min(1, ERROR_MSG.required).max(30, ERROR_MSG.exceed.thirty), + sex: z.string().min(1, ERROR_MSG.required), + phoneNumber: z + .string() + .min(1, ERROR_MSG.required) + .regex(REGEX.phoneNumber, ERROR_MSG.phoneNumber), + nationality: z.string().min(1, ERROR_MSG.required), + visaStatus: z.string(), + languageAbility: z.array( + z.object({ + label: z.string(), + value: z.string(), + }), + ), + interests: z.array( + z.object({ + label: z.string(), + value: z.string(), + }), + ), +}); + +export type RegisterValues = z.infer; + +export const validateRegister = (formData: FormData) => { + const formValues = Object.fromEntries(formData.entries()); + + return registerSchema.safeParse(formValues); +}; diff --git a/src/lib/schemas/resumeSchema.ts b/src/lib/schemas/resumeSchema.ts index e958d9f..3d91e01 100644 --- a/src/lib/schemas/resumeSchema.ts +++ b/src/lib/schemas/resumeSchema.ts @@ -1,23 +1,6 @@ import { z } from 'zod'; - -const REGEX = { - phoneNumber: /^\d{2,3}-\d{3,4}-\d{4}$/, -}; - -const ERROR_MSG = { - required: '필수 입력 항목입니다.', - numberRequired: '1 이상의 숫자를 입력해주세요.', - exceed: { - ten: '10자 이내로 입력해주세요.', - thirty: '30자 이내로 입력해주세요.', - fifty: '50자 이내로 입력해주세요.', - hundred: '100자 이내로 입력해주세요.', - thousand: '1000자 이내로 입력해주세요.', - twoThousand: '2000자 이내로 입력해주세요.', - fiveThousand: '5000자 이내로 입력해주세요.', - }, - phoneNumber: '올바른 전화번호를 입력해주세요.', -}; +import { ERROR_MSG } from './error'; +import { REGEX } from './regex'; export const resumeSchema = z.object({ title: z.string().min(1, ERROR_MSG.required).max(50, ERROR_MSG.exceed.fifty), diff --git a/src/lib/type/profile/application.ts b/src/lib/type/profile/application.ts new file mode 100644 index 0000000..e675e23 --- /dev/null +++ b/src/lib/type/profile/application.ts @@ -0,0 +1,10 @@ +export type Application = { + id: number; + company: string; + title: string; + logo: string; + location: string; + appliedAt: string; + status: string; + resumeTitle: string; +}; diff --git a/src/main.tsx b/src/main.tsx index 444c88e..f1cc26c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,8 +3,10 @@ import { createRoot } from 'react-dom/client'; import App from './App.tsx'; import './global.css'; +import '../i18n/i18n.ts'; + createRoot(document.getElementById('root')!).render( - + , ); diff --git a/src/pages/login/Page.tsx b/src/pages/login/Page.tsx new file mode 100644 index 0000000..5638e9d --- /dev/null +++ b/src/pages/login/Page.tsx @@ -0,0 +1,46 @@ +import { Link } from 'react-router-dom'; +import styles from './page.module.scss'; +import { FormProvider, useForm } from 'react-hook-form'; +import Card from '@/components/common/card/Card'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { loginSchema, LoginValues } from '@/lib/schemas/loginSchema'; +import LoginSection from '@/components/login/LoginSection'; + +const defaultValues = { + email: '', + password: '', +}; + +export default function LoginPage() { + const formState = useForm({ + defaultValues, + resolver: zodResolver(loginSchema), + }); + + const onSubmit = (data: LoginValues) => { + console.log(data); + }; + + const onError = (error: unknown) => { + console.error(error); + }; + + return ( +
+
+

JobForeigner

+

로그인

+

+ 아직 계정이 없으신가요? 회원가입 +

+ + +
+ + +
+
+
+
+ ); +} diff --git a/src/pages/login/page.module.scss b/src/pages/login/page.module.scss new file mode 100644 index 0000000..df9dd43 --- /dev/null +++ b/src/pages/login/page.module.scss @@ -0,0 +1,44 @@ +.page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + height: 100%; + background-color: var(--color-gray-50); + text-align: center; + padding: 4rem 0; +} + +.container { + width: 420px; + + > h1 { + font-size: 2.5rem; + color: var(--color-purple-500); + font-weight: 700; + margin-bottom: 2rem; + } + + > h2 { + font-size: 1.875rem; + font-weight: 700; + margin-bottom: 1.125rem; + } + + > p { + font-size: 1rem; + font-weight: 400; + margin-bottom: 3rem; + color: var(--color-gray-500); + + > a { + color: var(--color-purple-500); + text-decoration: none; + + &:hover { + color: var(--color-purple-400); + } + } + } +} diff --git a/src/pages/profile/applications/Page.tsx b/src/pages/profile/applications/Page.tsx new file mode 100644 index 0000000..3e0ce79 --- /dev/null +++ b/src/pages/profile/applications/Page.tsx @@ -0,0 +1,198 @@ +import { useState } from 'react'; +import styles from './page.module.scss'; +import StatusBox from '@/components/profile/applications/StatusBox'; +import { Application } from '@/lib/type/profile/application'; +import ApplicationsTabs from '@/components/profile/applications/ApplicationsTabs'; +import ApplicationInfo from '@/components/profile/applications/ApplicationInfo'; +import { + FileTextIcon, + CalendarClock, + CheckCircle2, + Clock, + XCircle, +} from 'lucide-react'; + +function getIcon(title: string) { + if (title === '전체 지원' || title === 'all') { + return ; + } + if (title === '서류 검토중' || title === 'reviewing') { + return ; + } + if (title === '면접 예정' || title === 'interview') { + return ; + } + if (title === '완료' || title === 'accepted') { + return ; + } + if (title === '완료' || title === 'rejected') { + return ; + } + return null; +} + +const applications: Application[] = [ + { + id: 1, + company: '토스', + title: '프론트엔드 개발자', + logo: 'https://toss.im/assets/images/toss-logo.png', + location: '서울 강남구', + appliedAt: '2021-08-01', + status: 'reviewing', + resumeTitle: '프론트엔드 개발자 이력서', + }, + { + id: 2, + company: '당근마켓', + title: '백엔드 개발자', + logo: 'https://toss.im/assets/images/toss-logo.png', + location: '서울 강남구', + appliedAt: '2021-08-01', + status: 'interview', + resumeTitle: '백엔드 개발자 이력서', + }, + { + id: 3, + company: '네이버', + title: '디자이너', + logo: 'https://toss.im/assets/images/toss-logo.png', + location: '경기 성남', + appliedAt: '2021-08-01', + status: 'rejected', + resumeTitle: '디자이너 이력서', + }, + { + id: 4, + company: '카카오', + title: '프론트엔드 개발자', + logo: 'https://toss.im/assets/images/toss-logo.png', + location: '서울 강남구', + appliedAt: '2021-08-01', + status: 'accepted', + resumeTitle: '프론트엔드 개발자 이력서', + }, + { + id: 5, + company: '라인', + title: '프론트엔드 개발자', + logo: 'https://toss.im/assets/images/toss-logo.png', + location: '도쿄 신주쿠', + appliedAt: '2021-08-01', + status: 'interview', + resumeTitle: '프론트엔드 개발자 이력서', + }, + { + id: 6, + company: '우아한 형제들', + title: '프론트엔드 개발자', + logo: 'https://toss.im/assets/images/toss-logo.png', + location: '경기 성남', + appliedAt: '2021-08-01', + status: 'reviewing', + resumeTitle: '프론트엔드 개발자 이력서', + }, + { + id: 7, + company: '쿠팡', + title: '프론트엔드 개발자', + logo: 'https://toss.im/assets/images/toss-logo.png', + location: '서울 강남구', + appliedAt: '2021-08-01', + status: 'interview', + resumeTitle: '프론트엔드 개발자 이력서', + }, + { + id: 8, + company: '다쏘시스템', + title: '프론트엔드 개발자', + logo: 'https://toss.im/assets/images/toss-logo.png', + location: '대구 중구', + appliedAt: '2021-08-01', + status: 'reviewing', + resumeTitle: '프론트엔드 개발자 이력서', + }, +]; + +export default function ApplicationsPage() { + const [selectedApplications, setSelectedApplications] = useState<{ + status: string; + applications: Application[]; + }>({ + status: 'all', + applications: applications, + }); + + const reviewing = applications.filter( + application => application.status === 'reviewing', + ); + const interviewing = applications.filter( + application => application.status === 'interview', + ); + const accepted = applications.filter( + application => + application.status === 'accepted' || application.status === 'rejected', + ); + + const statusBoxes = [ + { + id: 1, + title: '전체 지원', + number: applications.length, + }, + { + id: 2, + title: '서류 검토중', + number: reviewing.length, + }, + { + id: 3, + title: '면접 예정', + number: interviewing.length, + }, + { + id: 4, + title: '완료', + number: accepted.length, + }, + ]; + + return ( +
+
+

지원 내역

+

+ 지원한 채용 공고와 진행 상태를 확인할 수 있습니다. +

+
+ {statusBoxes.map(statusBox => ( + + ))} +
+
+ +
+
+ {selectedApplications.applications.map(application => ( + + ))} +
+
+
+ ); +} diff --git a/src/pages/profile/applications/page.module.scss b/src/pages/profile/applications/page.module.scss new file mode 100644 index 0000000..e102c1c --- /dev/null +++ b/src/pages/profile/applications/page.module.scss @@ -0,0 +1,48 @@ +.container { + background-color: var(--color-gray-50); + flex: 1; + padding: 1rem; +} + +.page { + max-width: 1024px; + margin: 0 auto; + margin-top: 3rem; + margin-bottom: 6rem; +} + +.pageTitle { + font-size: 1.5rem; + line-height: 2rem; + font-weight: 700; + color: var(--color-gray-900); +} + +.pageDescription { + margin-top: 0.25rem; + font-size: 0.875rem; + color: var(--color-gray-500); + margin-bottom: 1.5rem; +} + +.statusBoxes { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; +} + +.tabs { + display: inline-flex; + gap: 0.25rem; + margin-top: 2rem; + margin-bottom: 1rem; + border-radius: 0.5rem; + background-color: var(--color-indigo-50); + padding: 0.25rem; +} + +.applications { + display: flex; + flex-direction: column; + gap: 1rem; +} diff --git a/src/pages/register/Page.tsx b/src/pages/register/Page.tsx new file mode 100644 index 0000000..7cfc764 --- /dev/null +++ b/src/pages/register/Page.tsx @@ -0,0 +1,67 @@ +import Card from '@/components/common/card/Card'; +import styles from './page.module.scss'; +import { Link } from 'react-router-dom'; +import { useState } from 'react'; +import Progress from '@/components/common/progress/Progress'; +import FirstSection from '@/components/register/FirstSection'; +import SecondSection from '@/components/register/SecondSection'; +import ThirdSection from '@/components/register/ThirdSections'; +import FourthSection from '@/components/register/FourthSection'; +import { FormProvider, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { registerSchema, RegisterValues } from '@/lib/schemas/registerSchema'; + +const defaultValues = { + email: '', + password: '', + passwordConfirm: '', + name: '', + sex: '', + phoneNumber: '', + nationality: '', + visaStatus: 'none', + languageAbility: [], + interests: [], +}; + +export default function RegisterPage() { + const [progress, setProgress] = useState(1); + const formState = useForm({ + defaultValues, + resolver: zodResolver(registerSchema), + }); + + const onSubmit = (data: RegisterValues) => { + console.log(data); + }; + + const onError = (error: unknown) => { + console.error(error); + }; + + return ( +
+
+

JobForeigner

+

회원가입

+

+ 이미 계정이 있으신가요? 로그인 +

+
+ {progress} / 4 단계 + +
+ + +
+ {progress === 1 && } + {progress === 2 && } + {progress === 3 && } + {progress === 4 && } + +
+
+
+
+ ); +} diff --git a/src/pages/register/page.module.scss b/src/pages/register/page.module.scss new file mode 100644 index 0000000..40eaef6 --- /dev/null +++ b/src/pages/register/page.module.scss @@ -0,0 +1,58 @@ +.page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + height: 100%; + background-color: var(--color-gray-50); + text-align: center; + padding: 4rem 0; +} + +.container { + width: 420px; + + > h1 { + font-size: 2.5rem; + color: var(--color-purple-500); + font-weight: 700; + margin-bottom: 2rem; + } + + > h2 { + font-size: 1.875rem; + font-weight: 700; + margin-bottom: 1.125rem; + } + + > p { + font-size: 1rem; + font-weight: 400; + margin-bottom: 3rem; + color: var(--color-gray-500); + + > a { + color: var(--color-purple-500); + text-decoration: none; + + &:hover { + color: var(--color-purple-400); + } + } + } +} + +.progressBar { + display: flex; + width: inherit; + flex-direction: column; + align-items: center; + gap: 1rem; + margin-bottom: 2rem; + + span { + width: inherit; + text-align: left; + } +} diff --git a/src/router.tsx b/src/router.tsx index 92896df..40d1c33 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -16,10 +16,15 @@ const ResumeListPage = lazy(() => import('./pages/profile/resume/Page')); const CreateResumePage = lazy( () => import('./pages/profile/resume/create/Page'), ); +const ApplicationsPage = lazy( + () => import('./pages/profile/applications/Page'), +); const CommunityPage = lazy(() => import('./pages/community/Page')); const CompaniesPage = lazy(() => import('./pages/companies/Page')); const DetailCompanyPage = lazy(() => import('./pages/companies/DetailPage')); const NotFoundPage = lazy(() => import('./pages/notFound/Page')); +const RegisterPage = lazy(() => import('./pages/register/Page')); +const LoginPage = lazy(() => import('./pages/login/Page')); // 각 페이지를 Suspense가 적용된 HOC로 감싸기 const SuspensedMainPage = withSuspense(MainPage); @@ -30,6 +35,9 @@ const SuspensedCommunityPage = withSuspense(CommunityPage); const SuspensedCompaniesPage = withSuspense(CompaniesPage); const SuspensedDetailCompanyPage = withSuspense(DetailCompanyPage); const SuspensedNotFoundPage = withSuspense(NotFoundPage); +const SuspensedRegisterPage = withSuspense(RegisterPage); +const SuspensedLoginPage = withSuspense(LoginPage); +const SuspensedApplicationsPage = withSuspense(ApplicationsPage); export const router = createBrowserRouter( createRoutesFromElements( @@ -40,6 +48,8 @@ export const router = createBrowserRouter( } /> } /> } /> + } /> + } /> {/* Sidebar가 포함된 라우트 */} @@ -50,6 +60,10 @@ export const router = createBrowserRouter( path='/profile/resume/create' element={} /> + } + /> {/* Layout이 적용되지 않는 라우트 */} diff --git a/translations/translations.csv b/translations/translations.csv new file mode 100644 index 0000000..4e4d170 --- /dev/null +++ b/translations/translations.csv @@ -0,0 +1,12 @@ +key,ko,en +signUp,회원가입,Sign up +login,로그인,Login +searchRecruitment,채용 공고 검색하기,Search for job postings +recruitmentInfo,채용정보,recruitment +companiesInfo,기업정보,Companies +nearBy,주변기업찾기,Surrounding +community,커뮤니티,Community +mainBannerTitle1,외국인을 위한,For foreigners +mainBannerTitle2,채용 플랫폼,recruitment platform +mainBannerSubtitle,한국에서 일자리를 찾고 있는 외국인을 위한 맞춤형 취업 정보와 서비스를 제공합니다,It provides customized employment information and services for foreigners looking for a job in Korea +viewRecruitment,채용정보 보기,View Recruitment \ No newline at end of file diff --git a/tsconfig.app.json b/tsconfig.app.json index 689287a..1056cba 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -5,6 +5,8 @@ "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", + "esModuleInterop": true, + "resolveJsonModule": true, "skipLibCheck": true, /* Bundler mode */