diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b10a7bf..75cd9d96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'pnpm' - name: Install dependencies diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 00000000..8317345c --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,23 @@ +{ + "name": "desktop", + "productName": "Kareer", + "private": true, + "version": "0.1.0", + "description": "Desktop application", + "main": "dist/src/main.js", + "scripts": { + "build": "tsc", + "check-types": "tsc --noEmit", + "lint": "tsc --noEmit", + "make:mac": "pnpm build && node dist/scripts/make-macos.js", + "start": "pnpm build && electron ." + }, + "devDependencies": { + "@electron/packager": "^20.0.4", + "@kareer/typescript-config": "workspace:*", + "@kds/icons": "workspace:*", + "@types/node": "catalog:", + "electron": "^43.2.0", + "typescript": "catalog:" + } +} diff --git a/apps/desktop/scripts/make-macos.ts b/apps/desktop/scripts/make-macos.ts new file mode 100644 index 00000000..d2ae2d47 --- /dev/null +++ b/apps/desktop/scripts/make-macos.ts @@ -0,0 +1,130 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { packager, type OfficialArch } from '@electron/packager'; + +import { productName, version } from '../package.json'; + +const desktopDir = path.resolve(__dirname, '../..'); +const outDir = path.join(desktopDir, 'out'); +const arch = (process.argv[2] ?? process.arch) as OfficialArch; + +const run = (command: string, args: string[]) => + execFileSync(command, args, { stdio: 'inherit' }); + +const makeIcon = (tempDir: string) => { + const logoPath = require.resolve('@kds/icons/assets/logo.svg'); + const svgPath = path.join(tempDir, 'logo.svg'); + const logo = fs + .readFileSync(logoPath, 'utf8') + .replace(/]*>|<\/svg>/g, ''); + const svg = ` + +${logo} +`; + + fs.writeFileSync(svgPath, svg); + + const previewDir = path.join(tempDir, 'preview'); + fs.mkdirSync(previewDir); + run('/usr/bin/qlmanage', ['-t', '-s', '1024', '-o', previewDir, svgPath]); + + const sourcePng = path.join(previewDir, 'logo.svg.png'); + const iconsetDir = path.join(tempDir, `${productName}.iconset`); + fs.mkdirSync(iconsetDir); + + for (const [name, size] of [ + ['icon_16x16.png', 16], + ['icon_16x16@2x.png', 32], + ['icon_32x32.png', 32], + ['icon_32x32@2x.png', 64], + ['icon_128x128.png', 128], + ['icon_128x128@2x.png', 256], + ['icon_256x256.png', 256], + ['icon_256x256@2x.png', 512], + ['icon_512x512.png', 512], + ['icon_512x512@2x.png', 1024], + ] as const) { + run('/usr/bin/sips', [ + '-z', + String(size), + String(size), + sourcePng, + '--out', + path.join(iconsetDir, name), + ]); + } + + const iconPath = path.join(tempDir, `${productName}.icns`); + run('/usr/bin/iconutil', ['-c', 'icns', iconsetDir, '-o', iconPath]); + return iconPath; +}; + +const main = async () => { + if (process.platform !== 'darwin') { + throw new Error('macOS에서만 DMG를 만들 수 있습니다.'); + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kareer-macos-')); + + try { + const [packageDir] = await packager({ + appBundleId: 'com.teamkareer.kareer', + appCategoryType: 'public.app-category.business', + arch, + asar: true, + dir: desktopDir, + icon: makeIcon(tempDir), + ignore: [ + /\/node_modules(?:\/|$)/, + /\/scripts(?:\/|$)/, + /\.test\.js$/, + /\.ts$/, + ], + name: productName, + out: outDir, + overwrite: true, + platform: 'darwin', + prune: false, + }); + + if (!packageDir) { + throw new Error('Electron 앱 패키징 결과를 찾을 수 없습니다.'); + } + + const appPath = path.join(packageDir, `${productName}.app`); + run('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath]); + + const dmgSourceDir = path.join(tempDir, 'dmg'); + fs.mkdirSync(dmgSourceDir); + run('/usr/bin/ditto', [ + appPath, + path.join(dmgSourceDir, `${productName}.app`), + ]); + fs.symlinkSync('/Applications', path.join(dmgSourceDir, 'Applications')); + + const makeDir = path.join(outDir, 'make'); + const dmgPath = path.join(makeDir, `${productName}-${version}-${arch}.dmg`); + fs.mkdirSync(makeDir, { recursive: true }); + run('/usr/bin/hdiutil', [ + 'create', + '-volname', + productName, + '-srcfolder', + dmgSourceDir, + '-ov', + '-format', + 'UDZO', + dmgPath, + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}; + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts new file mode 100644 index 00000000..54c2a6a9 --- /dev/null +++ b/apps/desktop/src/main.ts @@ -0,0 +1,67 @@ +import { app, BrowserWindow, shell } from 'electron'; + +import { productName } from '../package.json'; +import { isAllowedNavigationUrl, isHttpsUrl } from './url-policy.js'; + +const WEB_URL = 'https://app.ka-reer.com'; + +app.setName(productName); + +const createWindow = () => { + const mainWindow = new BrowserWindow({ + width: 1440, + height: 900, + minWidth: 1024, + minHeight: 700, + autoHideMenuBar: true, + show: false, + webPreferences: { + contextIsolation: true, + devTools: !app.isPackaged, + nodeIntegration: false, + sandbox: true, + }, + }); + + mainWindow.once('ready-to-show', () => mainWindow.show()); + + mainWindow.webContents.on('will-navigate', (event, url) => { + if (!isAllowedNavigationUrl(url)) { + event.preventDefault(); + } + }); + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (isHttpsUrl(url)) { + void shell.openExternal(url); + } + + return { action: 'deny' }; + }); + + mainWindow.webContents.session.setPermissionRequestHandler( + (_webContents, _permission, callback) => callback(false), + ); + + void mainWindow + .loadURL(WEB_URL) + .catch((error) => + console.error(`${productName} 웹을 불러오지 못했습니다.`, error), + ); +}; + +app.whenReady().then(() => { + createWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); diff --git a/apps/desktop/src/url-policy.ts b/apps/desktop/src/url-policy.ts new file mode 100644 index 00000000..68ae502b --- /dev/null +++ b/apps/desktop/src/url-policy.ts @@ -0,0 +1,21 @@ +const ALLOWED_NAVIGATION_ORIGINS = new Set([ + 'https://app.ka-reer.com', + 'https://api.ka-reer.com', + 'https://accounts.google.com', +]); + +export const isAllowedNavigationUrl = (value: string) => { + try { + return ALLOWED_NAVIGATION_ORIGINS.has(new URL(value).origin); + } catch { + return false; + } +}; + +export const isHttpsUrl = (value: string) => { + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } +}; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 00000000..b22beeb8 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@kareer/typescript-config/base.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["src", "scripts"], + "exclude": ["node_modules", "dist", "out"] +} diff --git a/apps/landing/.env.example b/apps/landing/.env.example new file mode 100644 index 00000000..c5d2cf32 --- /dev/null +++ b/apps/landing/.env.example @@ -0,0 +1 @@ +VITE_FORMSPREE_FORM_ID=your_form_id diff --git a/apps/landing/eslint.config.js b/apps/landing/eslint.config.js new file mode 100644 index 00000000..d4eefd1a --- /dev/null +++ b/apps/landing/eslint.config.js @@ -0,0 +1,3 @@ +import { config } from '@kareer/eslint-config/react'; + +export default config; diff --git a/apps/landing/index.html b/apps/landing/index.html new file mode 100644 index 00000000..a845125a --- /dev/null +++ b/apps/landing/index.html @@ -0,0 +1,37 @@ + + + + + + + + + + + + + kareer + + +
+ + + diff --git a/apps/landing/package.json b/apps/landing/package.json new file mode 100644 index 00000000..fac0b7de --- /dev/null +++ b/apps/landing/package.json @@ -0,0 +1,37 @@ +{ + "name": "landing", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@kds/icons": "workspace:*", + "@kds/ui": "workspace:*", + "i18next": "^25.8.13", + "react": "catalog:", + "react-dom": "catalog:", + "react-i18next": "^16.5.4" + }, + "devDependencies": { + "@kareer/eslint-config": "workspace:*", + "@kareer/typescript-config": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vanilla-extract/css": "catalog:", + "@vanilla-extract/recipes": "catalog:", + "@vanilla-extract/vite-plugin": "catalog:", + "@vitejs/plugin-react": "catalog:", + "babel-plugin-react-compiler": "^1.0.0", + "eslint": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-svgr": "catalog:" + } +} diff --git a/apps/landing/public/og.png b/apps/landing/public/og.png new file mode 100644 index 00000000..d0bcad3a Binary files /dev/null and b/apps/landing/public/og.png differ diff --git a/apps/landing/src/assets/bg_why_kareer.jpg b/apps/landing/src/assets/bg_why_kareer.jpg new file mode 100644 index 00000000..f90de3c0 Binary files /dev/null and b/apps/landing/src/assets/bg_why_kareer.jpg differ diff --git a/apps/landing/src/assets/index.ts b/apps/landing/src/assets/index.ts new file mode 100644 index 00000000..93e7e1b9 --- /dev/null +++ b/apps/landing/src/assets/index.ts @@ -0,0 +1 @@ +export { default as bg_why_kareer } from './bg_why_kareer.jpg'; diff --git a/apps/landing/src/assets/pabicon.webp b/apps/landing/src/assets/pabicon.webp new file mode 100644 index 00000000..78941fcd Binary files /dev/null and b/apps/landing/src/assets/pabicon.webp differ diff --git a/apps/landing/src/components/badge/badge.css.ts b/apps/landing/src/components/badge/badge.css.ts new file mode 100644 index 00000000..3ae7b09c --- /dev/null +++ b/apps/landing/src/components/badge/badge.css.ts @@ -0,0 +1,33 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { recipe } from '@vanilla-extract/recipes'; + +export const badge = recipe({ + base: { + ...typography.cap1_sb_12, + display: 'inline-flex', + alignItems: 'center', + gap: '0.6rem', + borderStyle: 'solid', + borderWidth: '1px', + borderRadius: '99px', + }, + variants: { + tone: { + light: { + padding: '0.5rem 1.6rem', + borderColor: themeVars.color.primary[300], + color: themeVars.color.primary[500], + backgroundColor: themeVars.color.primary[100], + }, + dark: { + padding: '0.6rem 1.6rem', + borderColor: themeVars.color.primary[700], + color: themeVars.color.primary[300], + backgroundColor: 'rgba(59, 110, 248, 0.25)', + }, + }, + }, + defaultVariants: { + tone: 'light', + }, +}); diff --git a/apps/landing/src/components/badge/badge.tsx b/apps/landing/src/components/badge/badge.tsx new file mode 100644 index 00000000..38938585 --- /dev/null +++ b/apps/landing/src/components/badge/badge.tsx @@ -0,0 +1,19 @@ +import { ReactNode } from 'react'; + +import * as styles from './badge.css'; + +interface BadgeProps { + icon?: ReactNode; + children: ReactNode; + tone?: 'light' | 'dark'; + className?: string; +} + +const Badge = ({ icon, children, tone = 'light', className }: BadgeProps) => ( +
+ {icon} + {children} +
+); + +export default Badge; diff --git a/apps/landing/src/components/early-access-form/early-access-form.css.ts b/apps/landing/src/components/early-access-form/early-access-form.css.ts new file mode 100644 index 00000000..fe34374a --- /dev/null +++ b/apps/landing/src/components/early-access-form/early-access-form.css.ts @@ -0,0 +1,47 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +export const container = style({ + width: '100%', + maxWidth: '48rem', + margin: '0 auto', +}); + +export const form = style({ + display: 'flex', + gap: '0.8rem', +}); + +export const inputWrapper = style({ + flex: 1, + minWidth: 0, +}); + +export const visuallyHidden = style({ + position: 'absolute', + width: 1, + height: 1, + overflow: 'hidden', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', +}); + +export const note = recipe({ + base: { + marginTop: '1rem', + color: themeVars.color.grayscale.gray500, + }, + variants: { + variant: { + hero: { + ...typography.cap3_r_12, + }, + final: { + ...typography.body6_r_16, + marginBottom: '3.6rem', + lineHeight: 1.6, + }, + }, + }, +}); diff --git a/apps/landing/src/components/early-access-form/early-access-form.tsx b/apps/landing/src/components/early-access-form/early-access-form.tsx new file mode 100644 index 00000000..a17227c4 --- /dev/null +++ b/apps/landing/src/components/early-access-form/early-access-form.tsx @@ -0,0 +1,92 @@ +import { type FormEvent, useState } from 'react'; +import { SuccessCircleIcon, XIcon } from '@kds/icons'; +import { Button, Input, useToast } from '@kds/ui'; +import { useTranslation } from 'react-i18next'; + +import * as styles from './early-access-form.css'; + +const FORMSPREE_FORM_ID = import.meta.env.VITE_FORMSPREE_FORM_ID; +const FORMSPREE_ENDPOINT = FORMSPREE_FORM_ID + ? `https://formspree.io/f/${FORMSPREE_FORM_ID}` + : undefined; + +interface EarlyAccessFormProps { + buttonLabel: string; + note: string; + placeholder: string; + variant?: 'hero' | 'final'; +} + +const EarlyAccessForm = ({ + buttonLabel, + note, + placeholder, + variant = 'hero', +}: EarlyAccessFormProps) => { + const { t } = useTranslation(); + const { showToast } = useToast(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + if (!FORMSPREE_ENDPOINT) { + return; + } + + const form = event.currentTarget; + setIsSubmitting(true); + + try { + const response = await fetch(FORMSPREE_ENDPOINT, { + method: 'POST', + body: new FormData(form), + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error('Formspree submission failed'); + } + + form.reset(); + showToast({ + message: t('form.success'), + icon: , + }); + } catch { + showToast({ + message: t('form.error'), + icon: , + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ + + +
+

{note}

+
+ ); +}; + +export default EarlyAccessForm; diff --git a/apps/landing/src/components/feature-section/feature-section.css.ts b/apps/landing/src/components/feature-section/feature-section.css.ts new file mode 100644 index 00000000..8f696905 --- /dev/null +++ b/apps/landing/src/components/feature-section/feature-section.css.ts @@ -0,0 +1,72 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +export const container = recipe({ + base: { + padding: '8.8rem 4.8rem', + }, + variants: { + background: { + default: { + backgroundColor: themeVars.color.grayscale.white, + }, + muted: { + backgroundColor: themeVars.color.grayscale.gray100, + }, + }, + }, +}); + +export const inner = recipe({ + base: { + display: 'flex', + alignItems: 'center', + gap: '7.2rem', + maxWidth: '104rem', + margin: '0 auto', + }, + variants: { + previewPosition: { + left: { + flexDirection: 'row-reverse', + }, + right: { + flexDirection: 'row', + }, + }, + }, +}); + +export const content = style({ + flex: 1, +}); + +export const featureLabel = style({ + ...typography.cap1_sb_12, + marginBottom: '1.2rem', + color: themeVars.color.primary[500], + letterSpacing: '0.1em', + textTransform: 'uppercase', +}); + +export const title = style({ + marginBottom: '1.4rem', + color: themeVars.color.grayscale.gray800, + fontSize: '3.6rem', + fontWeight: 800, + lineHeight: 1.2, + letterSpacing: '-0.02em', + whiteSpace: 'pre-line', +}); + +export const description = style({ + ...typography.body6_r_16, + maxWidth: '44rem', + color: themeVars.color.grayscale.gray700, + lineHeight: 1.75, +}); + +export const preview = style({ + flex: 1, +}); diff --git a/apps/landing/src/components/feature-section/feature-section.tsx b/apps/landing/src/components/feature-section/feature-section.tsx new file mode 100644 index 00000000..11ea4e27 --- /dev/null +++ b/apps/landing/src/components/feature-section/feature-section.tsx @@ -0,0 +1,38 @@ +import { ReactNode } from 'react'; + +import * as styles from './feature-section.css'; + +interface FeatureSectionProps { + featureLabel: string; + title: string; + description: string; + preview: ReactNode; + id?: string; + previewPosition?: 'left' | 'right'; + background?: 'default' | 'muted'; +} + +const FeatureSection = ({ + featureLabel, + title, + description, + preview, + id, + previewPosition = 'right', + background = 'default', +}: FeatureSectionProps) => { + return ( +
+
+
+

{featureLabel}

+

{title}

+

{description}

+
+
{preview}
+
+
+ ); +}; + +export default FeatureSection; diff --git a/apps/landing/src/components/footer/landing-footer.css.ts b/apps/landing/src/components/footer/landing-footer.css.ts new file mode 100644 index 00000000..5e296bbb --- /dev/null +++ b/apps/landing/src/components/footer/landing-footer.css.ts @@ -0,0 +1,20 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const container = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '2.8rem 4.8rem', + borderTop: `1px solid ${themeVars.color.grayscale.gray300}`, +}); + +export const logo = style({ + display: 'flex', + alignItems: 'center', +}); + +export const note = style({ + ...typography.cap3_r_12, + color: themeVars.color.grayscale.gray500, +}); diff --git a/apps/landing/src/components/footer/landing-footer.tsx b/apps/landing/src/components/footer/landing-footer.tsx new file mode 100644 index 00000000..54086f78 --- /dev/null +++ b/apps/landing/src/components/footer/landing-footer.tsx @@ -0,0 +1,19 @@ +import { LogoIcon } from '@kds/icons'; +import { useTranslation } from 'react-i18next'; + +import * as styles from './landing-footer.css'; + +const LandingFooter = () => { + const { t } = useTranslation('landing'); + + return ( +
+
+ +
+ {t('footer.note')} +
+ ); +}; + +export default LandingFooter; diff --git a/apps/landing/src/components/header/constants/navigation.ts b/apps/landing/src/components/header/constants/navigation.ts new file mode 100644 index 00000000..49236e70 --- /dev/null +++ b/apps/landing/src/components/header/constants/navigation.ts @@ -0,0 +1,18 @@ +import { LANDING_SECTION_ID } from '@constants/section-id'; + +export const NAVIGATION_ITEMS = [ + { + labelKey: 'header.navigation.features', + sectionId: LANDING_SECTION_ID.features, + }, + { + labelKey: 'header.navigation.reviews', + sectionId: LANDING_SECTION_ID.reviews, + }, + { + labelKey: 'header.navigation.earlyAccess', + sectionId: LANDING_SECTION_ID.earlyAccess, + }, +] as const; + +export type LandingSectionId = (typeof NAVIGATION_ITEMS)[number]['sectionId']; diff --git a/apps/landing/src/components/header/hooks/use-landing-navigation.ts b/apps/landing/src/components/header/hooks/use-landing-navigation.ts new file mode 100644 index 00000000..e3fb1eb1 --- /dev/null +++ b/apps/landing/src/components/header/hooks/use-landing-navigation.ts @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState } from 'react'; +import { LANDING_SECTION_ID } from '@constants/index'; + +import { + type LandingSectionId, + NAVIGATION_ITEMS, +} from '../constants/navigation'; + +const scrollToSection = (sectionId: LandingSectionId) => { + document.getElementById(sectionId)?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); +}; + +const useLandingNavigation = () => { + const headerRef = useRef(null); + const [activeSectionId, setActiveSectionId] = + useState(null); + + useEffect(() => { + const header = headerRef.current; + const scrollContainer = header?.parentElement; + + if (!header || !scrollContainer) { + return; + } + + const updateActiveSection = () => { + const isScrollEnd = + scrollContainer.scrollTop + scrollContainer.clientHeight >= + scrollContainer.scrollHeight - 1; + + if (isScrollEnd) { + setActiveSectionId(LANDING_SECTION_ID.earlyAccess); + return; + } + + const activationTop = header.getBoundingClientRect().bottom; + const activeSection = NAVIGATION_ITEMS.reduce( + (currentSection, { sectionId }) => { + const section = document.getElementById(sectionId); + + if ( + section && + section.getBoundingClientRect().top <= activationTop + 1 + ) { + return sectionId; + } + + return currentSection; + }, + null, + ); + + setActiveSectionId(activeSection); + }; + + updateActiveSection(); + scrollContainer.addEventListener('scroll', updateActiveSection, { + passive: true, + }); + + return () => { + scrollContainer.removeEventListener('scroll', updateActiveSection); + }; + }, []); + + return { headerRef, activeSectionId, scrollToSection }; +}; + +export default useLandingNavigation; diff --git a/apps/landing/src/components/header/landing-header.css.ts b/apps/landing/src/components/header/landing-header.css.ts new file mode 100644 index 00000000..9b9432d2 --- /dev/null +++ b/apps/landing/src/components/header/landing-header.css.ts @@ -0,0 +1,70 @@ +import { themeVars, typography, zIndex } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +export const container = style({ + position: 'fixed', + top: 0, + right: 0, + left: 0, + zIndex: zIndex.autocomplete, + height: '6.2rem', + padding: '0 4.8rem', + borderBottom: `1px solid ${themeVars.color.grayscale.gray300}`, + backgroundColor: 'rgba(255, 255, 255, 0.92)', + backdropFilter: 'blur(12px)', +}); + +export const navigation = style({ + display: 'grid', + gridTemplateColumns: '1fr auto 1fr', + alignItems: 'center', + height: '100%', +}); + +export const logo = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + justifySelf: 'start', + flexShrink: 0, +}); + +export const sectionNavigation = style({ + display: 'flex', + alignItems: 'center', + gap: '2.4rem', +}); + +export const actions = style({ + display: 'flex', + alignItems: 'center', + justifySelf: 'end', + gap: '1.2rem', +}); + +export const sectionLink = recipe({ + base: { + ...typography.cap2_m_12, + padding: '0.4rem 0', + borderBottom: '2px solid transparent', + color: themeVars.color.grayscale.gray500, + transition: 'color 0.2s ease', + ':hover': { + color: themeVars.color.grayscale.gray800, + }, + }, + variants: { + active: { + true: { + borderBottomColor: themeVars.color.primary[500], + color: themeVars.color.primary[500], + selectors: { + '&:hover': { + color: themeVars.color.primary[500], + }, + }, + }, + }, + }, +}); diff --git a/apps/landing/src/components/header/landing-header.tsx b/apps/landing/src/components/header/landing-header.tsx new file mode 100644 index 00000000..b0999165 --- /dev/null +++ b/apps/landing/src/components/header/landing-header.tsx @@ -0,0 +1,54 @@ +import { LANDING_SECTION_ID } from '@constants/index'; +import { LogoIcon } from '@kds/icons'; +import { Button } from '@kds/ui'; +import { useTranslation } from 'react-i18next'; + +import { NAVIGATION_ITEMS } from './constants/navigation'; +import useLandingNavigation from './hooks/use-landing-navigation'; +import LanguageSelector from './language-selector/language-selector'; + +import * as styles from './landing-header.css'; + +const LandingHeader = () => { + const { t } = useTranslation('landing'); + const { headerRef, activeSectionId, scrollToSection } = + useLandingNavigation(); + + return ( +
+ +
+ ); +}; + +export default LandingHeader; diff --git a/apps/landing/src/components/header/language-selector/language-selector.css.ts b/apps/landing/src/components/header/language-selector/language-selector.css.ts new file mode 100644 index 00000000..875aafad --- /dev/null +++ b/apps/landing/src/components/header/language-selector/language-selector.css.ts @@ -0,0 +1,5 @@ +import { style } from '@vanilla-extract/css'; + +export const container = style({ + width: '13.9rem', +}); diff --git a/apps/landing/src/components/header/language-selector/language-selector.tsx b/apps/landing/src/components/header/language-selector/language-selector.tsx new file mode 100644 index 00000000..84210f45 --- /dev/null +++ b/apps/landing/src/components/header/language-selector/language-selector.tsx @@ -0,0 +1,31 @@ +import { LANGUAGE_OPTIONS } from '@i18n/constants'; +import { GlobalIcon } from '@kds/icons'; +import { Dropdown } from '@kds/ui'; +import { useTranslation } from 'react-i18next'; + +import * as styles from './language-selector.css'; + +const LanguageSelector = () => { + const { i18n } = useTranslation(); + const currentOption = LANGUAGE_OPTIONS.find( + (opt) => opt.value === i18n.language, + )!; + + const filteredOptions = LANGUAGE_OPTIONS.filter( + (opt) => opt.value !== i18n.language, + ); + + return ( +
+ i18n.changeLanguage(value)} + options={filteredOptions} + icon={} + > + {currentOption.label} + +
+ ); +}; + +export default LanguageSelector; diff --git a/apps/landing/src/constants/career-roadmap-preview.ts b/apps/landing/src/constants/career-roadmap-preview.ts new file mode 100644 index 00000000..4cda0c62 --- /dev/null +++ b/apps/landing/src/constants/career-roadmap-preview.ts @@ -0,0 +1,73 @@ +type PhaseState = 'past' | 'current' | 'future'; +type StatusTone = 'warning' | 'primary' | 'muted'; +type ActionState = 'active' | 'default' | 'completed'; +type ActionTagTone = 'visa' | 'career'; + +interface RoadmapPhase { + step: number; + name: string; + state: PhaseState; + status: string; + statusTone: StatusTone; +} + +interface RoadmapAction { + id: string; + tag: string; + tagTone: ActionTagTone; + label: string; + state: ActionState; + date?: string; + result?: string; +} + +export const ROADMAP_PHASES = [ + { + step: 1, + name: 'Verify Requirements\nSep – Nov', + state: 'past', + status: 'Incomplete 2', + statusTone: 'warning', + }, + { + step: 2, + name: 'Build Experience\nCurrent Phase', + state: 'current', + status: 'Remained 3', + statusTone: 'primary', + }, + { + step: 3, + name: 'D-10 Transition\nSep – Nov', + state: 'future', + status: 'Scheduled 8', + statusTone: 'muted', + }, +] satisfies RoadmapPhase[]; + +export const ROADMAP_ACTIONS = [ + { + id: 'internship-log', + tag: 'Visa', + tagTone: 'visa', + label: 'Prepare internship log', + date: 'Jan 24', + state: 'active', + }, + { + id: 'experience-portfolio', + tag: 'Career', + tagTone: 'career', + label: 'Build experience portfolio', + date: 'Jan 24', + state: 'default', + }, + { + id: 'university-documents', + tag: 'Career', + tagTone: 'career', + label: 'Register university documents', + result: 'Done ✓', + state: 'completed', + }, +] satisfies RoadmapAction[]; diff --git a/apps/landing/src/constants/index.ts b/apps/landing/src/constants/index.ts new file mode 100644 index 00000000..9a24bf17 --- /dev/null +++ b/apps/landing/src/constants/index.ts @@ -0,0 +1,4 @@ +export { ROADMAP_ACTIONS, ROADMAP_PHASES } from './career-roadmap-preview'; +export { JOB_PREVIEWS } from './job-preview'; +export { LANDING_SECTION_ID } from './section-id'; +export { TODO_ITEMS } from './todo-preview'; diff --git a/apps/landing/src/constants/job-preview.ts b/apps/landing/src/constants/job-preview.ts new file mode 100644 index 00000000..3ed1cf98 --- /dev/null +++ b/apps/landing/src/constants/job-preview.ts @@ -0,0 +1,42 @@ +export const JOB_PREVIEWS = [ + { + id: 'part-time', + company: 'Hunjin', + title: 'Frontend Developer', + date: 'Jan 24, 2026', + deadline: 'D-5', + employmentType: 'Part-time', + tagTone: 'blue', + location: 'Incheon', + }, + { + id: 'regular', + company: 'Junghoon', + title: 'Backend Developer', + date: 'Jan 24, 2026', + deadline: 'D-5', + employmentType: 'Regular', + tagTone: 'green', + location: 'Seoul', + }, + { + id: 'discussion', + company: 'Yoonji', + title: 'AI Developer', + date: 'Jan 24, 2026', + deadline: 'D-5', + employmentType: 'Discussion', + tagTone: 'gray', + location: 'Seoul', + }, + { + id: 'contract', + company: 'Haeun', + title: 'Frontend Developer', + date: 'Jan 24, 2026', + deadline: 'D-5', + employmentType: 'Contract', + tagTone: 'orange', + location: 'Changwon', + }, +] as const; diff --git a/apps/landing/src/constants/section-id.ts b/apps/landing/src/constants/section-id.ts new file mode 100644 index 00000000..3b0484e6 --- /dev/null +++ b/apps/landing/src/constants/section-id.ts @@ -0,0 +1,5 @@ +export const LANDING_SECTION_ID = { + features: 'landing-features', + reviews: 'landing-reviews', + earlyAccess: 'landing-early-access', +} as const; diff --git a/apps/landing/src/constants/todo-preview.ts b/apps/landing/src/constants/todo-preview.ts new file mode 100644 index 00000000..2b6b5b7b --- /dev/null +++ b/apps/landing/src/constants/todo-preview.ts @@ -0,0 +1,30 @@ +export const TODO_ITEMS = [ + { + id: 'opt-application', + label: 'Submit OPT Application', + due: 'D-1', + completed: false, + urgent: true, + }, + { + id: 'arc-documents', + label: 'Prepare ARC Documents', + due: 'D-5', + completed: false, + urgent: false, + }, + { + id: 'health-insurance', + label: 'Get Health Insurance', + due: 'D-10', + completed: false, + urgent: false, + }, + { + id: 'university-registration', + label: 'Register at University', + due: 'Done', + completed: true, + urgent: false, + }, +] as const; diff --git a/apps/landing/src/i18n/constants.ts b/apps/landing/src/i18n/constants.ts new file mode 100644 index 00000000..dfb2e990 --- /dev/null +++ b/apps/landing/src/i18n/constants.ts @@ -0,0 +1,12 @@ +export const LANGUAGE_OPTIONS = [ + { value: 'en', label: 'English' }, + { value: 'ko', label: '한국어' }, +] as const; + +export type SupportedLanguage = (typeof LANGUAGE_OPTIONS)[number]['value']; + +export const SUPPORTED_LANGUAGES = LANGUAGE_OPTIONS.map( + (option) => option.value, +) as SupportedLanguage[]; + +export const DEFAULT_LANGUAGE = 'en' as const satisfies SupportedLanguage; diff --git a/apps/landing/src/i18n/i18n.ts b/apps/landing/src/i18n/i18n.ts new file mode 100644 index 00000000..2574e009 --- /dev/null +++ b/apps/landing/src/i18n/i18n.ts @@ -0,0 +1,36 @@ +import { DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES } from '@i18n/constants'; +import { resources } from '@i18n/resources'; +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +const LANGUAGE_STORAGE_KEY = 'landing-language'; + +const getInitialLanguage = () => { + const savedLanguage = window.localStorage.getItem(LANGUAGE_STORAGE_KEY); + const isSupported = SUPPORTED_LANGUAGES.some( + (language) => language === savedLanguage, + ); + + return isSupported && savedLanguage ? savedLanguage : DEFAULT_LANGUAGE; +}; + +i18n.on('languageChanged', (language: string) => { + if (!SUPPORTED_LANGUAGES.some((item) => item === language)) { + return; + } + + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language); + document.documentElement.lang = language; +}); + +i18n.use(initReactI18next).init({ + resources, + lng: getInitialLanguage(), + fallbackLng: DEFAULT_LANGUAGE, + supportedLngs: [...SUPPORTED_LANGUAGES], + ns: ['landing'], + defaultNS: 'landing', + interpolation: { escapeValue: false }, +}); + +export default i18n; diff --git a/apps/landing/src/i18n/locales/en.json b/apps/landing/src/i18n/locales/en.json new file mode 100644 index 00000000..830c02f7 --- /dev/null +++ b/apps/landing/src/i18n/locales/en.json @@ -0,0 +1,108 @@ +{ + "form": { + "success": "Your early access request is complete.", + "error": "Something went wrong. Please try again shortly." + }, + "header": { + "navigation": { + "features": "Features", + "reviews": "Reviews", + "earlyAccess": "Early Access" + }, + "cta": "Get Early Access" + }, + "hero": { + "badge": "For International Students in Korea", + "title": "Lost in Korea's job market?", + "highlight": "Start with Kareer", + "description": "From visa to job listings to action plans —\nfind your personalized path, all in one place.", + "painPoints": { + "visa": "Applied for a job, only to hear 'visa not eligible'", + "e7": "I know what E-7 is, but how do I even get there?", + "job": "Is there really no other job besides English teacher?", + "deadline": "Missed the deadline relying on WeChat group info" + }, + "solution": "Kareer solves all of this for you →", + "email": { + "placeholder": "Enter your email address", + "cta": "Get Early Access", + "note": "We'll only send you launch updates. No spam." + } + }, + "stats": { + "label": "WHY KAREER EXISTS", + "descriptions": { + "desiredEmployment": "of international students\nwant to work in Korea", + "actualEmployment": "actually\nget hired" + }, + "versus": "VS", + "gap": "A 69.3%p gap — Kareer is here to close it" + }, + "features": { + "job": { + "label": "Feature 01", + "title": "Upload your resume,\nget only the jobs\nyou can actually apply for", + "description": "We analyze your visa info and resume from onboarding to show only jobs you can apply for right now. No more wasting time on listings that reject you due to visa issues." + }, + "roadmap": { + "label": "Feature 02", + "title": "We design your visa &\ncareer roadmap together", + "description": "We analyze your major, visa type, and language data from onboarding to map out a realistic path to E-7. Each step comes with a concrete action list." + }, + "todo": { + "label": "Feature 03", + "title": "Manage everything\nin one place, never miss a step", + "description": "From visa documents to career prep — manage it all in Visa / Career tabs. Actions from your roadmap link directly to your To-Do list." + } + }, + "voices": { + "label": "Real Voices", + "title": "Here's what international students are saying", + "items": { + "trade": { + "quote": "I wanted a job in trade, but had no idea where to start — so I just worked part-time at a kimbap place.", + "meta": "User Interview · China · D-2 · Business" + }, + "resume": { + "quote": "I've never written a resume or cover letter before. I have no idea how to improve my chances of getting hired.", + "meta": "User Interview · Vietnam · D-2 · Marketing" + }, + "wechat": { + "quote": "The only information I could find was in WeChat groups. I had no idea where else to look.", + "meta": "User Interview · China · D-2 · International Trade" + } + } + }, + "whyKareer": { + "title": "Why Kareer?", + "description": "Your Korean career journey, from first step to final offer — with Kareer.", + "benefits": { + "jobs": { + "title": "Only jobs that match your profile", + "description": "We analyze your visa, TOPIK level, and resume to show only the jobs you can apply for. We filter out the rejections before you even see the listing." + }, + "roadmap": { + "title": "Integrated visa & career roadmap", + "description": "We design your E-7 path based on your major, visa, and language data. No need to manage visa prep and career prep separately." + }, + "actions": { + "title": "Directly linked to your action list", + "description": "Every step of your roadmap links to a concrete To-Do. You'll always know what to do and when." + } + }, + "credit": "DIVE SOPT 37TH WEBJAM · TEAM KAREER — \"YOUR CAREER, NOW CLEARING FOR TAKE-OFF\"" + }, + "earlyAccess": { + "badge": "Early Access · First 100 Sign-ups", + "title": "Sign up now and\nbe the first to use it", + "description": "We're opening early access to just 100 people.\nGet your personalized job matches and career roadmap right away.", + "email": { + "placeholder": "Enter your email address", + "cta": "Get Early Access", + "note": "We'll only send you launch updates. No spam." + } + }, + "footer": { + "note": "© 2026 Kareer · Built for international students in Korea" + } +} diff --git a/apps/landing/src/i18n/locales/ko.json b/apps/landing/src/i18n/locales/ko.json new file mode 100644 index 00000000..677ed4a1 --- /dev/null +++ b/apps/landing/src/i18n/locales/ko.json @@ -0,0 +1,108 @@ +{ + "form": { + "success": "얼리 액세스 신청이 완료되었어요.", + "error": "신청에 실패했어요. 잠시 후 다시 시도해주세요." + }, + "header": { + "navigation": { + "features": "기능", + "reviews": "후기", + "earlyAccess": "얼리 액세스" + }, + "cta": "얼리 액세스 신청" + }, + "hero": { + "badge": "한국의 외국인 유학생을 위해", + "title": "한국 취업, 막막하다면?", + "highlight": "Kareer와 시작해보세요", + "description": "비자·공고·액션플랜까지\n내 조건에 맞는 경로를 한 곳에서 확인하세요.", + "painPoints": { + "visa": "채용공고 지원했더니 비자 안 된다고", + "e7": "E-7이 뭔지는 알겠는데 어떻게 준비해?", + "job": "영어강사 말고 다른 직무는 없나요", + "deadline": "위챗 단톡방 정보만 믿다가 마감" + }, + "solution": "Kareer가 이 모든 걸 해결해드려요 →", + "email": { + "placeholder": "이메일 주소를 입력해주세요", + "cta": "신청하기", + "note": "스팸 없이 출시 소식만 보내드려요" + } + }, + "stats": { + "label": "왜 KAREER가 필요한가요", + "descriptions": { + "desiredEmployment": "외국인 유학생의\n한국 취업 희망률", + "actualEmployment": "실제 취업\n전환율" + }, + "versus": "VS", + "gap": "69.3%p의 갭 — Kareer가 이 간격을 줄여드려요" + }, + "features": { + "job": { + "label": "Feature 01", + "title": "이력서 올리면\n지원 가능한 공고만\n골라드려요", + "description": "온보딩에서 입력한 비자 정보와 이력서를 분석해서, 내가 지금 지원할 수 있는 공고만 보여드려요. 비자 때문에 지원도 못 하는 공고에 시간 낭비하지 않아도 돼요." + }, + "roadmap": { + "label": "Feature 02", + "title": "비자·커리어 로드맵을\n한 번에 설계해요", + "description": "온보딩에서 입력한 전공, 비자, 언어 데이터를 분석해서 E-7 취득까지의 현실적인 커리어 경로를 설계해드려요. 단계별로 해야 할 것들이 액션 리스트로 나와요." + }, + "todo": { + "label": "Feature 03", + "title": "할 일을 놓치지 않게\n한 곳에서 관리해요", + "description": "비자 서류부터 커리어 준비까지, Visa / Career 탭으로 나눠서 관리해요. 로드맵에서 액션을 추가하면 To-Do로 바로 연결돼요." + } + }, + "voices": { + "label": "Real Voices", + "title": "실제 외국인 유학생들의 이야기예요", + "items": { + "trade": { + "quote": "무역직을 원하는데 뭘 해야 할지 몰라서 김밥집 알바만 했다", + "meta": "유저 인터뷰 · 중국 · D-2 · 경영학" + }, + "resume": { + "quote": "이력서나 자기소개서를 한 번도 작성해본 적이 없다. 어떻게 해야 합격률을 높일 수 있는지 모르겠다", + "meta": "유저 인터뷰 · 베트남 · D-2 · 마케팅" + }, + "wechat": { + "quote": "위챗에서 주는 정보만 볼 수 있다. 더 알고 싶으면 어디를 찾아가야 할지 모르겠다", + "meta": "유저 인터뷰 · 중국 · D-2 · 국제통상" + } + } + }, + "whyKareer": { + "title": "왜 Kareer여야 하나요?", + "description": "외국인 유학생의 한국 취업, Kareer가 처음부터 끝까지 함께해요", + "benefits": { + "jobs": { + "title": "내 조건에 맞는 공고만", + "description": "비자, TOPIK, 이력서를 분석해서 지금 지원 가능한 공고만 골라드려요. 비자 때문에 안 된다는 말을 공고 보기 전에 미리 걸러드려요." + }, + "roadmap": { + "title": "비자·커리어 로드맵 통합 설계", + "description": "전공, 비자, 언어 데이터를 기반으로 E-7 취득까지의 경로를 설계해요. 비자 준비와 커리어 준비를 따로 할 필요 없어요." + }, + "actions": { + "title": "액션 리스트로 바로 연결", + "description": "로드맵의 각 단계가 구체적인 To-Do로 연결돼요. 무엇을 언제까지 해야 하는지 막막하지 않아요." + } + }, + "credit": "DIVE SOPT 37TH WEBJAM · TEAM KAREER — \"YOUR CAREER, NOW CLEARING FOR TAKE-OFF\"" + }, + "earlyAccess": { + "badge": "얼리 액세스 선착순 100명", + "title": "지금 신청하면\n가장 먼저 써볼 수 있어요", + "description": "100명에게만 먼저 오픈해요.\n내 조건으로 맞춤 공고와 커리어 로드맵을 바로 확인할 수 있어요.", + "email": { + "placeholder": "이메일 주소를 입력해주세요", + "cta": "얼리 액세스 신청하기", + "note": "스팸 없이 출시 소식만 보내드려요" + } + }, + "footer": { + "note": "© 2026 Kareer · Built for international students in Korea" + } +} diff --git a/apps/landing/src/i18n/resources.ts b/apps/landing/src/i18n/resources.ts new file mode 100644 index 00000000..e9a028d6 --- /dev/null +++ b/apps/landing/src/i18n/resources.ts @@ -0,0 +1,11 @@ +import enLanding from '@i18n/locales/en.json'; +import koLanding from '@i18n/locales/ko.json'; + +export const resources = { + en: { + landing: enLanding, + }, + ko: { + landing: koLanding, + }, +} as const; diff --git a/apps/landing/src/main.tsx b/apps/landing/src/main.tsx new file mode 100644 index 00000000..3536b49f --- /dev/null +++ b/apps/landing/src/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from 'react'; +import { ToastProvider } from '@kds/ui'; +import { createRoot } from 'react-dom/client'; + +import '@kds/ui/styles'; +import '@i18n/i18n'; + +import Page from './page'; + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/apps/landing/src/page.css.ts b/apps/landing/src/page.css.ts new file mode 100644 index 00000000..9b85aef7 --- /dev/null +++ b/apps/landing/src/page.css.ts @@ -0,0 +1,8 @@ +import { style } from '@vanilla-extract/css'; + +export const container = style({ + height: '100dvh', + overflowX: 'hidden', + overflowY: 'auto', + scrollPaddingTop: '6.2rem', +}); diff --git a/apps/landing/src/page.tsx b/apps/landing/src/page.tsx new file mode 100644 index 00000000..967a2ddb --- /dev/null +++ b/apps/landing/src/page.tsx @@ -0,0 +1,33 @@ +import LandingFooter from '@components/footer/landing-footer'; +import LandingHeader from '@components/header/landing-header'; +import CareerRoadmapSection from '@sections/career-roadmap/career-roadmap-section'; +import EarlyAccessSection from '@sections/early-access/early-access-section'; +import HeroSection from '@sections/hero/hero-section'; +import JobRecommendationSection from '@sections/job-recommendation/job-recommendation-section'; +import RealVoicesSection from '@sections/real-voices/real-voices-section'; +import StatsSection from '@sections/stats/stats-section'; +import TodoManagementSection from '@sections/todo-management/todo-management-section'; +import WhyKareerSection from '@sections/why-kareer/why-kareer-section'; + +import * as styles from './page.css'; + +const Page = () => { + return ( +
+ +
+ + + + + + + + +
+ +
+ ); +}; + +export default Page; diff --git a/apps/landing/src/sections/career-roadmap/career-roadmap-preview/career-roadmap-preview.css.ts b/apps/landing/src/sections/career-roadmap/career-roadmap-preview/career-roadmap-preview.css.ts new file mode 100644 index 00000000..0b7b41d0 --- /dev/null +++ b/apps/landing/src/sections/career-roadmap/career-roadmap-preview/career-roadmap-preview.css.ts @@ -0,0 +1,215 @@ +import { themeVars } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +export const container = style({ + padding: '2rem', + border: `1px solid ${themeVars.color.grayscale.gray300}`, + borderRadius: '14px', + backgroundColor: themeVars.color.grayscale.white, + boxShadow: '0 8px 40px rgba(59, 110, 248, 0.08)', +}); + +export const header = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: '1.6rem', +}); + +export const heading = style({ + color: themeVars.color.grayscale.gray800, + fontSize: '1.2rem', + fontWeight: 700, +}); + +export const headingAccent = style({ + color: themeVars.color.primary[500], +}); + +export const phaseCount = style({ + color: themeVars.color.grayscale.gray500, + fontSize: '1rem', +}); + +export const phases = style({ + display: 'flex', + position: 'relative', + marginBottom: '1.8rem', + + selectors: { + '&::before': { + position: 'absolute', + top: '1.4rem', + right: '2.8rem', + left: '2.8rem', + height: '2px', + zIndex: 0, + background: `linear-gradient(90deg, ${themeVars.color.grayscale.gray300} 33%, ${themeVars.color.primary[500]} 33%, ${themeVars.color.primary[500]} 66%, ${themeVars.color.grayscale.gray300} 66%)`, + content: '', + }, + }, +}); + +export const phase = style({ + position: 'relative', + flex: 1, + zIndex: 1, + textAlign: 'center', +}); + +export const phaseDot = recipe({ + base: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '2.8rem', + height: '2.8rem', + margin: '0 auto 0.6rem', + borderRadius: '50%', + fontSize: '1.1rem', + fontWeight: 700, + }, + variants: { + state: { + past: { + color: themeVars.color.grayscale.gray500, + backgroundColor: themeVars.color.grayscale.gray300, + }, + current: { + color: themeVars.color.grayscale.white, + backgroundColor: themeVars.color.primary[500], + boxShadow: '0 0 0 4px rgba(59, 110, 248, 0.2)', + }, + future: { + border: `2px solid ${themeVars.color.grayscale.gray300}`, + color: themeVars.color.grayscale.gray500, + backgroundColor: themeVars.color.grayscale.white, + }, + }, + }, +}); + +export const phaseName = recipe({ + base: { + color: themeVars.color.grayscale.gray500, + fontSize: '1rem', + lineHeight: 1.3, + whiteSpace: 'pre-line', + }, + variants: { + current: { + true: { + color: themeVars.color.primary[500], + fontWeight: 600, + }, + }, + }, +}); + +export const phaseStatus = recipe({ + base: { + marginTop: '0.2rem', + fontSize: '0.9rem', + }, + variants: { + tone: { + warning: { + color: themeVars.color.pastel.kared_500, + }, + primary: { + color: themeVars.color.primary[500], + }, + muted: { + color: themeVars.color.grayscale.gray500, + }, + }, + }, +}); + +export const actions = style({ + display: 'flex', + flexDirection: 'column', + gap: '0.6rem', +}); + +export const action = recipe({ + base: { + display: 'flex', + alignItems: 'center', + gap: '0.9rem', + padding: '0.9rem 1.2rem', + border: `1px solid ${themeVars.color.grayscale.gray300}`, + borderRadius: '9px', + color: themeVars.color.grayscale.gray800, + fontSize: '1.2rem', + }, + variants: { + state: { + active: { + borderColor: themeVars.color.primary[300], + backgroundColor: themeVars.color.primary[100], + }, + default: {}, + completed: { + opacity: 0.45, + }, + }, + }, +}); + +export const actionTag = recipe({ + base: { + padding: '0.2rem 0.7rem', + borderRadius: '99px', + fontSize: '0.9rem', + fontWeight: 600, + }, + variants: { + tone: { + visa: { + color: themeVars.color.primary[500], + backgroundColor: themeVars.color.primary[100], + }, + career: { + color: themeVars.color.pastel.kamint_500, + backgroundColor: themeVars.color.pastel.kamint_100, + }, + }, + }, +}); + +export const actionLabel = recipe({ + variants: { + completed: { + true: { + color: themeVars.color.grayscale.gray500, + textDecoration: 'line-through', + }, + }, + }, +}); + +export const actionDate = style({ + marginLeft: '0.4rem', + color: themeVars.color.grayscale.gray500, + fontSize: '1rem', +}); + +export const todoAction = style({ + marginLeft: 'auto', + padding: '0.3rem 0.9rem', + border: `1px solid ${themeVars.color.primary[500]}`, + borderRadius: '99px', + color: themeVars.color.primary[500], + fontSize: '1rem', + fontWeight: 600, + whiteSpace: 'nowrap', +}); + +export const actionResult = style({ + marginLeft: 'auto', + color: themeVars.color.pastel.kamint_500, + fontSize: '1rem', + fontWeight: 600, +}); diff --git a/apps/landing/src/sections/career-roadmap/career-roadmap-preview/career-roadmap-preview.tsx b/apps/landing/src/sections/career-roadmap/career-roadmap-preview/career-roadmap-preview.tsx new file mode 100644 index 00000000..a7ad8bec --- /dev/null +++ b/apps/landing/src/sections/career-roadmap/career-roadmap-preview/career-roadmap-preview.tsx @@ -0,0 +1,53 @@ +import { ROADMAP_ACTIONS, ROADMAP_PHASES } from '@constants/index'; + +import * as styles from './career-roadmap-preview.css'; + +const CareerRoadmapPreview = () => { + return ( +
+
+

+ Your path to{' '} + E-7 Employment Visa +

+

3 Phases

+
+ +
+ {ROADMAP_PHASES.map(({ step, name, state, status, statusTone }) => ( +
+
{step}
+

+ {name} +

+

{status}

+
+ ))} +
+ +
+ {ROADMAP_ACTIONS.map( + ({ id, tag, tagTone, label, state, date, result }) => ( +
+ {tag} + + {label} + + {date && {date}} + {state !== 'completed' && ( + + To-Do + )} + {result && {result}} +
+ ), + )} +
+
+ ); +}; + +export default CareerRoadmapPreview; diff --git a/apps/landing/src/sections/career-roadmap/career-roadmap-section.tsx b/apps/landing/src/sections/career-roadmap/career-roadmap-section.tsx new file mode 100644 index 00000000..7dbfd855 --- /dev/null +++ b/apps/landing/src/sections/career-roadmap/career-roadmap-section.tsx @@ -0,0 +1,20 @@ +import FeatureSection from '@components/feature-section/feature-section'; +import { useTranslation } from 'react-i18next'; + +import CareerRoadmapPreview from './career-roadmap-preview/career-roadmap-preview'; + +const CareerRoadmapSection = () => { + const { t } = useTranslation('landing'); + + return ( + } + previewPosition="left" + /> + ); +}; + +export default CareerRoadmapSection; diff --git a/apps/landing/src/sections/early-access/early-access-section.css.ts b/apps/landing/src/sections/early-access/early-access-section.css.ts new file mode 100644 index 00000000..268a0663 --- /dev/null +++ b/apps/landing/src/sections/early-access/early-access-section.css.ts @@ -0,0 +1,31 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const container = style({ + padding: '10rem 4.8rem', + background: `linear-gradient(180deg, #eff6ff 0%, ${themeVars.color.grayscale.white} 100%)`, + lineHeight: 1.6, + textAlign: 'center', +}); + +export const badgeSpacing = style({ + marginBottom: '2rem', + lineHeight: 1.6, +}); + +export const title = style({ + marginBottom: '1.2rem', + color: themeVars.color.grayscale.gray800, + fontSize: '4rem', + fontWeight: 800, + letterSpacing: '-0.02em', + whiteSpace: 'pre-line', +}); + +export const description = style({ + ...typography.body6_r_16, + marginBottom: '3.6rem', + color: themeVars.color.grayscale.gray700, + lineHeight: 1.7, + whiteSpace: 'pre-line', +}); diff --git a/apps/landing/src/sections/early-access/early-access-section.tsx b/apps/landing/src/sections/early-access/early-access-section.tsx new file mode 100644 index 00000000..7172edc7 --- /dev/null +++ b/apps/landing/src/sections/early-access/early-access-section.tsx @@ -0,0 +1,33 @@ +import Badge from '@components/badge/badge'; +import EarlyAccessForm from '@components/early-access-form/early-access-form'; +import { LANDING_SECTION_ID } from '@constants/section-id'; +import { SparkleIcon } from '@kds/icons'; +import { useTranslation } from 'react-i18next'; + +import * as styles from './early-access-section.css'; + +const EarlyAccessSection = () => { + const { t } = useTranslation('landing'); + + return ( +
+ } + className={styles.badgeSpacing} + > + {t('earlyAccess.badge')} + +

{t('earlyAccess.title')}

+

{t('earlyAccess.description')}

+ + +
+ ); +}; + +export default EarlyAccessSection; diff --git a/apps/landing/src/sections/hero/hero-section.css.ts b/apps/landing/src/sections/hero/hero-section.css.ts new file mode 100644 index 00000000..f17071a3 --- /dev/null +++ b/apps/landing/src/sections/hero/hero-section.css.ts @@ -0,0 +1,71 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const container = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + minHeight: '100vh', + padding: '11rem 4.8rem 0', + textAlign: 'center', +}); + +export const badgeSpacing = style({ + marginBottom: '2.8rem', +}); + +export const title = style({ + marginBottom: '2rem', + color: themeVars.color.grayscale.gray800, + fontSize: '5.8rem', + fontWeight: 800, + lineHeight: 1.15, + letterSpacing: '-0.03em', +}); + +export const highlight = style({ + position: 'relative', + display: 'inline-block', + color: themeVars.color.primary[500], + '::after': { + content: '', + position: 'absolute', + bottom: '-0.4rem', + left: 0, + width: '100%', + height: '0.4rem', + borderRadius: '2px', + backgroundColor: themeVars.color.primary[500], + opacity: 0.3, + }, +}); + +export const description = style({ + ...typography.body3_r_18, + maxWidth: '52rem', + margin: '0 auto 4.4rem', + color: themeVars.color.grayscale.gray700, + lineHeight: 1.75, + whiteSpace: 'pre-line', +}); + +export const painPoints = style({ + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'center', + maxWidth: '70rem', + marginBottom: '4.8rem', + gap: '1rem', +}); + +export const painPointIcon = style({ + flexShrink: 0, +}); + +export const solution = style({ + ...typography.body7_sb_14, + marginTop: '-1.2rem', + marginBottom: '2.8rem', + color: themeVars.color.primary[500], +}); diff --git a/apps/landing/src/sections/hero/hero-section.tsx b/apps/landing/src/sections/hero/hero-section.tsx new file mode 100644 index 00000000..7e035abc --- /dev/null +++ b/apps/landing/src/sections/hero/hero-section.tsx @@ -0,0 +1,66 @@ +import Badge from '@components/badge/badge'; +import EarlyAccessForm from '@components/early-access-form/early-access-form'; +import { + GlobalIcon, + RoadmapIcon, + SearchIcon, + TimerIcon, + XIcon, +} from '@kds/icons'; +import { useTranslation } from 'react-i18next'; + +import PainPointItem from './pain-point-item/pain-point-item'; + +import * as styles from './hero-section.css'; + +const PAIN_POINTS = [ + { icon: XIcon, translationKey: 'hero.painPoints.visa' }, + { icon: RoadmapIcon, translationKey: 'hero.painPoints.e7' }, + { icon: SearchIcon, translationKey: 'hero.painPoints.job' }, + { icon: TimerIcon, translationKey: 'hero.painPoints.deadline' }, +] as const; + +const HeroSection = () => { + const { t } = useTranslation('landing'); + + return ( +
+ } + className={styles.badgeSpacing} + > + {t('hero.badge')} + + +

+ {t('hero.title')} +
+ {t('hero.highlight')} +

+ +

{t('hero.description')}

+ +
+ {PAIN_POINTS.map(({ icon: Icon, translationKey }) => ( + + } + label={t(translationKey)} + /> + ))} +
+ +

{t('hero.solution')}

+ + +
+ ); +}; + +export default HeroSection; diff --git a/apps/landing/src/sections/hero/pain-point-item/pain-point-item.css.ts b/apps/landing/src/sections/hero/pain-point-item/pain-point-item.css.ts new file mode 100644 index 00000000..f9b0577f --- /dev/null +++ b/apps/landing/src/sections/hero/pain-point-item/pain-point-item.css.ts @@ -0,0 +1,21 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const painPoint = style({ + ...typography.body9_r_14, + display: 'flex', + alignItems: 'center', + gap: '0.8rem', + padding: '1.1rem 1.6rem', + border: `1px solid ${themeVars.color.grayscale.gray300}`, + borderRadius: '10px', + color: themeVars.color.grayscale.gray800, + backgroundColor: themeVars.color.grayscale.gray100, + lineHeight: 1.6, + transition: 'all 0.2s ease', + ':hover': { + borderColor: themeVars.color.primary[300], + color: themeVars.color.primary[600], + backgroundColor: themeVars.color.primary[100], + }, +}); diff --git a/apps/landing/src/sections/hero/pain-point-item/pain-point-item.tsx b/apps/landing/src/sections/hero/pain-point-item/pain-point-item.tsx new file mode 100644 index 00000000..a76b73a7 --- /dev/null +++ b/apps/landing/src/sections/hero/pain-point-item/pain-point-item.tsx @@ -0,0 +1,17 @@ +import { ReactNode } from 'react'; + +import * as styles from './pain-point-item.css'; + +interface PainPointItemProps { + icon: ReactNode; + label: string; +} + +const PainPointItem = ({ icon, label }: PainPointItemProps) => ( +
+ {icon} + {label} +
+); + +export default PainPointItem; diff --git a/apps/landing/src/sections/job-recommendation/job-preview-card/job-preview-card.css.ts b/apps/landing/src/sections/job-recommendation/job-preview-card/job-preview-card.css.ts new file mode 100644 index 00000000..a7827015 --- /dev/null +++ b/apps/landing/src/sections/job-recommendation/job-preview-card/job-preview-card.css.ts @@ -0,0 +1,104 @@ +import { themeVars } from '@kds/ui/styles'; +import { style, styleVariants } from '@vanilla-extract/css'; + +export const jobCard = style({ + overflow: 'hidden', + border: `1px solid ${themeVars.color.grayscale.gray300}`, + borderRadius: '10px', + backgroundColor: themeVars.color.grayscale.white, +}); + +export const jobImage = style({ + display: 'flex', + position: 'relative', + alignItems: 'flex-start', + justifyContent: 'space-between', + height: '7.2rem', + padding: '0.8rem', + background: `linear-gradient(135deg, ${themeVars.color.grayscale.gray300}, ${themeVars.color.grayscale.gray400})`, +}); + +export const deadline = style({ + padding: '0.2rem 0.8rem', + borderRadius: '99px', + color: themeVars.color.grayscale.gray800, + backgroundColor: themeVars.color.grayscale.white, + fontSize: '1rem', + fontWeight: 600, +}); + +export const jobBody = style({ + padding: '0.8rem 1rem', +}); + +export const company = style({ + marginBottom: '0.2rem', + color: themeVars.color.grayscale.gray500, + fontSize: '0.9rem', + fontWeight: 600, + letterSpacing: '0.05em', + textTransform: 'uppercase', +}); + +export const jobTitle = style({ + marginBottom: '0.4rem', + color: themeVars.color.grayscale.gray800, + fontSize: '1.1rem', + fontWeight: 700, + lineHeight: 1.3, +}); + +export const date = style({ + marginBottom: '0.6rem', + color: themeVars.color.grayscale.gray500, + fontSize: '1rem', +}); + +export const jobTags = style({ + display: 'flex', + flexWrap: 'wrap', + gap: '0.4rem', +}); + +const jobTagBase = style({ + padding: '0.2rem 0.7rem', + borderRadius: '99px', + fontSize: '0.9rem', + fontWeight: 500, + whiteSpace: 'nowrap', +}); + +export const jobTag = styleVariants({ + blue: [ + jobTagBase, + { + border: `1px solid ${themeVars.color.primary[300]}`, + color: themeVars.color.primary[600], + backgroundColor: themeVars.color.primary[100], + }, + ], + green: [ + jobTagBase, + { + border: `1px solid ${themeVars.color.pastel.kamint_500}`, + color: themeVars.color.pastel.kamint_500, + backgroundColor: themeVars.color.pastel.kamint_100, + }, + ], + orange: [ + jobTagBase, + { + border: `1px solid ${themeVars.color.pastel.kaorange_500}`, + color: themeVars.color.pastel.kaorange_500, + backgroundColor: themeVars.color.pastel.kaorange_100, + }, + ], + gray: [ + jobTagBase, + { + border: `1px solid ${themeVars.color.grayscale.gray300}`, + color: themeVars.color.grayscale.gray700, + backgroundColor: themeVars.color.grayscale.gray100, + }, + ], +}); diff --git a/apps/landing/src/sections/job-recommendation/job-preview-card/job-preview-card.tsx b/apps/landing/src/sections/job-recommendation/job-preview-card/job-preview-card.tsx new file mode 100644 index 00000000..bdf11179 --- /dev/null +++ b/apps/landing/src/sections/job-recommendation/job-preview-card/job-preview-card.tsx @@ -0,0 +1,43 @@ +import { BookmarkIcon } from '@kds/icons'; + +import * as styles from './job-preview-card.css'; + +interface JobPreviewCardProps { + company: string; + title: string; + date: string; + deadline: string; + employmentType: string; + tagTone: keyof typeof styles.jobTag; + location?: string; +} + +const JobPreviewCard = ({ + company, + title, + date, + deadline, + employmentType, + tagTone, + location, +}: JobPreviewCardProps) => { + return ( +
+
+ {deadline} + +
+
+

{company}

+

{title}

+

{date}

+
+ {employmentType} + {location && {location}} +
+
+
+ ); +}; + +export default JobPreviewCard; diff --git a/apps/landing/src/sections/job-recommendation/job-recommendation-preview/job-recommendation-preview.css.ts b/apps/landing/src/sections/job-recommendation/job-recommendation-preview/job-recommendation-preview.css.ts new file mode 100644 index 00000000..d40c8b72 --- /dev/null +++ b/apps/landing/src/sections/job-recommendation/job-recommendation-preview/job-recommendation-preview.css.ts @@ -0,0 +1,75 @@ +import { themeVars } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const preview = style({ + padding: '2.2rem', + border: `1px solid ${themeVars.color.grayscale.gray300}`, + borderRadius: '14px', + backgroundColor: themeVars.color.grayscale.white, + boxShadow: '0 8px 40px rgba(59, 110, 248, 0.08)', +}); + +export const uploadBar = style({ + display: 'flex', + alignItems: 'center', + gap: '0.8rem', + marginBottom: '1.4rem', + padding: '0.8rem 1rem', + border: `1.5px solid ${themeVars.color.grayscale.gray300}`, + borderRadius: '10px', + backgroundColor: themeVars.color.grayscale.white, +}); + +export const upload = style({ + padding: '0.5rem 1rem', + border: `1px solid ${themeVars.color.primary[300]}`, + borderRadius: '7px', + color: themeVars.color.grayscale.gray700, + backgroundColor: themeVars.color.primary[100], + fontSize: '1.1rem', + fontWeight: 600, + whiteSpace: 'nowrap', +}); + +export const file = style({ + padding: '0.4rem 0.9rem', + borderRadius: '6px', + color: themeVars.color.grayscale.gray700, + backgroundColor: themeVars.color.grayscale.gray100, + fontSize: '1.1rem', + whiteSpace: 'nowrap', +}); + +export const todoOption = style({ + display: 'flex', + alignItems: 'center', + gap: '0.6rem', + marginLeft: 'auto', + color: themeVars.color.grayscale.gray500, + fontSize: '1.1rem', + whiteSpace: 'nowrap', +}); + +export const toggle = style({ + position: 'relative', + width: '2.8rem', + height: '1.6rem', + borderRadius: '99px', + backgroundColor: themeVars.color.primary[500], +}); + +export const toggleThumb = style({ + position: 'absolute', + top: '0.2rem', + right: '0.2rem', + width: '1.2rem', + height: '1.2rem', + borderRadius: '50%', + backgroundColor: themeVars.color.grayscale.white, +}); + +export const jobGrid = style({ + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: '0.8rem', +}); diff --git a/apps/landing/src/sections/job-recommendation/job-recommendation-preview/job-recommendation-preview.tsx b/apps/landing/src/sections/job-recommendation/job-recommendation-preview/job-recommendation-preview.tsx new file mode 100644 index 00000000..3ae5b41e --- /dev/null +++ b/apps/landing/src/sections/job-recommendation/job-recommendation-preview/job-recommendation-preview.tsx @@ -0,0 +1,30 @@ +import { JOB_PREVIEWS } from '@constants/index'; + +import JobPreviewCard from '../job-preview-card/job-preview-card'; + +import * as styles from './job-recommendation-preview.css'; + +const JobRecommendationPreview = () => { + return ( +
+
+ + Upload + resume.pdf ✕ +
+ Include completed to-dos + + + +
+
+ +
+ {JOB_PREVIEWS.map(({ id, ...job }) => ( + + ))} +
+
+ ); +}; + +export default JobRecommendationPreview; diff --git a/apps/landing/src/sections/job-recommendation/job-recommendation-section.tsx b/apps/landing/src/sections/job-recommendation/job-recommendation-section.tsx new file mode 100644 index 00000000..7eb180a5 --- /dev/null +++ b/apps/landing/src/sections/job-recommendation/job-recommendation-section.tsx @@ -0,0 +1,22 @@ +import FeatureSection from '@components/feature-section/feature-section'; +import { LANDING_SECTION_ID } from '@constants/section-id'; +import { useTranslation } from 'react-i18next'; + +import JobRecommendationPreview from './job-recommendation-preview/job-recommendation-preview'; + +const JobRecommendationSection = () => { + const { t } = useTranslation('landing'); + + return ( + } + background="muted" + /> + ); +}; + +export default JobRecommendationSection; diff --git a/apps/landing/src/sections/real-voices/real-voices-section.css.ts b/apps/landing/src/sections/real-voices/real-voices-section.css.ts new file mode 100644 index 00000000..16684d36 --- /dev/null +++ b/apps/landing/src/sections/real-voices/real-voices-section.css.ts @@ -0,0 +1,35 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const container = style({ + padding: '7.2rem 4.8rem', + backgroundColor: themeVars.color.grayscale.white, +}); + +export const inner = style({ + maxWidth: '90rem', + margin: '0 auto', + textAlign: 'center', +}); + +export const label = style({ + ...typography.cap1_sb_12, + marginBottom: '1.6rem', + color: themeVars.color.primary[500], + letterSpacing: '0.1em', + textTransform: 'uppercase', +}); + +export const title = style({ + marginBottom: '4rem', + color: themeVars.color.grayscale.gray800, + fontSize: '2.8rem', + fontWeight: 800, + letterSpacing: '-0.02em', +}); + +export const voices = style({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: '1.6rem', +}); diff --git a/apps/landing/src/sections/real-voices/real-voices-section.tsx b/apps/landing/src/sections/real-voices/real-voices-section.tsx new file mode 100644 index 00000000..11170223 --- /dev/null +++ b/apps/landing/src/sections/real-voices/real-voices-section.tsx @@ -0,0 +1,33 @@ +import { LANDING_SECTION_ID } from '@constants/section-id'; +import { useTranslation } from 'react-i18next'; + +import VoiceCard from './voice-card/voice-card'; + +import * as styles from './real-voices-section.css'; + +const VOICE_IDS = ['trade', 'resume', 'wechat'] as const; + +const RealVoicesSection = () => { + const { t } = useTranslation('landing'); + + return ( +
+
+

{t('voices.label')}

+

{t('voices.title')}

+ +
+ {VOICE_IDS.map((id) => ( + + ))} +
+
+
+ ); +}; + +export default RealVoicesSection; diff --git a/apps/landing/src/sections/real-voices/voice-card/voice-card.css.ts b/apps/landing/src/sections/real-voices/voice-card/voice-card.css.ts new file mode 100644 index 00000000..d58b01f4 --- /dev/null +++ b/apps/landing/src/sections/real-voices/voice-card/voice-card.css.ts @@ -0,0 +1,23 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const card = style({ + padding: '2.4rem 2rem', + border: `1px solid ${themeVars.color.grayscale.gray300}`, + borderRadius: '14px', + backgroundColor: themeVars.color.grayscale.gray100, + textAlign: 'left', +}); + +export const quote = style({ + ...typography.body9_r_14, + marginBottom: '1.6rem', + color: themeVars.color.grayscale.gray800, + fontStyle: 'italic', + lineHeight: 1.75, +}); + +export const meta = style({ + ...typography.cap2_m_12, + color: themeVars.color.grayscale.gray500, +}); diff --git a/apps/landing/src/sections/real-voices/voice-card/voice-card.tsx b/apps/landing/src/sections/real-voices/voice-card/voice-card.tsx new file mode 100644 index 00000000..b8efda7f --- /dev/null +++ b/apps/landing/src/sections/real-voices/voice-card/voice-card.tsx @@ -0,0 +1,15 @@ +import * as styles from './voice-card.css'; + +interface VoiceCardProps { + content: string; + interviewInfo: string; +} + +const VoiceCard = ({ content, interviewInfo }: VoiceCardProps) => ( +
+

{content}

+

{interviewInfo}

+
+); + +export default VoiceCard; diff --git a/apps/landing/src/sections/stats/hooks/use-count-up-on-visible.ts b/apps/landing/src/sections/stats/hooks/use-count-up-on-visible.ts new file mode 100644 index 00000000..8fce88f3 --- /dev/null +++ b/apps/landing/src/sections/stats/hooks/use-count-up-on-visible.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from 'react'; + +const ANIMATION_DURATION = 1800; +const VISIBLE_THRESHOLD = 0.15; + +const useCountUpOnVisible = () => { + const containerRef = useRef(null); + const [progress, setProgress] = useState(0); + + useEffect(() => { + if (!containerRef.current) { + return; + } + + let animationFrameId = 0; + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) { + return; + } + + observer.disconnect(); + + const startTime = performance.now(); + const updateProgress = (currentTime: number) => { + const elapsedRatio = Math.min( + (currentTime - startTime) / ANIMATION_DURATION, + 1, + ); + setProgress(1 - Math.pow(1 - elapsedRatio, 3)); + + if (elapsedRatio < 1) { + animationFrameId = requestAnimationFrame(updateProgress); + } + }; + + animationFrameId = requestAnimationFrame(updateProgress); + }, + { threshold: VISIBLE_THRESHOLD }, + ); + + observer.observe(containerRef.current); + + return () => { + observer.disconnect(); + cancelAnimationFrame(animationFrameId); + }; + }, []); + + return { containerRef, progress }; +}; + +export default useCountUpOnVisible; diff --git a/apps/landing/src/sections/stats/stat-item/stat-item.css.ts b/apps/landing/src/sections/stats/stat-item/stat-item.css.ts new file mode 100644 index 00000000..002c0d6d --- /dev/null +++ b/apps/landing/src/sections/stats/stat-item/stat-item.css.ts @@ -0,0 +1,27 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const stat = style({ + flex: 1, + padding: '2.4rem 3.2rem', +}); + +export const value = style({ + marginBottom: '0.6rem', + color: themeVars.color.grayscale.white, + fontSize: '5.6rem', + fontWeight: 800, + lineHeight: 1, + letterSpacing: '-0.03em', +}); + +export const accent = style({ + color: themeVars.color.primary[500], +}); + +export const description = style({ + ...typography.body9_r_14, + color: themeVars.color.grayscale.gray400, + lineHeight: 1.5, + whiteSpace: 'pre-line', +}); diff --git a/apps/landing/src/sections/stats/stat-item/stat-item.tsx b/apps/landing/src/sections/stats/stat-item/stat-item.tsx new file mode 100644 index 00000000..4633904d --- /dev/null +++ b/apps/landing/src/sections/stats/stat-item/stat-item.tsx @@ -0,0 +1,17 @@ +import * as styles from './stat-item.css'; + +interface StatItemProps { + value: number; + description: string; +} + +const StatItem = ({ value, description }: StatItemProps) => ( +
+

+ {value}% +

+

{description}

+
+); + +export default StatItem; diff --git a/apps/landing/src/sections/stats/stats-section.css.ts b/apps/landing/src/sections/stats/stats-section.css.ts new file mode 100644 index 00000000..facdbdf3 --- /dev/null +++ b/apps/landing/src/sections/stats/stats-section.css.ts @@ -0,0 +1,34 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const container = style({ + padding: '7.2rem 4.8rem', + textAlign: 'center', + backgroundColor: '#1A2B4A', +}); + +export const label = style({ + ...typography.cap1_sb_12, + marginBottom: '4rem', + color: themeVars.color.grayscale.gray500, + letterSpacing: '0.1em', + textTransform: 'uppercase', +}); + +export const stats = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + maxWidth: '70rem', + margin: '0 auto', +}); + +export const versus = style({ + ...typography.body1_sb_18, + padding: '0 1.6rem', + color: themeVars.color.grayscale.gray600, +}); + +export const gapSpacing = style({ + marginTop: '2.8rem', +}); diff --git a/apps/landing/src/sections/stats/stats-section.tsx b/apps/landing/src/sections/stats/stats-section.tsx new file mode 100644 index 00000000..087e0513 --- /dev/null +++ b/apps/landing/src/sections/stats/stats-section.tsx @@ -0,0 +1,51 @@ +import Badge from '@components/badge/badge'; +import { FitAnalysisIcon } from '@kds/icons'; +import { useTranslation } from 'react-i18next'; + +import useCountUpOnVisible from './hooks/use-count-up-on-visible'; +import StatItem from './stat-item/stat-item'; + +import * as styles from './stats-section.css'; + +const DESIRED_EMPLOYMENT_RATE = 77; +const ACTUAL_EMPLOYMENT_RATE = 7.7; + +const StatsSection = () => { + const { t } = useTranslation('landing'); + const { containerRef, progress } = useCountUpOnVisible(); + + const desiredEmploymentRate = Math.floor(DESIRED_EMPLOYMENT_RATE * progress); + const actualEmploymentRate = Number( + (ACTUAL_EMPLOYMENT_RATE * progress).toFixed(1), + ); + + return ( +
+

{t('stats.label')}

+ +
+ + +

{t('stats.versus')}

+ + +
+ + } + className={styles.gapSpacing} + > + {t('stats.gap')} + +
+ ); +}; + +export default StatsSection; diff --git a/apps/landing/src/sections/todo-management/todo-management-preview/todo-management-preview.css.ts b/apps/landing/src/sections/todo-management/todo-management-preview/todo-management-preview.css.ts new file mode 100644 index 00000000..878d3009 --- /dev/null +++ b/apps/landing/src/sections/todo-management/todo-management-preview/todo-management-preview.css.ts @@ -0,0 +1,114 @@ +import { themeVars } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +export const container = style({ + padding: '2rem', + border: `1px solid ${themeVars.color.grayscale.gray300}`, + borderRadius: '14px', + backgroundColor: themeVars.color.grayscale.white, + boxShadow: '0 8px 40px rgba(59, 110, 248, 0.08)', +}); + +export const tabs = style({ + display: 'flex', + gap: '0.6rem', + marginBottom: '1.4rem', +}); + +export const tab = recipe({ + base: { + padding: '0.5rem 1.4rem', + borderRadius: '99px', + fontSize: '1.1rem', + fontWeight: 600, + }, + variants: { + active: { + true: { + color: themeVars.color.grayscale.white, + backgroundColor: themeVars.color.primary[500], + }, + false: { + color: themeVars.color.grayscale.gray700, + backgroundColor: themeVars.color.grayscale.gray100, + }, + }, + }, +}); + +export const todoItem = style({ + display: 'flex', + alignItems: 'center', + gap: '0.9rem', + marginBottom: '0.3rem', + padding: '0.8rem 1rem', + borderRadius: '8px', +}); + +export const checkbox = style({ + flexShrink: 0, +}); + +export const todoName = recipe({ + base: { + color: themeVars.color.grayscale.gray800, + fontSize: '1.2rem', + fontWeight: 500, + }, + variants: { + completed: { + true: { + color: themeVars.color.grayscale.gray500, + textDecoration: 'line-through', + }, + }, + }, +}); + +export const dueDate = recipe({ + base: { + marginLeft: 'auto', + color: themeVars.color.grayscale.gray500, + fontSize: '1rem', + whiteSpace: 'nowrap', + }, + variants: { + urgent: { + true: { + color: themeVars.color.pastel.kared_500, + fontWeight: 600, + }, + }, + }, +}); + +export const completedBar = style({ + display: 'flex', + alignItems: 'center', + gap: '0.6rem', + marginTop: '0.6rem', + padding: '0.8rem 1rem', + borderTop: `1px solid ${themeVars.color.grayscale.gray300}`, +}); + +export const completedCheck = style({ + color: themeVars.color.primary[500], + fontSize: '1.3rem', +}); + +export const completedLabel = style({ + color: themeVars.color.grayscale.gray500, + fontSize: '1.1rem', + fontWeight: 600, +}); + +export const completedCount = style({ + marginLeft: 'auto', + padding: '0.1rem 0.7rem', + borderRadius: '99px', + color: themeVars.color.primary[500], + backgroundColor: themeVars.color.primary[100], + fontSize: '1rem', + fontWeight: 600, +}); diff --git a/apps/landing/src/sections/todo-management/todo-management-preview/todo-management-preview.tsx b/apps/landing/src/sections/todo-management/todo-management-preview/todo-management-preview.tsx new file mode 100644 index 00000000..85c64e0b --- /dev/null +++ b/apps/landing/src/sections/todo-management/todo-management-preview/todo-management-preview.tsx @@ -0,0 +1,41 @@ +import { TODO_ITEMS } from '@constants/index'; +import { TodoCheckIcon, TodoIcon } from '@kds/icons'; + +import * as styles from './todo-management-preview.css'; + +const TodoManagementPreview = () => { + return ( +
+
+ Visa + Career +
+ +
+ {TODO_ITEMS.map(({ id, label, due, completed, urgent }) => ( +
+ {completed ? ( + + ) : ( + + )} + {label} + {due} +
+ ))} +
+ +
+ + Completed + 2 +
+
+ ); +}; + +export default TodoManagementPreview; diff --git a/apps/landing/src/sections/todo-management/todo-management-section.tsx b/apps/landing/src/sections/todo-management/todo-management-section.tsx new file mode 100644 index 00000000..e7893c39 --- /dev/null +++ b/apps/landing/src/sections/todo-management/todo-management-section.tsx @@ -0,0 +1,20 @@ +import FeatureSection from '@components/feature-section/feature-section'; +import { useTranslation } from 'react-i18next'; + +import TodoManagementPreview from './todo-management-preview/todo-management-preview'; + +const TodoManagementSection = () => { + const { t } = useTranslation('landing'); + + return ( + } + background="muted" + /> + ); +}; + +export default TodoManagementSection; diff --git a/apps/landing/src/sections/why-kareer/benefit-card/benefit-card.css.ts b/apps/landing/src/sections/why-kareer/benefit-card/benefit-card.css.ts new file mode 100644 index 00000000..d199fcf3 --- /dev/null +++ b/apps/landing/src/sections/why-kareer/benefit-card/benefit-card.css.ts @@ -0,0 +1,37 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const card = style({ + padding: '2.6rem 2.2rem', + border: '1px solid rgba(255, 255, 255, 0.18)', + borderRadius: '14px', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + textAlign: 'left', + backdropFilter: 'blur(12px)', + transition: 'all 0.25s ease', + ':hover': { + transform: 'translateY(-4px)', + borderColor: 'rgba(255, 255, 255, 0.32)', + backgroundColor: 'rgba(255, 255, 255, 0.16)', + }, +}); + +export const icon = style({ + display: 'flex', + alignItems: 'center', + height: '3.2rem', + color: themeVars.color.grayscale.white, + marginBottom: '1.4rem', +}); + +export const cardTitle = style({ + ...typography.body4_sb_16, + marginBottom: '0.8rem', + color: themeVars.color.grayscale.white, +}); + +export const cardDescription = style({ + ...typography.cap3_r_12, + color: 'rgba(255, 255, 255, 0.65)', + lineHeight: 1.7, +}); diff --git a/apps/landing/src/sections/why-kareer/benefit-card/benefit-card.tsx b/apps/landing/src/sections/why-kareer/benefit-card/benefit-card.tsx new file mode 100644 index 00000000..8ca2e789 --- /dev/null +++ b/apps/landing/src/sections/why-kareer/benefit-card/benefit-card.tsx @@ -0,0 +1,19 @@ +import { ReactNode } from 'react'; + +import * as styles from './benefit-card.css'; + +interface BenefitCardProps { + icon: ReactNode; + title: string; + description: string; +} + +const BenefitCard = ({ icon, title, description }: BenefitCardProps) => ( +
+
{icon}
+

{title}

+

{description}

+
+); + +export default BenefitCard; diff --git a/apps/landing/src/sections/why-kareer/why-kareer-section.css.ts b/apps/landing/src/sections/why-kareer/why-kareer-section.css.ts new file mode 100644 index 00000000..68fde4f0 --- /dev/null +++ b/apps/landing/src/sections/why-kareer/why-kareer-section.css.ts @@ -0,0 +1,62 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const container = style({ + position: 'relative', + overflow: 'hidden', + lineHeight: 1.6, +}); + +export const backgroundImage = style({ + position: 'absolute', + zIndex: 0, + inset: 0, + width: '100%', + height: '100%', + objectFit: 'cover', + objectPosition: 'center 30%', +}); + +export const overlay = style({ + position: 'absolute', + zIndex: 1, + inset: 0, + backgroundColor: 'rgba(15, 23, 42, 0.72)', +}); + +export const inner = style({ + position: 'relative', + zIndex: 2, + maxWidth: '96rem', + margin: '0 auto', + padding: '8.8rem 0', + textAlign: 'center', +}); + +export const title = style({ + marginBottom: '1.2rem', + color: themeVars.color.grayscale.white, + fontSize: '3.6rem', + fontWeight: 800, + letterSpacing: '-0.02em', +}); + +export const description = style({ + ...typography.body6_r_16, + marginBottom: '5.2rem', + color: 'rgba(255, 255, 255, 0.6)', + lineHeight: 1.6, +}); + +export const benefits = style({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: '1.4rem', +}); + +export const credit = style({ + ...typography.cap3_r_12, + marginTop: '3.6rem', + color: 'rgba(255, 255, 255, 0.35)', + textAlign: 'left', +}); diff --git a/apps/landing/src/sections/why-kareer/why-kareer-section.tsx b/apps/landing/src/sections/why-kareer/why-kareer-section.tsx new file mode 100644 index 00000000..423e9aa5 --- /dev/null +++ b/apps/landing/src/sections/why-kareer/why-kareer-section.tsx @@ -0,0 +1,53 @@ +import { bg_why_kareer } from '@assets/index'; +import { CheckIcon, FitAnalysisIcon, RoadmapIcon } from '@kds/icons'; +import { useTranslation } from 'react-i18next'; + +import BenefitCard from './benefit-card/benefit-card'; + +import * as styles from './why-kareer-section.css'; + +const BENEFITS = [ + { + id: 'jobs', + icon: , + }, + { + id: 'roadmap', + icon: , + }, + { + id: 'actions', + icon: , + }, +] as const; + +const WhyKareerSection = () => { + const { t } = useTranslation('landing'); + + return ( +
+ +
+ +
+

{t('whyKareer.title')}

+

{t('whyKareer.description')}

+ +
+ {BENEFITS.map(({ id, icon }) => ( + + ))} +
+ +

{t('whyKareer.credit')}

+
+
+ ); +}; + +export default WhyKareerSection; diff --git a/apps/landing/tsconfig.json b/apps/landing/tsconfig.json new file mode 100644 index 00000000..a42ce9b5 --- /dev/null +++ b/apps/landing/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@kareer/typescript-config/react.json", + "compilerOptions": { + "types": ["vite/client", "vite-plugin-svgr/client"], + "paths": { + "@assets/*": ["./src/assets/*"], + "@components/*": ["./src/components/*"], + "@constants/*": ["./src/constants/*"], + "@i18n/*": ["./src/i18n/*"], + "@sections/*": ["./src/sections/*"] + } + }, + "include": ["src", "vite.config.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/landing/vite.config.ts b/apps/landing/vite.config.ts new file mode 100644 index 00000000..f67ab9dd --- /dev/null +++ b/apps/landing/vite.config.ts @@ -0,0 +1,26 @@ +import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; +import react from '@vitejs/plugin-react'; +import path from 'path'; +import { defineConfig } from 'vite'; +import svgr from 'vite-plugin-svgr'; + +export default defineConfig({ + plugins: [ + react({ + babel: { + plugins: [['babel-plugin-react-compiler']], + }, + }), + svgr(), + vanillaExtractPlugin(), + ], + resolve: { + alias: { + '@assets': path.resolve(__dirname, 'src/assets'), + '@components': path.resolve(__dirname, 'src/components'), + '@constants': path.resolve(__dirname, 'src/constants'), + '@i18n': path.resolve(__dirname, 'src/i18n'), + '@sections': path.resolve(__dirname, 'src/sections'), + }, + }, +}); diff --git a/apps/web/index.html b/apps/web/index.html index f7f56cec..3c840e05 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -9,8 +9,8 @@ property="og:description" content="The fastest roadmap to your vision" /> - - + + - + diff --git a/apps/web/src/entities/job/model/end_point.ts b/apps/web/src/entities/job/model/end-point.ts similarity index 100% rename from apps/web/src/entities/job/model/end_point.ts rename to apps/web/src/entities/job/model/end-point.ts diff --git a/apps/web/src/entities/job/model/index.ts b/apps/web/src/entities/job/model/index.ts index 7f11b65a..5c73b97a 100644 --- a/apps/web/src/entities/job/model/index.ts +++ b/apps/web/src/entities/job/model/index.ts @@ -1,2 +1,2 @@ -export { END_POINT } from './end_point'; +export { END_POINT } from './end-point'; export * from './types'; diff --git a/apps/web/src/entities/onboarding/model/end_point.ts b/apps/web/src/entities/onboarding/model/end-point.ts similarity index 100% rename from apps/web/src/entities/onboarding/model/end_point.ts rename to apps/web/src/entities/onboarding/model/end-point.ts diff --git a/apps/web/src/entities/onboarding/model/index.ts b/apps/web/src/entities/onboarding/model/index.ts index 9544e07d..99d47db3 100644 --- a/apps/web/src/entities/onboarding/model/index.ts +++ b/apps/web/src/entities/onboarding/model/index.ts @@ -1,6 +1,6 @@ export * from './constants'; export * from './degree'; -export { END_POINT } from './end_point'; +export { END_POINT } from './end-point'; export * from './form-to-request'; export * from './job-skills'; export * from './options'; diff --git a/apps/web/src/entities/phase/model/end-point.ts b/apps/web/src/entities/phase/model/end-point.ts index 68eb05ae..9c7ff82c 100644 --- a/apps/web/src/entities/phase/model/end-point.ts +++ b/apps/web/src/entities/phase/model/end-point.ts @@ -1,6 +1,11 @@ export const END_POINT = { PHASE: { + GET_PHASE_LIST: 'api/v1/roadmap/phases', + GET_PHASE_ITEM_HOME: (phaseId: number) => + `api/v1/roadmap/phases/${phaseId}/home`, + GET_PHASE_ITEM_ROADMAP: (phaseId: number) => + `api/v1/roadmap/phases/${phaseId}`, GET_AI_GUIDE: (phaseActionId: number) => - `api/v1/phase-actions/${phaseActionId}/guide`, + `api/v1/roadmap/phase-actions/${phaseActionId}/guide`, }, }; diff --git a/apps/web/src/entities/phase/model/end_point.ts b/apps/web/src/entities/phase/model/end_point.ts deleted file mode 100644 index ac6a97c2..00000000 --- a/apps/web/src/entities/phase/model/end_point.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const END_POINT = { - PHASE: { - GET_PHASE_LIST: 'api/v1/phases', - GET_PHASE_ITEM_HOME: (phaseId: number) => `api/v1/phases/${phaseId}/home`, - GET_PHASE_ITEM_ROADMAP: (phaseId: number) => - `api/v1/phases/${phaseId}/roadmap`, - }, -}; diff --git a/apps/web/src/entities/phase/model/index.ts b/apps/web/src/entities/phase/model/index.ts index 3bc491ec..d290ad3f 100644 --- a/apps/web/src/entities/phase/model/index.ts +++ b/apps/web/src/entities/phase/model/index.ts @@ -1,4 +1,4 @@ -export { END_POINT } from './end_point'; +export { END_POINT } from './end-point'; export type { GetPhaseItemHomeResponse, GetPhaseItemRoadmapResponse, diff --git a/apps/web/src/entities/phase/model/types.ts b/apps/web/src/entities/phase/model/types.ts index 8f704dd4..af99afcf 100644 --- a/apps/web/src/entities/phase/model/types.ts +++ b/apps/web/src/entities/phase/model/types.ts @@ -3,14 +3,14 @@ import { components, operations, paths } from '@shared/types/schema'; export type GetAiGuideRequest = operations['getAiGuide']['parameters']['path']; export type GetAIGuideResponse = - paths['/api/v1/phase-actions/{phaseActionId}/guide']['get']['responses']['200']['content']['*/*']; + paths['/api/v1/roadmap/phase-actions/{phaseActionId}/guide']['get']['responses']['200']['content']['*/*']; export type GetPhaseListResponse = - paths['/api/v1/phases']['get']['responses']['200']['content']['*/*']; + paths['/api/v1/roadmap/phases']['get']['responses']['200']['content']['*/*']; export type GetPhaseItemHomeResponse = - paths['/api/v1/phases/{phaseId}/home']['get']['responses']['200']['content']['*/*']; + paths['/api/v1/roadmap/phases/{phaseId}/home']['get']['responses']['200']['content']['*/*']; export type GetPhaseItemRoadmapResponse = - paths['/api/v1/phases/{phaseId}/roadmap']['get']['responses']['200']['content']['*/*']; + paths['/api/v1/roadmap/phases/{phaseId}']['get']['responses']['200']['content']['*/*']; export type Phase = components['schemas']['PhaseResponse']; export type RequiredActionType = 'Visa' | 'Career'; diff --git a/apps/web/src/entities/phase/queries/queries.ts b/apps/web/src/entities/phase/queries/queries.ts index 26eca9a7..ba352b16 100644 --- a/apps/web/src/entities/phase/queries/queries.ts +++ b/apps/web/src/entities/phase/queries/queries.ts @@ -9,17 +9,11 @@ import { getAiGuide } from '@entities/phase/api/get-ai-guide'; import { GetAiGuideRequest } from '@entities/phase/model/types'; import { PHASE_QUERY_KEY } from '@entities/phase/queries'; -const POLLING_COUNT_DOWM_TIMER = 2000; - export const PHASE_QUERY_OPTIONS = { GET_PHASE_LIST: () => { return queryOptions({ queryKey: PHASE_QUERY_KEY.PHASE_LIST(), queryFn: getPhaseList, - refetchInterval: (query) => { - const phases = query.state.data?.phases ?? []; - return phases.length === 0 ? POLLING_COUNT_DOWM_TIMER : false; - }, }); }, GET_PHASE_ITEM_HOME: (phaseId: number) => { diff --git a/apps/web/src/entities/terms/model/end_points.ts b/apps/web/src/entities/terms/model/end-point.ts similarity index 100% rename from apps/web/src/entities/terms/model/end_points.ts rename to apps/web/src/entities/terms/model/end-point.ts diff --git a/apps/web/src/entities/terms/model/index.ts b/apps/web/src/entities/terms/model/index.ts index 50f04455..8573be55 100644 --- a/apps/web/src/entities/terms/model/index.ts +++ b/apps/web/src/entities/terms/model/index.ts @@ -1,4 +1,4 @@ -export { END_POINT } from './end_points'; +export { END_POINT } from './end-point'; export type { GetTermsListResponse, PostTermAgreementsBody, diff --git a/apps/web/src/entities/todo/api/get-todo-list.ts b/apps/web/src/entities/todo/api/get-todo-list.ts index 715a2302..a1313fdb 100644 --- a/apps/web/src/entities/todo/api/get-todo-list.ts +++ b/apps/web/src/entities/todo/api/get-todo-list.ts @@ -1,4 +1,4 @@ -import { END_POINT } from '@entities/todo/model/end_point'; +import { END_POINT } from '@entities/todo/model/end-point'; import { GetTodoListResponse } from '@entities/todo/model/types'; import { api } from '@shared/apis/configs/instance'; diff --git a/apps/web/src/entities/todo/model/end-point.ts b/apps/web/src/entities/todo/model/end-point.ts new file mode 100644 index 00000000..af11381a --- /dev/null +++ b/apps/web/src/entities/todo/model/end-point.ts @@ -0,0 +1,5 @@ +export const END_POINT = { + TODO: { + GET_TODO_ITEMS: 'api/v1/roadmap/action-items', + }, +}; diff --git a/apps/web/src/entities/todo/model/end_point.ts b/apps/web/src/entities/todo/model/end_point.ts deleted file mode 100644 index 99461c29..00000000 --- a/apps/web/src/entities/todo/model/end_point.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const END_POINT = { - TODO: { - GET_TODO_ITEMS: 'api/v1/action-items', - }, -}; diff --git a/apps/web/src/entities/todo/model/get-due-in-days.ts b/apps/web/src/entities/todo/model/get-due-in-days.ts new file mode 100644 index 00000000..bd32bcfa --- /dev/null +++ b/apps/web/src/entities/todo/model/get-due-in-days.ts @@ -0,0 +1,21 @@ +export const getDueInDays = (deadline: string): number | null => { + const due = new Date(deadline); + + if (Number.isNaN(due.getTime())) { + return null; + } + + const now = new Date(); + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ); + const startOfDue = new Date(due.getFullYear(), due.getMonth(), due.getDate()); + + const MILLISECONDS_PER_DAY = 1000 * 60 * 60 * 24; + + return Math.ceil( + (startOfDue.getTime() - startOfToday.getTime()) / MILLISECONDS_PER_DAY, + ); +}; diff --git a/apps/web/src/entities/todo/model/index.ts b/apps/web/src/entities/todo/model/index.ts index 889080f8..500912c8 100644 --- a/apps/web/src/entities/todo/model/index.ts +++ b/apps/web/src/entities/todo/model/index.ts @@ -1,2 +1,3 @@ -export { END_POINT } from './end_point'; -export type { GetTodoListResponse } from './types'; +export { END_POINT } from './end-point'; +export { getDueInDays } from './get-due-in-days'; +export type { ActionItem, ActionItemList, GetTodoListResponse } from './types'; diff --git a/apps/web/src/entities/todo/model/types.ts b/apps/web/src/entities/todo/model/types.ts index 8b375fdc..c87b2e3d 100644 --- a/apps/web/src/entities/todo/model/types.ts +++ b/apps/web/src/entities/todo/model/types.ts @@ -1,4 +1,8 @@ -import { paths } from '@shared/types/schema'; +import { components, paths } from '@shared/types/schema'; export type GetTodoListResponse = - paths['/api/v1/action-items']['get']['responses']['200']['content']['*/*']; + paths['/api/v1/roadmap/action-items']['get']['responses']['200']['content']['*/*']; + +export type ActionItem = components['schemas']['ActionItemResponse']; + +export type ActionItemList = components['schemas']['ActionItemListResponse']; diff --git a/apps/web/src/entities/todo/ui/index.ts b/apps/web/src/entities/todo/ui/index.ts index 46f61c09..b52b13aa 100644 --- a/apps/web/src/entities/todo/ui/index.ts +++ b/apps/web/src/entities/todo/ui/index.ts @@ -1 +1,2 @@ +export { default as TodoCompletedSection } from './todo-completed-section/todo-completed-section'; export { default as TodoItem } from './todo-item/todo-item'; diff --git a/apps/web/src/entities/todo/ui/todo-completed-section/todo-completed-section.css.ts b/apps/web/src/entities/todo/ui/todo-completed-section/todo-completed-section.css.ts new file mode 100644 index 00000000..efd66355 --- /dev/null +++ b/apps/web/src/entities/todo/ui/todo-completed-section/todo-completed-section.css.ts @@ -0,0 +1,41 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const container = style({ + display: 'flex', + flexDirection: 'column', + gap: '1.6rem', + paddingTop: '1rem', + borderTop: `1px solid ${themeVars.color.grayscale.gray300}`, +}); + +export const toggleButton = style({ + display: 'flex', + alignItems: 'center', + width: '100%', + padding: '0.4rem 0', +}); + +export const checkIcon = style({ + flexShrink: 0, +}); + +export const label = style({ + ...typography.body7_sb_14, + color: themeVars.color.grayscale.gray700, + textAlign: 'left', + marginLeft: '0.4rem', +}); + +export const count = style({ + ...typography.body7_sb_14, + color: themeVars.color.grayscale.gray500, + flexShrink: 0, + marginLeft: '0.6rem', +}); + +export const chevron = style({ + flexShrink: 0, + marginLeft: 'auto', + color: themeVars.color.grayscale.gray400, +}); diff --git a/apps/web/src/entities/todo/ui/todo-completed-section/todo-completed-section.tsx b/apps/web/src/entities/todo/ui/todo-completed-section/todo-completed-section.tsx new file mode 100644 index 00000000..1a43b218 --- /dev/null +++ b/apps/web/src/entities/todo/ui/todo-completed-section/todo-completed-section.tsx @@ -0,0 +1,40 @@ +import { ArrowDownIcon, ArrowUpIcon, CheckCircleIcon } from '@kds/icons'; +import type { ReactNode } from 'react'; + +import * as styles from './todo-completed-section.css'; + +interface TodoCompletedSectionProps { + count: number; + label: string; + isOpen: boolean; + onToggleOpen: () => void; + children: ReactNode; +} + +const TodoCompletedSection = ({ + count, + label, + isOpen, + onToggleOpen, + children, +}: TodoCompletedSectionProps) => { + const ChevronIcon = isOpen ? ArrowUpIcon : ArrowDownIcon; + + return ( +
+ + {isOpen && children} +
+ ); +}; + +export default TodoCompletedSection; diff --git a/apps/web/src/entities/todo/ui/todo-item/todo-item.css.ts b/apps/web/src/entities/todo/ui/todo-item/todo-item.css.ts index 32a22745..bd4a6058 100644 --- a/apps/web/src/entities/todo/ui/todo-item/todo-item.css.ts +++ b/apps/web/src/entities/todo/ui/todo-item/todo-item.css.ts @@ -14,17 +14,26 @@ export const contentWrapper = style({ display: 'flex', flexDirection: 'column', gap: '0.2rem', + minWidth: 0, }); -export const icon = style({ - backgroundColor: 'transparent', - padding: 0, - cursor: 'pointer', +export const action = style({ + display: 'flex', + alignItems: 'center', + flexShrink: 0, + marginLeft: 'auto', }); export const title = style({ ...typography.body8_m_14, color: themeVars.color.grayscale.gray800, + + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 2, + overflow: 'hidden', + minWidth: 0, + wordBreak: 'break-word', }); export const description = style({ diff --git a/apps/web/src/entities/todo/ui/todo-item/todo-item.tsx b/apps/web/src/entities/todo/ui/todo-item/todo-item.tsx index 2b95cc32..e6435959 100644 --- a/apps/web/src/entities/todo/ui/todo-item/todo-item.tsx +++ b/apps/web/src/entities/todo/ui/todo-item/todo-item.tsx @@ -1,4 +1,5 @@ import { Checkbox } from '@kds/ui'; +import type { MouseEvent, ReactNode } from 'react'; import * as styles from './todo-item.css'; @@ -10,6 +11,7 @@ interface TodoItemProps { size: TodoItemSize; isChecked: boolean; onToggle: () => void; + action?: ReactNode; } const TodoItem = ({ @@ -18,16 +20,27 @@ const TodoItem = ({ size, isChecked, onToggle, + action, }: TodoItemProps) => { const showDescription = size === 'lg' || !isChecked; + const showFullTitleIfClamped = (event: MouseEvent) => { + const titleElement = event.currentTarget; + + titleElement.title = + titleElement.scrollHeight > titleElement.clientHeight ? title : ''; + }; + return (
  • -

    {title}

    +

    + {title} +

    {showDescription &&

    {description}

    }
    + {action &&
    {action}
    }
  • ); }; diff --git a/apps/web/src/entities/user/model/end_point.ts b/apps/web/src/entities/user/model/end-point.ts similarity index 100% rename from apps/web/src/entities/user/model/end_point.ts rename to apps/web/src/entities/user/model/end-point.ts diff --git a/apps/web/src/entities/user/model/index.ts b/apps/web/src/entities/user/model/index.ts index 62db2e04..a0948a8c 100644 --- a/apps/web/src/entities/user/model/index.ts +++ b/apps/web/src/entities/user/model/index.ts @@ -1,4 +1,4 @@ -export { END_POINT } from './end_point'; +export { END_POINT } from './end-point'; export type { GetUserCompletion, GetUserInfoResponse, diff --git a/apps/web/src/features/auth/api/exchange-google-code.ts b/apps/web/src/features/auth/api/exchange-google-code.ts index 94d109e9..eaec4482 100644 --- a/apps/web/src/features/auth/api/exchange-google-code.ts +++ b/apps/web/src/features/auth/api/exchange-google-code.ts @@ -1,4 +1,4 @@ -import { END_POINT } from '@features/auth/model/end_point'; +import { END_POINT } from '@features/auth/model/end-point'; import { GoogleLoginResponse } from '@features/auth/model/types'; import { api } from '@shared/apis/configs/instance'; diff --git a/apps/web/src/features/auth/api/sign-out.ts b/apps/web/src/features/auth/api/sign-out.ts index 04576c81..a7780c4e 100644 --- a/apps/web/src/features/auth/api/sign-out.ts +++ b/apps/web/src/features/auth/api/sign-out.ts @@ -1,4 +1,4 @@ -import { END_POINT } from '@features/auth/model/end_point'; +import { END_POINT } from '@features/auth/model/end-point'; import { LogOutResponse } from '@features/auth/model/types'; import { api } from '@shared/apis/configs/instance'; diff --git a/apps/web/src/features/auth/model/end_point.ts b/apps/web/src/features/auth/model/end-point.ts similarity index 100% rename from apps/web/src/features/auth/model/end_point.ts rename to apps/web/src/features/auth/model/end-point.ts diff --git a/apps/web/src/features/onboarding/api/parse-sse-events.ts b/apps/web/src/features/onboarding/api/parse-sse-events.ts new file mode 100644 index 00000000..efa45b34 --- /dev/null +++ b/apps/web/src/features/onboarding/api/parse-sse-events.ts @@ -0,0 +1,32 @@ +const parseSseEvent = ( + block: string, +): { + event: string; + data: string; +} => { + const data: string[] = []; + let event = 'message'; + + block.split(/\r?\n/).forEach((line) => { + const [field, ...values] = line.split(':'); + const value = values.join(':').replace(/^ /, ''); + + if (field === 'event') { + event = value; + } + + if (field === 'data') { + data.push(value); + } + }); + + return { event, data: data.join('\n') }; +}; + +export const parseSseEvents = (buffer: string, flush = false) => { + const blocks = buffer.split(/\r?\n\r?\n/); + const remaining = flush ? '' : (blocks.pop() ?? ''); + const events = blocks.filter(Boolean).map(parseSseEvent); + + return { events, remaining }; +}; diff --git a/apps/web/src/features/onboarding/api/post-onboarding-roadmap.ts b/apps/web/src/features/onboarding/api/post-onboarding-roadmap.ts index c16e05cd..d4d36b70 100644 --- a/apps/web/src/features/onboarding/api/post-onboarding-roadmap.ts +++ b/apps/web/src/features/onboarding/api/post-onboarding-roadmap.ts @@ -1,10 +1,52 @@ -import type { PostAiRoadMapResponse } from '@features/onboarding'; import { END_POINT } from '@features/onboarding'; import { api } from '@shared/apis/configs/instance'; -export const postAiRoadMap = async (): Promise => { - const response = await api - .post(END_POINT.ONBOARDING.POST_AI_ROADMAP) - .json(); - return response; +import { parseSseEvents } from './parse-sse-events'; + +interface RoadmapGenerationFailedEvent { + message?: string; +} + +export const postAiRoadMap = async (): Promise => { + const response = await api.post(END_POINT.ONBOARDING.POST_AI_ROADMAP, { + headers: { Accept: 'text/event-stream' }, + timeout: false, + }); + const reader = response.body?.getReader(); + + if (!reader) { + throw new Error('로드맵 생성 스트림을 열지 못했습니다.'); + } + + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value, { stream: !done }); + + const parsed = parseSseEvents(buffer, done); + buffer = parsed.remaining; + + for (const event of parsed.events) { + if (event.event === 'roadmap-completed') { + return; + } + + if (event.event === 'roadmap-failed') { + const failure = JSON.parse( + event.data, + ) as RoadmapGenerationFailedEvent; + throw new Error(failure.message ?? '로드맵 생성에 실패했습니다.'); + } + } + + if (done) { + throw new Error('로드맵 생성 스트림이 완료 전에 종료되었습니다.'); + } + } + } finally { + reader.releaseLock(); + } }; diff --git a/apps/web/src/features/onboarding/hooks/index.ts b/apps/web/src/features/onboarding/hooks/index.ts index 422c3019..18a1c9f0 100644 --- a/apps/web/src/features/onboarding/hooks/index.ts +++ b/apps/web/src/features/onboarding/hooks/index.ts @@ -1,3 +1,4 @@ export { useIndustryField } from './useIndustryField'; +export { useRoadmapLoadingProgress } from './useRoadmapLoadingProgress'; export { useTargetJobSkills } from './useTargetJobSkills'; export { useVisaInformation } from './useVisaInformation'; diff --git a/apps/web/src/features/onboarding/hooks/useRoadmapLoadingProgress.ts b/apps/web/src/features/onboarding/hooks/useRoadmapLoadingProgress.ts new file mode 100644 index 00000000..0377cc0c --- /dev/null +++ b/apps/web/src/features/onboarding/hooks/useRoadmapLoadingProgress.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; + +const STEP_DURATION_MS = 5_000; + +interface UseRoadmapLoadingProgressParams { + stepCount: number; + isRoadmapReady: boolean; + startedAt: number; +} + +export const useRoadmapLoadingProgress = ({ + stepCount, + isRoadmapReady, + startedAt, +}: UseRoadmapLoadingProgressParams) => { + const [currentTime, setCurrentTime] = useState(() => Date.now()); + const lastStepIndex = stepCount - 1; + const elapsedTime = Math.max(currentTime - startedAt, 0); + const activeStepIndex = Math.min( + Math.floor(elapsedTime / STEP_DURATION_MS), + lastStepIndex, + ); + + useEffect( + function scheduleNextRoadmapLoadingStep() { + if (activeStepIndex === lastStepIndex) { + return; + } + + const nextStepStartedAt = + startedAt + (activeStepIndex + 1) * STEP_DURATION_MS; + const remainingTime = Math.max(nextStepStartedAt - Date.now(), 0); + const timer = window.setTimeout( + () => setCurrentTime(Date.now()), + remainingTime, + ); + + return () => window.clearTimeout(timer); + }, + [activeStepIndex, lastStepIndex, startedAt], + ); + + const isComplete = activeStepIndex === lastStepIndex && isRoadmapReady; + + return isComplete ? stepCount : activeStepIndex; +}; diff --git a/apps/web/src/features/onboarding/model/end-point.ts b/apps/web/src/features/onboarding/model/end-point.ts index 77d3cb68..f557101e 100644 --- a/apps/web/src/features/onboarding/model/end-point.ts +++ b/apps/web/src/features/onboarding/model/end-point.ts @@ -1,7 +1,7 @@ export const END_POINT = { ONBOARDING: { POST_ONBOARDING: 'api/v2/members/onboard', - POST_AI_ROADMAP: 'api/v1/members/roadmap', + POST_AI_ROADMAP: 'api/v1/roadmap/generations/stream', POST_OCR_VISA: 'api/v1/members/onboard/ocr/visa', POST_OCR_PASSPORT: 'api/v1/members/onboard/ocr/passport', }, diff --git a/apps/web/src/features/onboarding/model/index.ts b/apps/web/src/features/onboarding/model/index.ts index 829cb9ba..309bfd14 100644 --- a/apps/web/src/features/onboarding/model/index.ts +++ b/apps/web/src/features/onboarding/model/index.ts @@ -1,6 +1,5 @@ export { END_POINT } from './end-point'; export { - type PostAiRoadMapResponse, type PostOcrPassportResponse, type PostOcrVisaResponse, type PostOnboardingForm, diff --git a/apps/web/src/features/onboarding/model/types.ts b/apps/web/src/features/onboarding/model/types.ts index fba86e2f..ed4aa922 100644 --- a/apps/web/src/features/onboarding/model/types.ts +++ b/apps/web/src/features/onboarding/model/types.ts @@ -3,9 +3,6 @@ import { paths } from '@shared/types/schema'; export type PostOnboardingForm = paths['/api/v2/members/onboard']['post']['requestBody']['content']['application/json']; -export type PostAiRoadMapResponse = - paths['/api/v1/members/roadmap']['post']['responses']['200']['content']['*/*']; - export type PostOcrVisaResponse = paths['/api/v1/members/onboard/ocr/visa']['post']['responses']['200']['content']['*/*']; diff --git a/apps/web/src/features/onboarding/queries/index.ts b/apps/web/src/features/onboarding/queries/index.ts index e2a5e407..2916286f 100644 --- a/apps/web/src/features/onboarding/queries/index.ts +++ b/apps/web/src/features/onboarding/queries/index.ts @@ -1 +1,4 @@ -export { ONBOARDING_MUTATION_OPTIONS } from './queries'; +export { + ONBOARDING_MUTATION_OPTIONS, + ROADMAP_GENERATION_MUTATION_KEY, +} from './queries'; diff --git a/apps/web/src/features/onboarding/queries/queries.ts b/apps/web/src/features/onboarding/queries/queries.ts index 8cda3a9a..2a1b4b42 100644 --- a/apps/web/src/features/onboarding/queries/queries.ts +++ b/apps/web/src/features/onboarding/queries/queries.ts @@ -7,6 +7,8 @@ import { postOnboardingForm, } from '@features/onboarding'; +export const ROADMAP_GENERATION_MUTATION_KEY = ['roadmap-generation'] as const; + export const ONBOARDING_MUTATION_OPTIONS = { POST_ONBOARDING_FORM: () => { return mutationOptions({ @@ -15,6 +17,7 @@ export const ONBOARDING_MUTATION_OPTIONS = { }, POST_AI_ROADMAP: () => { return mutationOptions({ + mutationKey: ROADMAP_GENERATION_MUTATION_KEY, mutationFn: postAiRoadMap, }); }, diff --git a/apps/web/src/features/onboarding/ui/index.ts b/apps/web/src/features/onboarding/ui/index.ts index b86a0185..6ee7b1e6 100644 --- a/apps/web/src/features/onboarding/ui/index.ts +++ b/apps/web/src/features/onboarding/ui/index.ts @@ -1 +1,2 @@ export { default as LanguageSelector } from './language-selector/language-selector'; +export { default as RoadmapLoadingCard } from './roadmap-loading-card/roadmap-loading-card'; diff --git a/apps/web/src/features/onboarding/ui/roadmap-loading-card/roadmap-loading-card.css.ts b/apps/web/src/features/onboarding/ui/roadmap-loading-card/roadmap-loading-card.css.ts new file mode 100644 index 00000000..65bdf55b --- /dev/null +++ b/apps/web/src/features/onboarding/ui/roadmap-loading-card/roadmap-loading-card.css.ts @@ -0,0 +1,245 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { keyframes, style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +const spin = keyframes({ + to: { transform: 'rotate(360deg)' }, +}); + +const fadeOut = keyframes({ + to: { opacity: 0 }, +}); + +const delayedTransition = '700ms'; +const reducedMotion = '(prefers-reduced-motion: reduce)'; + +export const card = recipe({ + base: { + display: 'flex', + flexDirection: 'column', + width: '56rem', + height: '44rem', + padding: '4rem', + gap: '3rem', + backgroundColor: themeVars.color.grayscale.white, + border: `1px solid ${themeVars.color.grayscale.gray300}`, + borderRadius: '20px', + }, + variants: { + isComplete: { + true: { + animation: `${fadeOut} 300ms ease-in 700ms forwards`, + '@media': { + [reducedMotion]: { + animation: `${fadeOut} 1ms linear forwards`, + }, + }, + }, + false: {}, + }, + }, +}); + +export const heading = style({ + ...typography.sub1_sb_22, + color: themeVars.color.grayscale.gray800, +}); + +export const screenReaderStatus = style({ + position: 'absolute', + width: '1px', + height: '1px', + padding: 0, + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', +}); + +export const stepList = style({ + width: '100%', + overflow: 'hidden', +}); + +export const stepRow = recipe({ + base: { + display: 'flex', + alignItems: 'center', + width: '100%', + height: '7.8rem', + padding: '1rem 1.4rem', + gap: '1.6rem', + borderRadius: '10px', + overflow: 'hidden', + transition: `background-color 300ms ease ${delayedTransition}`, + '@media': { + [reducedMotion]: { transition: 'none' }, + }, + }, + variants: { + status: { + active: { backgroundColor: themeVars.color.grayscale.gray100 }, + done: { backgroundColor: 'transparent' }, + pending: { backgroundColor: 'transparent' }, + }, + }, +}); + +export const statusIcon = style({ + position: 'relative', + flexShrink: 0, + width: '2.4rem', + height: '2.4rem', +}); + +export const pendingIcon = recipe({ + base: { + position: 'absolute', + inset: '0.2rem', + border: `2px solid ${themeVars.color.grayscale.gray300}`, + borderRadius: '50%', + transition: `opacity 300ms ease ${delayedTransition}`, + '@media': { + [reducedMotion]: { transition: 'none' }, + }, + }, + variants: { + status: { + active: { opacity: 0 }, + done: { opacity: 0, transitionDelay: '0ms' }, + pending: { opacity: 1 }, + }, + }, +}); + +export const loadingIcon = recipe({ + base: { + position: 'absolute', + inset: '0.2rem', + border: `2px solid ${themeVars.color.primary[200]}`, + borderTopColor: themeVars.color.primary[500], + borderRadius: '50%', + animation: `${spin} 800ms linear infinite`, + transition: `opacity 300ms ease ${delayedTransition}`, + '@media': { + [reducedMotion]: { + animation: 'none', + transition: 'none', + }, + }, + }, + variants: { + status: { + active: { opacity: 1 }, + done: { opacity: 0, transition: 'opacity 220ms ease-out' }, + pending: { opacity: 0 }, + }, + }, +}); + +export const doneIcon = recipe({ + base: { + position: 'absolute', + inset: '0.2rem', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: themeVars.color.grayscale.white, + backgroundColor: themeVars.color.primary[500], + borderRadius: '50%', + opacity: 0, + transform: 'scale(0.75)', + transition: 'opacity 220ms ease-out, transform 220ms ease-out', + '@media': { + [reducedMotion]: { transition: 'none' }, + }, + }, + variants: { + status: { + active: {}, + done: { opacity: 1, transform: 'scale(1)' }, + pending: {}, + }, + }, +}); + +export const stepContent = style({ + display: 'flex', + flex: 1, + flexDirection: 'column', + minWidth: 0, + overflow: 'hidden', +}); + +export const stepTitle = recipe({ + base: { + ...typography.body5_m_16, + transition: `color 300ms ease ${delayedTransition}, font-weight 300ms ease ${delayedTransition}`, + '@media': { + [reducedMotion]: { transition: 'none' }, + }, + }, + variants: { + status: { + active: { + ...typography.body4_sb_16, + color: themeVars.color.grayscale.gray800, + }, + done: { color: themeVars.color.grayscale.gray600 }, + pending: { color: themeVars.color.grayscale.gray400 }, + }, + }, +}); + +export const stepDescription = recipe({ + base: { + ...typography.body9_r_14, + color: themeVars.color.grayscale.gray500, + maxHeight: 0, + marginTop: 0, + opacity: 0, + overflow: 'hidden', + transition: `max-height 300ms ease ${delayedTransition}, margin-top 300ms ease ${delayedTransition}, opacity 300ms ease ${delayedTransition}`, + '@media': { + [reducedMotion]: { transition: 'none' }, + }, + }, + variants: { + status: { + active: { maxHeight: '1.7rem', marginTop: '0.5rem', opacity: 1 }, + done: {}, + pending: {}, + }, + }, +}); + +export const connector = style({ + position: 'relative', + display: 'block', + width: '0.2rem', + height: '3rem', + marginLeft: '2.5rem', + overflow: 'hidden', + backgroundColor: themeVars.color.grayscale.gray300, + borderRadius: '1px', +}); + +export const connectorFill = recipe({ + base: { + position: 'absolute', + inset: 0, + backgroundColor: themeVars.color.primary[500], + transform: 'scaleY(0)', + transformOrigin: 'top', + transition: 'transform 520ms ease-in-out 180ms', + '@media': { + [reducedMotion]: { transition: 'none' }, + }, + }, + variants: { + isActive: { + true: { transform: 'scaleY(1)' }, + false: {}, + }, + }, +}); diff --git a/apps/web/src/features/onboarding/ui/roadmap-loading-card/roadmap-loading-card.tsx b/apps/web/src/features/onboarding/ui/roadmap-loading-card/roadmap-loading-card.tsx new file mode 100644 index 00000000..48a11a0e --- /dev/null +++ b/apps/web/src/features/onboarding/ui/roadmap-loading-card/roadmap-loading-card.tsx @@ -0,0 +1,138 @@ +import { CheckIcon } from '@kds/icons'; +import type { AnimationEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useRoadmapLoadingProgress } from '@features/onboarding/hooks'; + +import * as styles from './roadmap-loading-card.css'; + +interface RoadmapLoadingCardProps { + name: string; + isRoadmapReady: boolean; + startedAt: number; + onExitComplete?: () => void; +} + +type StepStatus = 'active' | 'done' | 'pending'; + +const getStepStatus = ( + stepIndex: number, + completedStepCount: number, +): StepStatus => { + if (stepIndex < completedStepCount) { + return 'done'; + } + + if (stepIndex === completedStepCount) { + return 'active'; + } + + return 'pending'; +}; + +const RoadmapLoadingCard = ({ + name, + isRoadmapReady, + startedAt, + onExitComplete, +}: RoadmapLoadingCardProps) => { + const { t } = useTranslation('onboarding'); + const steps = [ + { + id: 'profileReview', + title: t('roadmapLoading.steps.profileReview.title', { name }), + description: t('roadmapLoading.steps.profileReview.description'), + }, + { + id: 'jobMatching', + title: t('roadmapLoading.steps.jobMatching.title'), + description: t('roadmapLoading.steps.jobMatching.description'), + }, + { + id: 'roadmapCreation', + title: t('roadmapLoading.steps.roadmapCreation.title', { name }), + description: t('roadmapLoading.steps.roadmapCreation.description'), + }, + ]; + const completedStepCount = useRoadmapLoadingProgress({ + stepCount: steps.length, + isRoadmapReady, + startedAt, + }); + const isComplete = completedStepCount === steps.length; + const currentStep = steps[completedStepCount]; + const stepItems = steps.map((step, index) => { + const status = getStepStatus(index, completedStepCount); + const isLastStep = index === steps.length - 1; + + return { + ...step, + status, + hasConnector: !isLastStep, + isConnectorActive: status === 'done', + }; + }); + + const handleAnimationEnd = (event: AnimationEvent) => { + if (event.target === event.currentTarget && isComplete) { + onExitComplete?.(); + } + }; + + return ( +
    +

    + {isComplete + ? t('roadmapLoading.completedTitle', { name }) + : t('roadmapLoading.title')} +

    +

    + {currentStep?.title ?? t('roadmapLoading.completedTitle', { name })} +

    +
    + {stepItems.map( + ({ + id, + title, + description, + status, + hasConnector, + isConnectorActive, + }) => ( +
    +
    + + + + + + + + + {title} + + {description} + + +
    + {hasConnector && ( + + + + )} +
    + ), + )} +
    +
    + ); +}; + +export default RoadmapLoadingCard; diff --git a/apps/web/src/features/todo/api/create-todo-item.ts b/apps/web/src/features/todo/api/create-todo-item.ts new file mode 100644 index 00000000..59c06920 --- /dev/null +++ b/apps/web/src/features/todo/api/create-todo-item.ts @@ -0,0 +1,23 @@ +import { + END_POINT, + PostCreateTodoBody, + PostCreateTodoRequest, + PostCreateTodoResponse, + TodoItemResponseData, +} from '@features/todo/model'; +import { api } from '@shared/apis/configs/instance'; + +import { unwrapActionItem } from './unwrap-action-item'; + +export const createTodoItem = async ({ + actionsType, + ...rest +}: PostCreateTodoRequest): Promise => { + const body: PostCreateTodoBody = { ...rest, type: actionsType }; + + const response = await api + .post(END_POINT.TODO.POST_ACTION_ITEM, { json: body }) + .json(); + + return unwrapActionItem(response.data); +}; diff --git a/apps/web/src/features/todo/api/delete-todo-item.ts b/apps/web/src/features/todo/api/delete-todo-item.ts new file mode 100644 index 00000000..bf160081 --- /dev/null +++ b/apps/web/src/features/todo/api/delete-todo-item.ts @@ -0,0 +1,21 @@ +import { DeleteTodoRequest, END_POINT } from '@features/todo/model'; +import { api } from '@shared/apis/configs/instance'; +import { HTTP_STATUS_CODE } from '@shared/constants/HTTP_STATUS_CODE'; +import { isHttpError } from '@shared/utils/http-error'; + +export const deleteTodoItem = async ({ + actionItemId, +}: DeleteTodoRequest): Promise => { + try { + await api.delete(END_POINT.TODO.DELETE_ACTION_ITEM(actionItemId)); + } catch (error) { + if ( + isHttpError(error) && + error.response?.status === HTTP_STATUS_CODE.NOT_FOUND + ) { + return; + } + + throw error; + } +}; diff --git a/apps/web/src/features/todo/api/index.ts b/apps/web/src/features/todo/api/index.ts index 44eb8a7c..63704952 100644 --- a/apps/web/src/features/todo/api/index.ts +++ b/apps/web/src/features/todo/api/index.ts @@ -1,2 +1,5 @@ export { addTodoItem } from './add-todo-item'; +export { createTodoItem } from './create-todo-item'; +export { deleteTodoItem } from './delete-todo-item'; export { toggleCheckbox } from './toggle-checkbox'; +export { updateTodoItem } from './update-todo-item'; diff --git a/apps/web/src/features/todo/api/unwrap-action-item.ts b/apps/web/src/features/todo/api/unwrap-action-item.ts new file mode 100644 index 00000000..cc1de833 --- /dev/null +++ b/apps/web/src/features/todo/api/unwrap-action-item.ts @@ -0,0 +1,9 @@ +import type { ActionItem } from '@entities/todo'; + +export const unwrapActionItem = (data: ActionItem | undefined): ActionItem => { + if (!data) { + throw new Error('액션 아이템 응답이 비어 있습니다.'); + } + + return data; +}; diff --git a/apps/web/src/features/todo/api/update-todo-item.ts b/apps/web/src/features/todo/api/update-todo-item.ts new file mode 100644 index 00000000..e8501c92 --- /dev/null +++ b/apps/web/src/features/todo/api/update-todo-item.ts @@ -0,0 +1,24 @@ +import { + END_POINT, + PatchUpdateTodoBody, + PatchUpdateTodoRequest, + PatchUpdateTodoResponse, + TodoItemResponseData, +} from '@features/todo/model'; +import { api } from '@shared/apis/configs/instance'; + +import { unwrapActionItem } from './unwrap-action-item'; + +export const updateTodoItem = async ({ + actionItemId, + title, + deadline, +}: PatchUpdateTodoRequest): Promise => { + const body: PatchUpdateTodoBody = { title, deadline }; + + const response = await api + .patch(END_POINT.TODO.PATCH_ACTION_ITEM(actionItemId), { json: body }) + .json(); + + return unwrapActionItem(response.data); +}; diff --git a/apps/web/src/features/todo/hooks/index.ts b/apps/web/src/features/todo/hooks/index.ts new file mode 100644 index 00000000..75049be9 --- /dev/null +++ b/apps/web/src/features/todo/hooks/index.ts @@ -0,0 +1,4 @@ +export { useCreateTodo } from './use-create-todo'; +export { useDeleteTodo } from './use-delete-todo'; +export { useToggleTodo } from './use-toggle-todo'; +export { useUpdateTodo } from './use-update-todo'; diff --git a/apps/web/src/features/todo/hooks/use-create-todo.ts b/apps/web/src/features/todo/hooks/use-create-todo.ts new file mode 100644 index 00000000..ebcace4f --- /dev/null +++ b/apps/web/src/features/todo/hooks/use-create-todo.ts @@ -0,0 +1,81 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { + daysToDeadline, + insertItem, + removeItem, + type TodoActionsType, + type TodoDraft, +} from '@features/todo/model'; +import { TODO_MUTATION_OPTIONS } from '@features/todo/queries'; +import { type ActionItemList, TODO_QUERY_OPTIONS } from '@entities/todo'; + +let lastTempId = 0; + +const createTempId = () => { + lastTempId -= 1; + + return lastTempId; +}; + +export const useCreateTodo = () => { + const queryClient = useQueryClient(); + const { queryKey } = TODO_QUERY_OPTIONS.GET_TODO_LIST(); + + const { mutate, isPending } = useMutation({ + ...TODO_MUTATION_OPTIONS.POST_CREATE_TODO(), + onMutate: async (payload) => { + await queryClient.cancelQueries({ queryKey }); + + const prev = queryClient.getQueryData(queryKey); + const tempId = createTempId(); + + queryClient.setQueryData(queryKey, (current) => { + if (!current) { + return current; + } + + return insertItem( + current, + { + actionItemId: tempId, + title: payload.title, + deadline: payload.deadline, + completed: false, + }, + payload.actionsType, + ); + }); + + return { prev, tempId }; + }, + onSuccess: (item, payload, context) => { + queryClient.setQueryData(queryKey, (current) => { + if (!current) { + return current; + } + + return insertItem( + removeItem(current, context.tempId), + item, + payload.actionsType, + ); + }); + }, + onError: (_error, _payload, context) => { + if (context?.prev) { + queryClient.setQueryData(queryKey, context.prev); + } + }, + }); + + const createTodo = (draft: TodoDraft, actionsType: TodoActionsType) => { + mutate({ + title: draft.title.trim(), + deadline: daysToDeadline(draft.dueInDays ?? 0), + actionsType, + }); + }; + + return { createTodo, isPending }; +}; diff --git a/apps/web/src/features/todo/hooks/use-delete-todo.ts b/apps/web/src/features/todo/hooks/use-delete-todo.ts new file mode 100644 index 00000000..f0c9f770 --- /dev/null +++ b/apps/web/src/features/todo/hooks/use-delete-todo.ts @@ -0,0 +1,105 @@ +import { type ReactNode, useEffect, useRef } from 'react'; +import { useToast } from '@kds/ui'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { deleteTodoItem } from '@features/todo/api'; +import { removeItem } from '@features/todo/model'; +import { TODO_MUTATION_OPTIONS } from '@features/todo/queries'; +import { type ActionItemList, TODO_QUERY_OPTIONS } from '@entities/todo'; + +interface UseDeleteTodoParams { + onHide: (actionItemId: number) => void; + onReveal: (actionItemId: number) => void; + renderUndoAction: (onUndo: () => void) => ReactNode; +} + +export const useDeleteTodo = ({ + onHide, + onReveal, + renderUndoAction, +}: UseDeleteTodoParams) => { + const { t } = useTranslation('todo'); + const { showToast, hideToast } = useToast(); + const queryClient = useQueryClient(); + const { queryKey } = TODO_QUERY_OPTIONS.GET_TODO_LIST(); + const pendingDeletesRef = useRef(new Map()); + + const { mutate } = useMutation({ + ...TODO_MUTATION_OPTIONS.DELETE_TODO(), + onMutate: async ({ actionItemId }) => { + await queryClient.cancelQueries({ queryKey }); + + const prev = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (current) => { + if (!current) { + return current; + } + + return removeItem(current, actionItemId); + }); + + return { prev }; + }, + onSuccess: (_data, { actionItemId }) => { + onReveal(actionItemId); + }, + onError: (_error, { actionItemId }, context) => { + if (context?.prev) { + queryClient.setQueryData(queryKey, context.prev); + } + + onReveal(actionItemId); + }, + }); + const commitDelete = (actionItemId: number) => { + if (!pendingDeletesRef.current.delete(actionItemId)) { + return; + } + + mutate({ actionItemId }); + }; + + const cancelDelete = (actionItemId: number) => { + const toastId = pendingDeletesRef.current.get(actionItemId); + + if (!toastId) { + return; + } + + pendingDeletesRef.current.delete(actionItemId); + hideToast(toastId); + onReveal(actionItemId); + }; + + const requestDelete = (actionItemId: number) => { + if (pendingDeletesRef.current.has(actionItemId)) { + return; + } + + onHide(actionItemId); + + const toastId = showToast({ + message: t('toast.deleted'), + action: renderUndoAction(() => cancelDelete(actionItemId)), + onAutoDismiss: () => commitDelete(actionItemId), + }); + + pendingDeletesRef.current.set(actionItemId, toastId); + }; + + useEffect(() => { + const pendingDeletes = pendingDeletesRef.current; + + return () => { + pendingDeletes.forEach((_toastId, actionItemId) => { + deleteTodoItem({ actionItemId }).catch(() => undefined); + }); + + pendingDeletes.clear(); + }; + }, []); + + return { requestDelete }; +}; diff --git a/apps/web/src/features/todo/hooks/use-toggle-todo.ts b/apps/web/src/features/todo/hooks/use-toggle-todo.ts new file mode 100644 index 00000000..9a3ac88a --- /dev/null +++ b/apps/web/src/features/todo/hooks/use-toggle-todo.ts @@ -0,0 +1,77 @@ +import { useRef } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { TODO_MUTATION_OPTIONS } from '@features/todo/queries'; +import { PHASE_QUERY_KEY } from '@entities/phase/queries'; +import { type ActionItemList, TODO_QUERY_OPTIONS } from '@entities/todo'; + +const toggleCompleted = ( + items: ActionItemList['visaActionItems'], + actionItemId: number, +) => + items?.map((item) => + item.actionItemId === actionItemId + ? { ...item, completed: !item.completed } + : item, + ); + +export const useToggleTodo = () => { + const queryClient = useQueryClient(); + const { queryKey } = TODO_QUERY_OPTIONS.GET_TODO_LIST(); + const pendingActionItemIds = useRef(new Set()); + + const { mutate } = useMutation({ + ...TODO_MUTATION_OPTIONS.PATCH_TODO(), + onMutate: async (actionItemId) => { + pendingActionItemIds.current.add(actionItemId); + + await queryClient.cancelQueries({ queryKey }); + + const prev = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (current) => { + if (!current) { + return current; + } + + return { + ...current, + visaActionItems: toggleCompleted( + current.visaActionItems, + actionItemId, + ), + careerActionItems: toggleCompleted( + current.careerActionItems, + actionItemId, + ), + }; + }); + + return { prev }; + }, + onError: (_error, _variables, context) => { + if (context?.prev) { + queryClient.setQueryData(queryKey, context.prev); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: PHASE_QUERY_KEY.PHASE_ITEM_ROADMAP_ALL(), + }); + }, + onSettled: (_data, _error, actionItemId) => { + pendingActionItemIds.current.delete(actionItemId); + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const toggleTodo = (actionItemId: number) => { + if (pendingActionItemIds.current.has(actionItemId)) { + return; + } + + mutate(actionItemId); + }; + + return { toggleTodo }; +}; diff --git a/apps/web/src/features/todo/hooks/use-update-todo.ts b/apps/web/src/features/todo/hooks/use-update-todo.ts new file mode 100644 index 00000000..7fda9819 --- /dev/null +++ b/apps/web/src/features/todo/hooks/use-update-todo.ts @@ -0,0 +1,61 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { + daysToDeadline, + type TodoDraft, + updateItem, +} from '@features/todo/model'; +import { TODO_MUTATION_OPTIONS } from '@features/todo/queries'; +import { type ActionItemList, TODO_QUERY_OPTIONS } from '@entities/todo'; + +export const useUpdateTodo = () => { + const queryClient = useQueryClient(); + const { queryKey } = TODO_QUERY_OPTIONS.GET_TODO_LIST(); + + const { mutate, isPending } = useMutation({ + ...TODO_MUTATION_OPTIONS.PATCH_UPDATE_TODO(), + onMutate: async (payload) => { + await queryClient.cancelQueries({ queryKey }); + + const prev = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (current) => { + if (!current) { + return current; + } + + return updateItem(current, { + actionItemId: payload.actionItemId, + title: payload.title, + deadline: payload.deadline, + }); + }); + + return { prev }; + }, + onSuccess: (item) => { + queryClient.setQueryData(queryKey, (current) => { + if (!current) { + return current; + } + + return updateItem(current, item); + }); + }, + onError: (_error, _payload, context) => { + if (context?.prev) { + queryClient.setQueryData(queryKey, context.prev); + } + }, + }); + + const updateTodo = (actionItemId: number, draft: TodoDraft) => { + mutate({ + actionItemId, + title: draft.title.trim(), + deadline: daysToDeadline(draft.dueInDays ?? 0), + }); + }; + + return { updateTodo, isPending }; +}; diff --git a/apps/web/src/features/todo/index.ts b/apps/web/src/features/todo/index.ts index 355d61ed..d7e48283 100644 --- a/apps/web/src/features/todo/index.ts +++ b/apps/web/src/features/todo/index.ts @@ -1,4 +1,5 @@ export * from './api'; +export * from './hooks'; export * from './model'; export * from './queries'; export * from './ui'; diff --git a/apps/web/src/features/todo/model/end-point.ts b/apps/web/src/features/todo/model/end-point.ts index 294b6b07..54518e0d 100644 --- a/apps/web/src/features/todo/model/end-point.ts +++ b/apps/web/src/features/todo/model/end-point.ts @@ -1,8 +1,13 @@ export const END_POINT = { TODO: { POST_TODO_ITEM: (phaseActionId: number) => - `api/v1/phase-actions/${phaseActionId}/todo`, + `api/v1/roadmap/phase-actions/${phaseActionId}/todo`, PATCH_TOGGLE_CHECKBOX: (actionItemId: number) => - `api/v1/action-items/${actionItemId}/completed`, + `api/v1/roadmap/action-items/${actionItemId}/completed`, + POST_ACTION_ITEM: 'api/v1/roadmap/action-items', + PATCH_ACTION_ITEM: (actionItemId: number) => + `api/v1/roadmap/action-items/${actionItemId}`, + DELETE_ACTION_ITEM: (actionItemId: number) => + `api/v1/roadmap/action-items/${actionItemId}`, }, }; diff --git a/apps/web/src/features/todo/model/index.ts b/apps/web/src/features/todo/model/index.ts index 682c8bde..b832cf43 100644 --- a/apps/web/src/features/todo/model/index.ts +++ b/apps/web/src/features/todo/model/index.ts @@ -1,7 +1,25 @@ export { END_POINT } from './end-point'; +export { insertItem, removeItem, updateItem } from './todo-cache'; +export { + daysToDeadline, + DUE_DAYS_MIN, + TITLE_MAX_LENGTH, + type TodoDraft, + type TodoDraftError, + validateTodoDraft, +} from './todo-draft'; export type { + DeleteTodoRequest, PatchToggleCheckboxRequest, PatchToggleCHeckboxResponse, + PatchUpdateTodoBody, + PatchUpdateTodoRequest, + PatchUpdateTodoResponse, PostAddTodoItemRequest, PostAddTodoItemResponse, + PostCreateTodoBody, + PostCreateTodoRequest, + PostCreateTodoResponse, + TodoActionsType, + TodoItemResponseData, } from './types'; diff --git a/apps/web/src/features/todo/model/todo-cache.ts b/apps/web/src/features/todo/model/todo-cache.ts new file mode 100644 index 00000000..d42f5a3d --- /dev/null +++ b/apps/web/src/features/todo/model/todo-cache.ts @@ -0,0 +1,64 @@ +import type { ActionItem, ActionItemList } from '@entities/todo'; + +import type { TodoActionsType } from './types'; + +const mergeItem = (current: ActionItem, next: ActionItem): ActionItem => ({ + actionItemId: next.actionItemId ?? current.actionItemId, + title: next.title ?? current.title, + deadline: next.deadline ?? current.deadline, + completed: next.completed ?? current.completed, +}); + +const replaceInItems = ( + items: ActionItem[] | undefined, + next: ActionItem, +): ActionItem[] | undefined => + items?.map((item) => + item.actionItemId === next.actionItemId ? mergeItem(item, next) : item, + ); + +export const insertItem = ( + list: ActionItemList, + item: ActionItem, + actionsType: TodoActionsType, +): ActionItemList => { + if (actionsType === 'VISA') { + return { + ...list, + visaActionItems: [...(list.visaActionItems ?? []), item], + }; + } + + return { + ...list, + careerActionItems: [...(list.careerActionItems ?? []), item], + }; +}; + +export const updateItem = ( + list: ActionItemList, + item: ActionItem, +): ActionItemList => { + if (item.actionItemId === undefined) { + return list; + } + + return { + ...list, + visaActionItems: replaceInItems(list.visaActionItems, item), + careerActionItems: replaceInItems(list.careerActionItems, item), + }; +}; + +export const removeItem = ( + list: ActionItemList, + actionItemId: number, +): ActionItemList => ({ + ...list, + visaActionItems: list.visaActionItems?.filter( + (item) => item.actionItemId !== actionItemId, + ), + careerActionItems: list.careerActionItems?.filter( + (item) => item.actionItemId !== actionItemId, + ), +}); diff --git a/apps/web/src/features/todo/model/todo-draft.ts b/apps/web/src/features/todo/model/todo-draft.ts new file mode 100644 index 00000000..6dfa6071 --- /dev/null +++ b/apps/web/src/features/todo/model/todo-draft.ts @@ -0,0 +1,50 @@ +export type TodoDraft = { + title: string; + dueInDays: number | null; +}; + +export type TodoDraftError = + | 'TITLE_REQUIRED' + | 'TITLE_TOO_LONG' + | 'DAYS_REQUIRED' + | 'DAYS_INVALID'; + +export const TITLE_MAX_LENGTH = 255; +export const DUE_DAYS_MIN = 1; + +export const validateTodoDraft = (draft: TodoDraft): TodoDraftError | null => { + const title = draft.title.trim(); + + if (title.length === 0) { + return 'TITLE_REQUIRED'; + } + + if (title.length > TITLE_MAX_LENGTH) { + return 'TITLE_TOO_LONG'; + } + + if (draft.dueInDays === null) { + return 'DAYS_REQUIRED'; + } + + if (!Number.isInteger(draft.dueInDays) || draft.dueInDays < DUE_DAYS_MIN) { + return 'DAYS_INVALID'; + } + + return null; +}; + +export const daysToDeadline = (days: number): string => { + const now = new Date(); + const deadline = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() + days, + ); + + const year = String(deadline.getFullYear()).padStart(4, '0'); + const month = String(deadline.getMonth() + 1).padStart(2, '0'); + const date = String(deadline.getDate()).padStart(2, '0'); + + return `${year}-${month}-${date}`; +}; diff --git a/apps/web/src/features/todo/model/types.ts b/apps/web/src/features/todo/model/types.ts index 4cab9b63..09091276 100644 --- a/apps/web/src/features/todo/model/types.ts +++ b/apps/web/src/features/todo/model/types.ts @@ -1,13 +1,46 @@ +import type { ActionItem } from '@entities/todo'; import { paths } from '@shared/types/schema'; export type PostAddTodoItemRequest = - paths['/api/v1/phase-actions/{phaseActionId}/todo']['post']['parameters']['path']['phaseActionId']; + paths['/api/v1/roadmap/phase-actions/{phaseActionId}/todo']['post']['parameters']['path']['phaseActionId']; export type PostAddTodoItemResponse = - paths['/api/v1/phase-actions/{phaseActionId}/todo']['post']['responses'][200]['content']['*/*']; + paths['/api/v1/roadmap/phase-actions/{phaseActionId}/todo']['post']['responses'][200]['content']['*/*']; export type PatchToggleCheckboxRequest = - paths['/api/v1/action-items/{actionItemId}/completed']['patch']['parameters']['path']['actionItemId']; + paths['/api/v1/roadmap/action-items/{actionItemId}/completed']['patch']['parameters']['path']['actionItemId']; export type PatchToggleCHeckboxResponse = - paths['/api/v1/action-items/{actionItemId}/completed']['patch']['responses']['200']['content']['*/*']; + paths['/api/v1/roadmap/action-items/{actionItemId}/completed']['patch']['responses']['200']['content']['*/*']; + +export type TodoActionsType = 'VISA' | 'CAREER'; + +export type PostCreateTodoBody = + paths['/api/v1/roadmap/action-items']['post']['requestBody']['content']['application/json']; + +export type PostCreateTodoRequest = { + title: string; + deadline: string; + actionsType: TodoActionsType; +}; + +export type PostCreateTodoResponse = + paths['/api/v1/roadmap/action-items']['post']['responses']['200']['content']['*/*']; + +export type PatchUpdateTodoBody = + paths['/api/v1/roadmap/action-items/{actionItemId}']['patch']['requestBody']['content']['application/json']; + +export type PatchUpdateTodoRequest = { + actionItemId: number; + title: string; + deadline: string; +}; + +export type PatchUpdateTodoResponse = + paths['/api/v1/roadmap/action-items/{actionItemId}']['patch']['responses']['200']['content']['*/*']; + +export type DeleteTodoRequest = { + actionItemId: paths['/api/v1/roadmap/action-items/{actionItemId}']['delete']['parameters']['path']['actionItemId']; +}; + +export type TodoItemResponseData = ActionItem; diff --git a/apps/web/src/features/todo/queries/queires.ts b/apps/web/src/features/todo/queries/queires.ts index c56b2b49..79c97310 100644 --- a/apps/web/src/features/todo/queries/queires.ts +++ b/apps/web/src/features/todo/queries/queires.ts @@ -1,6 +1,12 @@ import { mutationOptions } from '@tanstack/react-query'; -import { addTodoItem, toggleCheckbox } from '@features/todo/api'; +import { + addTodoItem, + createTodoItem, + deleteTodoItem, + toggleCheckbox, + updateTodoItem, +} from '@features/todo/api'; export const TODO_MUTATION_OPTIONS = { POST_TODO: () => { @@ -14,4 +20,22 @@ export const TODO_MUTATION_OPTIONS = { mutationFn: toggleCheckbox, }); }, + + POST_CREATE_TODO: () => { + return mutationOptions({ + mutationFn: createTodoItem, + }); + }, + + PATCH_UPDATE_TODO: () => { + return mutationOptions({ + mutationFn: updateTodoItem, + }); + }, + + DELETE_TODO: () => { + return mutationOptions({ + mutationFn: deleteTodoItem, + }); + }, }; diff --git a/apps/web/src/features/todo/ui/index.ts b/apps/web/src/features/todo/ui/index.ts index 094ca3e1..0faaa9ae 100644 --- a/apps/web/src/features/todo/ui/index.ts +++ b/apps/web/src/features/todo/ui/index.ts @@ -1 +1,7 @@ export { default as ActionTodoButton } from './action-todo-button/action-todo-button'; +export { default as TodoItemForm } from './todo-item-form/todo-item-form'; +export { + type TodoItemHandlers, + default as TodoItemList, +} from './todo-item-list/todo-item-list'; +export { default as TodoItemMenu } from './todo-item-menu/todo-item-menu'; diff --git a/apps/web/src/features/todo/ui/todo-item-form/todo-item-form.css.ts b/apps/web/src/features/todo/ui/todo-item-form/todo-item-form.css.ts new file mode 100644 index 00000000..8a577c14 --- /dev/null +++ b/apps/web/src/features/todo/ui/todo-item-form/todo-item-form.css.ts @@ -0,0 +1,110 @@ +import { themeVars, typography } from '@kds/ui/styles'; +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +export const container = recipe({ + base: { + display: 'flex', + alignItems: 'center', + padding: '1.2rem 1.6rem', + gap: '0.8rem', + borderRadius: '10px', + backgroundColor: themeVars.color.grayscale.gray100, + }, + variants: { + hasError: { + true: { + boxShadow: `inset 0 0 0 2px ${themeVars.color.pastel.kared_500}`, + }, + false: { + boxShadow: `inset 0 0 0 2px ${themeVars.color.primary[500]}`, + }, + }, + }, + defaultVariants: { + hasError: false, + }, +}); + +export const checkboxPlaceholder = style({ + display: 'flex', + flexShrink: 0, + alignItems: 'center', +}); + +export const content = style({ + display: 'flex', + flexDirection: 'column', + gap: '0.2rem', + flex: 1, + minWidth: 0, +}); + +export const titleInput = style({ + ...typography.body8_m_14, + + display: 'block', + width: '100%', + outline: 'none', + backgroundColor: 'transparent', + color: themeVars.color.grayscale.gray800, + resize: 'none', + overflow: 'hidden', + wordBreak: 'break-word', + + selectors: { + '&::placeholder': { + color: themeVars.color.grayscale.gray400, + }, + }, +}); + +export const dueRow = style({ + ...typography.cap3_r_12, + color: themeVars.color.grayscale.gray500, +}); + +export const daysInput = recipe({ + base: { + ...typography.cap3_r_12, + + display: 'inline-block', + verticalAlign: 'middle', + width: 'auto', + minWidth: '2.4rem', + padding: '0.2rem 0.8rem', + margin: '0 0.2rem', + + borderWidth: '1px', + borderStyle: 'solid', + borderRadius: '8px', + outline: 'none', + backgroundColor: 'transparent', + color: themeVars.color.grayscale.gray900, + textAlign: 'center', + + selectors: { + '&::placeholder': { + color: themeVars.color.grayscale.gray500, + }, + }, + }, + variants: { + hasError: { + true: { + borderColor: themeVars.color.pastel.kared_500, + }, + false: { + borderColor: themeVars.color.grayscale.gray400, + }, + }, + }, + defaultVariants: { + hasError: false, + }, +}); + +export const errorMessage = style({ + ...typography.cap3_r_12, + color: themeVars.color.pastel.kared_500, +}); diff --git a/apps/web/src/features/todo/ui/todo-item-form/todo-item-form.tsx b/apps/web/src/features/todo/ui/todo-item-form/todo-item-form.tsx new file mode 100644 index 00000000..b209f371 --- /dev/null +++ b/apps/web/src/features/todo/ui/todo-item-form/todo-item-form.tsx @@ -0,0 +1,182 @@ +import { + type KeyboardEvent, + useCallback, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { TodoIcon } from '@kds/icons'; +import { Trans, useTranslation } from 'react-i18next'; + +import { + DUE_DAYS_MIN, + TITLE_MAX_LENGTH, + type TodoDraft, + type TodoDraftError, + validateTodoDraft, +} from '@features/todo/model'; + +import * as styles from './todo-item-form.css'; + +const ERROR_MESSAGES: Record< + TodoDraftError, + { key: string; options?: Record } +> = { + TITLE_REQUIRED: { key: 'form.error.titleRequired' }, + TITLE_TOO_LONG: { + key: 'form.error.titleTooLong', + options: { max: TITLE_MAX_LENGTH }, + }, + DAYS_REQUIRED: { key: 'form.error.daysRequired' }, + DAYS_INVALID: { + key: 'form.error.daysInvalid', + options: { min: DUE_DAYS_MIN }, + }, +}; + +const DAYS_INPUT_MAX_LENGTH = 4; + +const useAutoResize = (value: string) => { + const ref = useRef(null); + + const resize = useCallback(() => { + const textarea = ref.current; + + if (!textarea) { + return; + } + + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; + }, []); + + useLayoutEffect(resize, [resize, value]); + + return ref; +}; + +interface TodoItemFormProps { + initialDraft: TodoDraft; + onSubmit: (draft: TodoDraft) => void; + onCancel: () => void; +} + +const TodoItemForm = ({ + initialDraft, + onSubmit, + onCancel, +}: TodoItemFormProps) => { + const { t } = useTranslation('todo'); + const [title, setTitle] = useState(initialDraft.title); + const titleRef = useAutoResize(title); + const [days, setDays] = useState( + initialDraft.dueInDays === null ? '' : String(initialDraft.dueInDays), + ); + const [errorKey, setErrorKey] = useState(null); + + const toDraft = (): TodoDraft => ({ + title, + dueInDays: days.trim() === '' ? null : Number(days), + }); + + const handleDaysChange = (value: string) => { + setDays(value.replace(/[^0-9]/g, '')); + }; + + const handleSubmit = () => { + const draft = toDraft(); + const nextErrorKey = validateTodoDraft(draft); + + setErrorKey(nextErrorKey); + + if (nextErrorKey !== null) { + return; + } + + onSubmit(draft); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + if (event.nativeEvent.isComposing) { + return; + } + + event.preventDefault(); + handleSubmit(); + } + + if (event.key === 'Escape') { + onCancel(); + } + }; + + const errorMessage = errorKey + ? t(ERROR_MESSAGES[errorKey].key, ERROR_MESSAGES[errorKey].options) + : null; + + return ( +
  • + + + +
    +