feat: Footer/TopBar 라우팅 및 모든 페이지 네비게이션 핸들러 개선, Suspense 오류 대응, sheetEasyAI 로고 클릭 시 랜딩 이동 기능 구현

This commit is contained in:
sheetEasy AI Team
2025-07-02 16:36:46 +09:00
parent 2f3515985d
commit fede2eda26
22 changed files with 4735 additions and 192 deletions

587
package-lock.json generated
View File

@@ -9,6 +9,10 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@univerjs/core": "^0.8.2",
"@univerjs/design": "^0.8.2",
"@univerjs/docs": "^0.8.2",
@@ -29,11 +33,15 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"file-saver": "^2.0.5",
"i18next": "^25.3.0",
"i18next-browser-languagedetector": "^8.2.0",
"lucide-react": "^0.468.0",
"luckyexcel": "^1.0.1",
"luckysheet": "^2.1.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.5.3",
"recharts": "^3.0.2",
"tailwind-merge": "^2.5.4",
"zustand": "^5.0.2"
},
@@ -1682,12 +1690,46 @@
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz",
"integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@@ -2143,6 +2185,30 @@
}
}
},
"node_modules/@radix-ui/react-progress": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
@@ -2174,6 +2240,49 @@
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",
"integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-focus-guards": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.7",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
@@ -2334,6 +2443,21 @@
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
@@ -2438,6 +2562,47 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
"integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit/node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.11",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",
@@ -2752,6 +2917,18 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@@ -2930,6 +3107,69 @@
"@types/deep-eql": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -3134,6 +3374,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/yargs": {
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
@@ -7831,6 +8077,127 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
@@ -7876,6 +8243,12 @@
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
"license": "MIT"
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -8125,6 +8498,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.39.5",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.5.tgz",
"integrity": "sha512-z9V0qU4lx1TBXDNFWfAASWk6RNU6c6+TJBKE+FLIg8u0XJ6Yw58Hi0yX8ftEouj6p1QARRlXLFfHbIli93BdQQ==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
@@ -8425,6 +8808,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/expect": {
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/expect/-/expect-30.0.1.tgz",
@@ -8995,6 +9384,15 @@
"node": ">=12"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/http-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
@@ -9024,6 +9422,46 @@
"node": ">= 6"
}
},
"node_modules/i18next": {
"version": "25.3.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.0.tgz",
"integrity": "sha512-ZSQIiNGfqSG6yoLHaCvrkPp16UejHI8PCDxFYaNG/1qxtmqNmqEg4JlWKlxkrUmrin2sEjsy+Mjy1TRozBhOgw==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
"integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -9073,6 +9511,16 @@
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immer": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -9127,6 +9575,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -11459,6 +11916,32 @@
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-i18next": {
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.3.tgz",
"integrity": "sha512-ypYmOKOnjqPEJZO4m1BI0kS8kWqkBNsKYyhVUfij0gvjy9xJNoG/VcGkxq5dRlVwzmrmY1BQMAmpbbUBLwC4Kw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -11666,6 +12149,64 @@
"node": ">=8.10.0"
}
},
"node_modules/recharts": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.0.2.tgz",
"integrity": "sha512-eDc3ile9qJU9Dp/EekSthQPhAVPG48/uM47jk+PF7VBQngxeW3cwQpPHb/GHC1uqwyCRWXcIrDzuHRVrnRryoQ==",
"license": "MIT",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts/node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/recharts/node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -11715,6 +12256,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -12835,6 +13382,15 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -12850,6 +13406,28 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
@@ -13062,6 +13640,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vue": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.17.tgz",

View File

@@ -18,6 +18,10 @@
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@univerjs/core": "^0.8.2",
"@univerjs/design": "^0.8.2",
"@univerjs/docs": "^0.8.2",
@@ -38,11 +42,15 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"file-saver": "^2.0.5",
"i18next": "^25.3.0",
"i18next-browser-languagedetector": "^8.2.0",
"lucide-react": "^0.468.0",
"luckyexcel": "^1.0.1",
"luckysheet": "^2.1.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.5.3",
"recharts": "^3.0.2",
"tailwind-merge": "^2.5.4",
"zustand": "^5.0.2"
},

View File

@@ -1,12 +1,16 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Button } from "./components/ui/button";
import { TopBar } from "./components/ui/topbar";
import { lazy, Suspense } from "react";
import LandingPage from "./components/LandingPage";
import { SignUpPage } from "./components/auth/SignUpPage";
import { SignInPage } from "./components/auth/SignInPage";
import AccountPage from "./components/AccountPage";
import LicensePage from "./components/LicensePage";
import { useAppStore } from "./stores/useAppStore";
import { I18nProvider } from "./lib/i18n";
import type { TutorialItem } from "./types/tutorial";
import { startTransition } from "react";
// TutorialSheetViewer 동적 import
const TutorialSheetViewer = lazy(
@@ -18,6 +22,16 @@ const EditSheetViewer = lazy(
() => import("./components/sheet/EditSheetViewer"),
);
// 새로운 페이지들 동적 import
const RoadmapPage = lazy(() => import("./components/RoadmapPage"));
const UpdatesPage = lazy(() => import("./components/UpdatesPage"));
const SupportPage = lazy(() => import("./components/SupportPage"));
const ContactPage = lazy(() => import("./components/ContactPage"));
const PrivacyPolicyPage = lazy(() => import("./components/PrivacyPolicyPage"));
const TermsOfServicePage = lazy(
() => import("./components/TermsOfServicePage"),
);
// 앱 상태 타입 정의
type AppView =
| "landing"
@@ -25,7 +39,14 @@ type AppView =
| "signIn"
| "editor"
| "account"
| "tutorial";
| "tutorial"
| "license"
| "roadmap"
| "updates"
| "support"
| "contact"
| "privacy-policy"
| "terms-of-service";
function App() {
const [currentView, setCurrentView] = useState<AppView>("landing");
@@ -36,8 +57,27 @@ function App() {
user,
currentFile,
startTutorial,
setLanguage,
setHistoryPanelPosition,
} = useAppStore();
// 초기화: localStorage에서 설정 로드
useEffect(() => {
// 언어 설정 로드
const savedLanguage = localStorage.getItem("sheeteasy-language");
if (savedLanguage === "ko" || savedLanguage === "en") {
setLanguage(savedLanguage);
}
// 히스토리 패널 위치 설정 로드
const savedPosition = localStorage.getItem(
"sheeteasy-history-panel-position",
);
if (savedPosition === "left" || savedPosition === "right") {
setHistoryPanelPosition(savedPosition);
}
}, [setLanguage, setHistoryPanelPosition]);
// CTA 버튼 클릭 핸들러 - 인증 상태에 따른 분기 처리
const handleGetStarted = () => {
if (isAuthenticated) {
@@ -134,6 +174,45 @@ function App() {
}, 100);
};
// Footer 핸들러들 - 새로운 페이지 네비게이션
const handleRoadmapClick = () => {
startTransition(() => {
setCurrentView("roadmap");
});
};
const handleUpdatesClick = () => {
startTransition(() => {
setCurrentView("updates");
});
};
const handleSupportClick = () => {
if (isAuthenticated) {
startTransition(() => {
setCurrentView("support");
});
} else {
alert("지원 서비스는 로그인 후 이용 가능합니다.");
startTransition(() => {
setCurrentView("signIn");
});
}
};
const handleContactClick = () => {
startTransition(() => {
setCurrentView("contact");
});
};
const handlePrivacyPolicyClick = () => {
startTransition(() => {
setCurrentView("privacy-policy");
});
};
const handleTermsOfServiceClick = () => {
startTransition(() => {
setCurrentView("terms-of-service");
});
};
// 튜토리얼 선택 핸들러 - 튜토리얼 시작 후 튜토리얼 페이지로 전환
const handleTutorialSelect = (tutorial: TutorialItem) => {
console.log("🎯 튜토리얼 선택됨:", tutorial.metadata.title);
@@ -225,6 +304,11 @@ function App() {
const handleGoToSignIn = () => setCurrentView("signIn");
const handleGoToSignUp = () => setCurrentView("signUp");
const handleGoToEditor = () => setCurrentView("editor");
const handleGoToLicense = () => {
startTransition(() => {
setCurrentView("license");
});
};
// 에디터에서 홈으로 돌아가기 핸들러 (워닝 포함)
const handleEditorLogoClick = () => {
@@ -290,110 +374,27 @@ function App() {
showAuthButtons={false}
onAccountClick={handleAccountClick}
/>
<main className="container mx-auto py-8">
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-6">
<h1 className="text-2xl font-bold mb-6"> </h1>
<main>
<AccountPage
onGoToEditor={handleGoToEditor}
onLogout={handleLogout}
/>
</main>
</div>
);
{/* 사용자 정보 */}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3"> </h2>
<div className="space-y-2">
<p>
<strong>:</strong> {user?.email}
</p>
<p>
<strong>:</strong> {user?.name}
</p>
<p>
<strong>:</strong>{" "}
{user?.createdAt?.toLocaleDateString()}
</p>
<p>
<strong> :</strong>{" "}
{user?.lastLoginAt?.toLocaleDateString()}
</p>
</div>
</div>
{/* 구독 정보 */}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3"> </h2>
<div className="space-y-2">
<p>
<strong>:</strong>{" "}
{user?.subscription?.plan?.toUpperCase()}
</p>
<p>
<strong>:</strong> {user?.subscription?.status}
</p>
<p>
<strong> :</strong>{" "}
{user?.subscription?.currentPeriodEnd?.toLocaleDateString()}
</p>
</div>
</div>
{/* 사용량 */}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3"></h2>
<div className="space-y-2">
<p>
<strong>AI :</strong>{" "}
{user?.subscription?.usage?.aiQueries} /{" "}
{user?.subscription?.plan === "free"
? "30"
: user?.subscription?.plan === "lite"
? "100"
: "500"}
</p>
<p>
<strong> :</strong>{" "}
{user?.subscription?.usage?.cellCount} /{" "}
{user?.subscription?.plan === "free"
? "300"
: user?.subscription?.plan === "lite"
? "1000"
: "5000"}
</p>
</div>
</div>
{/* 설정 */}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3"></h2>
<div className="space-y-2">
<p>
<strong>:</strong>{" "}
{user?.preferences?.language === "ko"
? "한국어"
: "English"}
</p>
<p>
<strong> :</strong>{" "}
{user?.preferences?.historyPanelPosition === "right"
? "우측"
: "좌측"}
</p>
</div>
</div>
{/* 액션 버튼들 */}
<div className="flex space-x-4">
<Button
onClick={handleGoToEditor}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
</Button>
<Button
onClick={handleLogout}
variant="outline"
className="border-red-500 text-red-500 hover:bg-red-50"
>
</Button>
</div>
</div>
case "license":
return (
<div className="min-h-screen bg-gray-50">
<TopBar
showDownload={false}
showAccount={false}
showNavigation={false}
showAuthButtons={false}
onAccountClick={handleAccountClick}
/>
<main>
<LicensePage onBack={handleBackToLanding} />
</main>
</div>
);
@@ -464,6 +465,108 @@ function App() {
</div>
);
case "roadmap":
return (
<div className="min-h-screen bg-gray-50">
<TopBar
showDownload={false}
showAccount={false}
showNavigation={false}
showAuthButtons={false}
onAccountClick={handleAccountClick}
onLogoClick={handleBackToLanding}
/>
<main>
<RoadmapPage />
</main>
</div>
);
case "updates":
return (
<div className="min-h-screen bg-gray-50">
<TopBar
showDownload={false}
showAccount={false}
showNavigation={false}
showAuthButtons={false}
onAccountClick={handleAccountClick}
onLogoClick={handleBackToLanding}
/>
<main>
<UpdatesPage />
</main>
</div>
);
case "support":
return (
<div className="min-h-screen bg-gray-50">
<TopBar
showDownload={false}
showAccount={false}
showNavigation={false}
showAuthButtons={false}
onAccountClick={handleAccountClick}
onLogoClick={handleBackToLanding}
/>
<main>
<SupportPage />
</main>
</div>
);
case "contact":
return (
<div className="min-h-screen bg-gray-50">
<TopBar
showDownload={false}
showAccount={false}
showNavigation={false}
showAuthButtons={false}
onAccountClick={handleAccountClick}
onLogoClick={handleBackToLanding}
/>
<main>
<ContactPage />
</main>
</div>
);
case "privacy-policy":
return (
<div className="min-h-screen bg-gray-50">
<TopBar
showDownload={false}
showAccount={false}
showNavigation={false}
showAuthButtons={false}
onAccountClick={handleAccountClick}
onLogoClick={handleBackToLanding}
/>
<main>
<PrivacyPolicyPage />
</main>
</div>
);
case "terms-of-service":
return (
<div className="min-h-screen bg-gray-50">
<TopBar
showDownload={false}
showAccount={false}
showNavigation={false}
showAuthButtons={false}
onAccountClick={handleAccountClick}
onLogoClick={handleBackToLanding}
/>
<main>
<TermsOfServicePage />
</main>
</div>
);
case "landing":
default:
return (
@@ -473,6 +576,7 @@ function App() {
showAccount={false}
showNavigation={true}
showAuthButtons={true}
showTestAccount={true}
onSignInClick={handleGoToSignIn}
onGetStartedClick={handleGetStarted}
onAccountClick={handleAccountClick}
@@ -481,6 +585,7 @@ function App() {
onFeaturesClick={handleFeaturesClick}
onFAQClick={handleFAQClick}
onPricingClick={handlePricingClick}
onLogoClick={handleBackToLanding}
/>
<LandingPage
onGetStarted={handleGetStarted}
@@ -488,13 +593,24 @@ function App() {
onAccountClick={handleAccountClick}
onDemoClick={handleDemoClick}
onTutorialSelect={handleTutorialSelect}
onLicenseClick={handleGoToLicense}
onRoadmapClick={handleRoadmapClick}
onUpdatesClick={handleUpdatesClick}
onSupportClick={handleSupportClick}
onContactClick={handleContactClick}
onPrivacyPolicyClick={handlePrivacyPolicyClick}
onTermsOfServiceClick={handleTermsOfServiceClick}
/>
</div>
);
}
};
return <div className="min-h-screen">{renderCurrentView()}</div>;
return (
<I18nProvider>
<div className="min-h-screen">{renderCurrentView()}</div>
</I18nProvider>
);
}
export default App;

View File

@@ -0,0 +1,552 @@
import * as React from "react";
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { Button } from "./ui/button";
import { Badge } from "./ui/badge";
import { Progress } from "./ui/progress";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "./ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { useAppStore } from "../stores/useAppStore";
import { useTranslation, formatDate } from "../lib/i18n.tsx";
import type { User } from "../types/user";
import type { Language } from "../lib/i18n.tsx";
interface AccountPageProps {
onGoToEditor?: () => void;
onLogout?: () => void;
}
/**
* 현대적인 shadcn UI를 사용한 Account 페이지 컴포넌트
* - 구독 플랜 관리 (Card, Dialog, AlertDialog)
* - 사용량 분석 차트 (recharts)
* - 사용량 한계 경고 (Badge, Progress)
* - 사용자 설정
*/
const AccountPage: React.FC<AccountPageProps> = ({
onGoToEditor,
onLogout,
}) => {
const {
user,
language,
historyPanelPosition,
setLanguage,
setHistoryPanelPosition,
} = useAppStore();
const { t } = useTranslation();
const [changePlanOpen, setChangePlanOpen] = useState(false);
const [cancelSubOpen, setCancelSubOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
// 플랜별 한계값 계산
const getPlanLimits = (plan: string) => {
switch (plan) {
case "free":
return { aiQueries: 30, cellCount: 300 };
case "lite":
return { aiQueries: 100, cellCount: 1000 };
case "pro":
return { aiQueries: 500, cellCount: 5000 };
default:
return { aiQueries: 0, cellCount: 0 };
}
};
// 플랜 상태 배지 색상
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case "active":
return "default";
case "trial":
return "secondary";
case "canceled":
return "destructive";
default:
return "outline";
}
};
// 사용량 경고 여부 확인
const isUsageWarning = (used: number, limit: number) => {
return used / limit >= 0.8; // 80% 이상 사용 시 경고
};
// 사용량 진행률 계산
const getUsagePercentage = (used: number, limit: number) => {
return Math.min((used / limit) * 100, 100);
};
// 확장된 사용량 데이터 (최근 30일)
const generateUsageData = () => {
const data = [];
const today = new Date();
for (let i = 29; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
data.push({
date: formatDate(date, language),
aiQueries: Math.floor(Math.random() * 10) + 1,
cellCount: Math.floor(Math.random() * 50) + 10,
promptCount: Math.floor(Math.random() * 8) + 1,
editedCells: Math.floor(Math.random() * 40) + 5,
});
}
return data;
};
const usageData = generateUsageData();
const limits = getPlanLimits(user?.subscription?.plan || "free");
if (!user) {
return (
<div className="container mx-auto py-8">
<div className="text-center">
<p className="text-gray-600">{t.common.loading}</p>
</div>
</div>
);
}
return (
<div className="container mx-auto py-8 space-y-6">
{/* 페이지 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{t.account.title}
</h1>
<p className="text-muted-foreground">{t.account.subtitle}</p>
</div>
<div className="flex space-x-2">
<Button onClick={onGoToEditor} variant="outline">
{t.account.goToEditor}
</Button>
<Button onClick={onLogout} variant="destructive">
{t.account.logout}
</Button>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* 사용자 정보 카드 */}
<Card>
<CardHeader>
<CardTitle>{t.account.userInfo}</CardTitle>
<CardDescription>{t.account.userInfo}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div>
<p className="text-sm font-medium">{t.account.email}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
<div>
<p className="text-sm font-medium">{t.account.name}</p>
<p className="text-sm text-muted-foreground">{user.name}</p>
</div>
<div>
<p className="text-sm font-medium">{t.account.joinDate}</p>
<p className="text-sm text-muted-foreground">
{user.createdAt && formatDate(user.createdAt, language)}
</p>
</div>
<div>
<p className="text-sm font-medium">{t.account.lastLogin}</p>
<p className="text-sm text-muted-foreground">
{user.lastLoginAt && formatDate(user.lastLoginAt, language)}
</p>
</div>
</CardContent>
</Card>
{/* 구독 플랜 카드 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<Badge
variant={getStatusBadgeVariant(user.subscription?.status || "")}
>
{user.subscription?.status?.toUpperCase()}
</Badge>
</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-2xl font-bold">
{user.subscription?.plan?.toUpperCase()}
</p>
<p className="text-sm text-muted-foreground">
:{" "}
{user.subscription?.currentPeriodEnd?.toLocaleDateString(
"ko-KR",
)}
</p>
</div>
<div className="flex space-x-2">
<Dialog open={changePlanOpen} onOpenChange={setChangePlanOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-3 gap-4">
<Card className="cursor-pointer hover:bg-accent">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Free</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">0</p>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:bg-accent">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Lite</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">5,900</p>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:bg-accent">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Pro</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">14,900</p>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setChangePlanOpen(false)}
>
</Button>
<Button onClick={() => setChangePlanOpen(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={cancelSubOpen} onOpenChange={setCancelSubOpen}>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
?
</AlertDialogTitle>
<AlertDialogDescription>
. Free
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
{/* 사용량 요약 카드 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* AI 쿼리 사용량 */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium">AI </p>
{isUsageWarning(
user.subscription?.usage?.aiQueries || 0,
limits.aiQueries,
) && (
<Badge variant="destructive" className="text-xs">
</Badge>
)}
</div>
<Progress
value={getUsagePercentage(
user.subscription?.usage?.aiQueries || 0,
limits.aiQueries,
)}
className={
isUsageWarning(
user.subscription?.usage?.aiQueries || 0,
limits.aiQueries,
)
? "bg-red-100"
: ""
}
/>
<p className="text-xs text-muted-foreground mt-1">
{user.subscription?.usage?.aiQueries || 0} / {limits.aiQueries}{" "}
</p>
</div>
{/* 셀 카운트 사용량 */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium"> </p>
{isUsageWarning(
user.subscription?.usage?.cellCount || 0,
limits.cellCount,
) && (
<Badge variant="destructive" className="text-xs">
</Badge>
)}
</div>
<Progress
value={getUsagePercentage(
user.subscription?.usage?.cellCount || 0,
limits.cellCount,
)}
className={
isUsageWarning(
user.subscription?.usage?.cellCount || 0,
limits.cellCount,
)
? "bg-red-100"
: ""
}
/>
<p className="text-xs text-muted-foreground mt-1">
{user.subscription?.usage?.cellCount || 0} / {limits.cellCount}{" "}
</p>
</div>
</CardContent>
</Card>
</div>
{/* 사용량 분석 차트 */}
<Card className="col-span-full">
<CardHeader>
<CardTitle>{t.account.usageAnalytics}</CardTitle>
<CardDescription>
{t.account.usageAnalyticsDescription}
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={usageData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip
formatter={(value, name) => [
value,
t.account.tooltipLabels[
name as keyof typeof t.account.tooltipLabels
] || name,
]}
labelFormatter={(label) => `${t.account.date}: ${label}`}
/>
<Line
type="monotone"
dataKey="aiQueries"
stroke="#8884d8"
strokeWidth={2}
name="aiQueries"
/>
<Line
type="monotone"
dataKey="cellCount"
stroke="#82ca9d"
strokeWidth={2}
name="cellCount"
/>
<Line
type="monotone"
dataKey="promptCount"
stroke="#ff7300"
strokeWidth={2}
name="promptCount"
/>
<Line
type="monotone"
dataKey="editedCells"
stroke="#00bcd4"
strokeWidth={2}
name="editedCells"
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* 설정 카드 */}
<Card>
<CardHeader>
<CardTitle>{t.account.settings}</CardTitle>
<CardDescription>{t.account.settings}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium mb-2">{t.account.language}</p>
<Badge variant="outline">
{language === "ko" ? t.account.korean : t.account.english}
</Badge>
</div>
<div>
<p className="text-sm font-medium mb-2">
{t.account.historyPanelPosition}
</p>
<Badge variant="outline">
{historyPanelPosition === "right"
? t.account.right
: t.account.left}
</Badge>
</div>
</div>
<div className="pt-4">
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
{t.account.changeSettings}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t.account.changeSettings}</DialogTitle>
<DialogDescription>{t.account.settings}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<label htmlFor="language" className="text-right">
{t.account.language}
</label>
<div className="col-span-3">
<Select
value={language}
onValueChange={(value: Language) => setLanguage(value)}
>
<SelectTrigger>
<SelectValue placeholder={t.account.language} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ko">{t.account.korean}</SelectItem>
<SelectItem value="en">
{t.account.english}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<label htmlFor="panel-position" className="text-right">
{t.account.historyPanelPosition}
</label>
<div className="col-span-3">
<Select
value={historyPanelPosition}
onValueChange={(value: "left" | "right") =>
setHistoryPanelPosition(value)
}
>
<SelectTrigger>
<SelectValue
placeholder={t.account.historyPanelPosition}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="left">{t.account.left}</SelectItem>
<SelectItem value="right">
{t.account.right}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSettingsOpen(false)}
>
{t.common.cancel}
</Button>
<Button onClick={() => setSettingsOpen(false)}>
{t.common.save}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
</div>
);
};
export default AccountPage;

View File

@@ -0,0 +1,417 @@
import * as React from "react";
import { useState } from "react";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { cn } from "../lib/utils";
interface ContactPageProps {
className?: string;
onBack?: () => void;
}
const ContactPage = React.forwardRef<HTMLDivElement, ContactPageProps>(
({ className, onBack, ...props }, ref) => {
const [formData, setFormData] = useState({
name: "",
email: "",
company: "",
subject: "",
message: "",
contactReason: "",
});
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const contactReasons = [
{ value: "sales", label: "영업 문의", icon: "💼" },
{ value: "partnership", label: "파트너십", icon: "🤝" },
{ value: "feedback", label: "피드백", icon: "💭" },
{ value: "media", label: "미디어 문의", icon: "📰" },
{ value: "general", label: "일반 문의", icon: "💬" },
{ value: "other", label: "기타", icon: "❓" },
];
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const emailData = {
to: "contact@sheeteasy.ai",
from: formData.email,
subject: `[${formData.contactReason.toUpperCase()}] ${formData.subject}`,
html: `
<h2>문의사항</h2>
<p><strong>이름:</strong> ${formData.name}</p>
<p><strong>이메일:</strong> ${formData.email}</p>
<p><strong>회사:</strong> ${formData.company || "개인"}</p>
<p><strong>문의 유형:</strong> ${contactReasons.find((r) => r.value === formData.contactReason)?.label}</p>
<p><strong>제목:</strong> ${formData.subject}</p>
<hr>
<p><strong>내용:</strong></p>
<p>${formData.message.replace(/\n/g, "<br>")}</p>
`,
};
// TODO: 실제 이메일 서비스 연동
console.log("문의사항 전송:", emailData);
setTimeout(() => {
alert(
"문의사항이 성공적으로 전송되었습니다. 빠른 시일 내에 답변드리겠습니다.",
);
setFormData({
name: "",
email: "",
company: "",
subject: "",
message: "",
contactReason: "",
});
setIsSubmitting(false);
}, 1000);
} catch (error) {
alert("문의사항 전송에 실패했습니다. 다시 시도해주세요.");
setIsSubmitting(false);
}
};
return (
<div
ref={ref}
className={cn(
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50",
className,
)}
{...props}
>
<div className="container py-12">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-4xl font-bold text-gray-900 mb-2">
</h1>
<p className="text-lg text-gray-600">
</p>
</div>
{onBack && (
<Button
variant="outline"
onClick={onBack}
className="flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</Button>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Contact Form */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl"></span>
</CardTitle>
<p className="text-gray-600">
.
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Contact Reason */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{contactReasons.map((reason) => (
<button
key={reason.value}
type="button"
onClick={() =>
setFormData((prev) => ({
...prev,
contactReason: reason.value,
}))
}
className={cn(
"p-3 rounded-lg border text-left transition-all",
formData.contactReason === reason.value
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-gray-200 hover:border-gray-300",
)}
>
<div className="flex items-center gap-2">
<span className="text-lg">{reason.icon}</span>
<span className="text-sm font-medium">
{reason.label}
</span>
</div>
</button>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
placeholder="성함을 입력해주세요"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="답변받을 이메일을 입력해주세요"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
</div>
{/* Company */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
()
</label>
<input
type="text"
name="company"
value={formData.company}
onChange={handleInputChange}
placeholder="소속 회사나 기관을 입력해주세요"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* Subject */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<input
type="text"
name="subject"
value={formData.subject}
onChange={handleInputChange}
placeholder="문의 제목을 입력해주세요"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
{/* Message */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<textarea
name="message"
value={formData.message}
onChange={handleInputChange}
placeholder="문의 내용을 자세히 작성해주세요"
rows={6}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-vertical"
required
/>
</div>
<Button
type="submit"
disabled={
!formData.contactReason ||
!formData.name.trim() ||
!formData.email.trim() ||
!formData.subject.trim() ||
!formData.message.trim() ||
isSubmitting
}
className="w-full bg-green-600 hover:bg-green-700 disabled:opacity-50"
>
{isSubmitting ? "전송 중..." : "문의 보내기"}
</Button>
</form>
</CardContent>
</Card>
</div>
{/* Contact Information */}
<div className="space-y-6">
{/* Contact Methods */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-sm">📧</span>
</div>
<div>
<h4 className="font-medium text-gray-900"></h4>
<a
href="mailto:contact@sheeteasy.ai"
className="text-blue-600 hover:underline text-sm"
>
contact@sheeteasy.ai
</a>
<p className="text-xs text-gray-500 mt-1">
24-48
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-sm">💬</span>
</div>
<div>
<h4 className="font-medium text-gray-900">
</h4>
<p className="text-sm text-gray-600">
9:00 - 18:00
</p>
<p className="text-xs text-gray-500 mt-1">
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-sm">📱</span>
</div>
<div>
<h4 className="font-medium text-gray-900">
</h4>
<div className="flex gap-2 mt-1">
<a
href="#"
className="text-blue-600 hover:underline text-sm"
>
Twitter
</a>
<a
href="#"
className="text-blue-600 hover:underline text-sm"
>
LinkedIn
</a>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Business Hours */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">:</span>
<span className="font-medium">09:00 - 18:00</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">:</span>
<span className="font-medium">10:00 - 15:00</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">:</span>
<span className="text-gray-500"></span>
</div>
<p className="text-xs text-gray-500 mt-3">
* (KST)
</p>
</div>
</CardContent>
</Card>
{/* FAQ Link */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600 mb-3">
.
</p>
<Button variant="outline" className="w-full">
FAQ
</Button>
</CardContent>
</Card>
{/* Response Time */}
<Card className="bg-gradient-to-r from-green-50 to-blue-50">
<CardContent className="p-4">
<div className="text-center">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-2">
<span className="text-xl"></span>
</div>
<h3 className="font-semibold text-gray-900 mb-1">
</h3>
<p className="text-sm text-gray-600">
12
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
},
);
ContactPage.displayName = "ContactPage";
export default ContactPage;

View File

@@ -13,6 +13,13 @@ interface LandingPageProps {
onAccountClick?: () => void;
onDemoClick?: () => void;
onTutorialSelect?: (tutorial: TutorialItem) => void;
onLicenseClick?: () => void;
onRoadmapClick?: () => void;
onUpdatesClick?: () => void;
onSupportClick?: () => void;
onContactClick?: () => void;
onPrivacyPolicyClick?: () => void;
onTermsOfServiceClick?: () => void;
}
/**
@@ -26,6 +33,13 @@ const LandingPage: React.FC<LandingPageProps> = ({
onGetStarted,
onDemoClick,
onTutorialSelect,
onLicenseClick,
onRoadmapClick,
onUpdatesClick,
onSupportClick,
onContactClick,
onPrivacyPolicyClick,
onTermsOfServiceClick,
}) => {
return (
<div className="min-h-screen bg-white">
@@ -48,7 +62,15 @@ const LandingPage: React.FC<LandingPageProps> = ({
</main>
{/* Footer - 푸터 정보 */}
<Footer />
<Footer
onLicenseClick={onLicenseClick}
onRoadmapClick={onRoadmapClick}
onUpdatesClick={onUpdatesClick}
onSupportClick={onSupportClick}
onContactClick={onContactClick}
onPrivacyPolicyClick={onPrivacyPolicyClick}
onTermsOfServiceClick={onTermsOfServiceClick}
/>
</div>
);
};

View File

@@ -0,0 +1,389 @@
import * as React from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
interface LicensePageProps {
onBack?: () => void;
}
interface LibraryInfo {
name: string;
version: string;
license: string;
description: string;
url?: string;
}
/**
* 오픈소스 라이브러리 라이센스 정보 페이지
* - 사용된 모든 오픈소스 라이브러리 목록
* - 라이센스 타입별 분류
* - 각 라이브러리의 상세 정보
*/
const LicensePage: React.FC<LicensePageProps> = ({ onBack }) => {
// 사용된 오픈소스 라이브러리 정보
const libraries: LibraryInfo[] = [
// React 생태계
{
name: "React",
version: "18.3.1",
license: "MIT",
description: "사용자 인터페이스 구축을 위한 JavaScript 라이브러리",
url: "https://reactjs.org/",
},
{
name: "React DOM",
version: "18.3.1",
license: "MIT",
description: "React를 위한 DOM 렌더러",
url: "https://reactjs.org/",
},
// UI 프레임워크
{
name: "Tailwind CSS",
version: "3.4.17",
license: "MIT",
description: "유틸리티 우선 CSS 프레임워크",
url: "https://tailwindcss.com/",
},
{
name: "Radix UI",
version: "1.0+",
license: "MIT",
description: "접근 가능한 디자인 시스템 및 컴포넌트 라이브러리",
url: "https://www.radix-ui.com/",
},
{
name: "Lucide React",
version: "0.468.0",
license: "ISC",
description: "아름다운 오픈소스 아이콘 라이브러리",
url: "https://lucide.dev/",
},
{
name: "class-variance-authority",
version: "0.7.1",
license: "Apache 2.0",
description: "CSS-in-JS 변형 API",
url: "https://cva.style/",
},
{
name: "clsx",
version: "2.1.1",
license: "MIT",
description: "조건부 CSS 클래스 이름 구성 유틸리티",
url: "https://github.com/lukeed/clsx",
},
{
name: "tailwind-merge",
version: "2.5.4",
license: "MIT",
description: "Tailwind CSS 클래스 병합 유틸리티",
url: "https://github.com/dcastil/tailwind-merge",
},
// 차트 라이브러리
{
name: "Recharts",
version: "2.12+",
license: "MIT",
description: "React용 재사용 가능한 차트 라이브러리",
url: "https://recharts.org/",
},
// Excel 처리
{
name: "Luckysheet",
version: "2.1.13",
license: "MIT",
description: "온라인 스프레드시트 편집기",
url: "https://mengshukeji.github.io/LuckysheetDocs/",
},
{
name: "LuckyExcel",
version: "1.0.1",
license: "MIT",
description: "Excel과 Luckysheet 간 변환 라이브러리",
url: "https://github.com/mengshukeji/LuckyExcel",
},
{
name: "@zwight/luckyexcel",
version: "1.1.6",
license: "MIT",
description: "LuckyExcel의 개선된 버전",
url: "https://github.com/zwight/LuckyExcel",
},
{
name: "FileSaver.js",
version: "2.0.5",
license: "MIT",
description: "클라이언트 사이드 파일 저장 라이브러리",
url: "https://github.com/eligrey/FileSaver.js",
},
// Univer 생태계
{
name: "Univer Core",
version: "0.8.2",
license: "Apache 2.0",
description: "Univer 스프레드시트 엔진 코어",
url: "https://univer.ai/",
},
{
name: "Univer Sheets",
version: "0.8.2",
license: "Apache 2.0",
description: "Univer 스프레드시트 컴포넌트",
url: "https://univer.ai/",
},
{
name: "Univer Presets",
version: "0.8.2",
license: "Apache 2.0",
description: "Univer 사전 구성 패키지",
url: "https://univer.ai/",
},
// 상태 관리
{
name: "Zustand",
version: "5.0.2",
license: "MIT",
description: "작고 빠른 확장 가능한 상태 관리 솔루션",
url: "https://zustand-demo.pmnd.rs/",
},
// 개발 도구
{
name: "Vite",
version: "6.0.1",
license: "MIT",
description: "빠른 프론트엔드 빌드 도구",
url: "https://vitejs.dev/",
},
{
name: "TypeScript",
version: "5.6.2",
license: "Apache 2.0",
description: "JavaScript의 타입 안전 상위 집합",
url: "https://www.typescriptlang.org/",
},
{
name: "ESLint",
version: "9.15.0",
license: "MIT",
description: "JavaScript 및 JSX용 정적 코드 분석 도구",
url: "https://eslint.org/",
},
{
name: "Prettier",
version: "3.4.2",
license: "MIT",
description: "코드 포맷터",
url: "https://prettier.io/",
},
// 테스트
{
name: "Vitest",
version: "3.2.4",
license: "MIT",
description: "Vite 기반 단위 테스트 프레임워크",
url: "https://vitest.dev/",
},
{
name: "Testing Library",
version: "16.3.0",
license: "MIT",
description: "간단하고 완전한 테스트 유틸리티",
url: "https://testing-library.com/",
},
];
// 라이센스별 분류
const licenseGroups = libraries.reduce(
(groups, lib) => {
const license = lib.license;
if (!groups[license]) {
groups[license] = [];
}
groups[license].push(lib);
return groups;
},
{} as Record<string, LibraryInfo[]>,
);
// 라이센스 타입별 색상
const getLicenseBadgeVariant = (license: string) => {
switch (license) {
case "MIT":
return "default";
case "Apache 2.0":
return "secondary";
case "ISC":
return "outline";
default:
return "outline";
}
};
return (
<div className="container mx-auto py-8 space-y-6">
{/* 페이지 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
</h1>
<p className="text-muted-foreground">
sheetEasy AI에서
</p>
</div>
<Button onClick={onBack} variant="outline">
</Button>
</div>
{/* 라이센스 요약 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
{libraries.length}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
{Object.entries(licenseGroups).map(([license, libs]) => (
<div key={license} className="flex items-center space-x-2">
<Badge variant={getLicenseBadgeVariant(license)}>
{license}
</Badge>
<span className="text-sm text-muted-foreground">
{libs.length}
</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* 라이센스별 라이브러리 목록 */}
<div className="space-y-6">
{Object.entries(licenseGroups).map(([license, libs]) => (
<Card key={license}>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Badge variant={getLicenseBadgeVariant(license)}>
{license}
</Badge>
<span>{license} </span>
</CardTitle>
<CardDescription>
{libs.length} {license}
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-4 font-medium">
</th>
<th className="text-left py-2 px-4 font-medium"></th>
<th className="text-left py-2 px-4 font-medium"></th>
<th className="text-left py-2 px-4 font-medium"></th>
</tr>
</thead>
<tbody>
{libs.map((lib, index) => (
<tr key={index} className="border-b last:border-b-0">
<td className="py-3 px-4 font-medium">{lib.name}</td>
<td className="py-3 px-4 text-muted-foreground">
{lib.version}
</td>
<td className="py-3 px-4 text-sm text-muted-foreground max-w-md">
{lib.description}
</td>
<td className="py-3 px-4">
{lib.url && (
<a
href={lib.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-sm underline"
>
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
))}
</div>
{/* 추가 정보 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="font-medium mb-2">MIT </h4>
<p className="text-sm text-muted-foreground">
, , , ,
.
.
</p>
</div>
<div>
<h4 className="font-medium mb-2">Apache 2.0 </h4>
<p className="text-sm text-muted-foreground">
Apache Software Foundation에서 , MIT와
.
</p>
</div>
<div>
<h4 className="font-medium mb-2">ISC </h4>
<p className="text-sm text-muted-foreground">
MIT
.
</p>
</div>
</CardContent>
</Card>
{/* 면책 조항 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground leading-relaxed">
.
. sheetEasy AI는
.
</p>
</CardContent>
</Card>
</div>
);
};
export default LicensePage;

View File

@@ -0,0 +1,410 @@
import * as React from "react";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { cn } from "../lib/utils";
interface PrivacyPolicyPageProps {
className?: string;
onBack?: () => void;
}
const PrivacyPolicyPage = React.forwardRef<
HTMLDivElement,
PrivacyPolicyPageProps
>(({ className, onBack, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50",
className,
)}
{...props}
>
<div className="container py-12">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-4xl font-bold text-gray-900 mb-2">
</h1>
<p className="text-lg text-gray-600">
sheetEasy AI
</p>
</div>
{onBack && (
<Button
variant="outline"
onClick={onBack}
className="flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</Button>
)}
</div>
{/* Policy Content */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🔒</span>
</CardTitle>
<div className="text-sm text-gray-600">
<p>시행일자: 2025년 1 1</p>
<p> 개정일: 2025년 1 1</p>
</div>
</CardHeader>
<CardContent>
<div className="prose max-w-none space-y-8">
{/* Section 1 */}
<section>
<h2 className="text-2xl font-bold text-gray-900 mb-4">
1 ( )
</h2>
<div className="text-gray-700 space-y-4">
<p>
sheetEasy AI( "회사")
.
,
18
.
</p>
<div className="bg-blue-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2"> :</h3>
<ul className="space-y-1 text-sm">
<li> </li>
<li> AI </li>
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</section>
{/* Section 2 */}
<section>
<h2 className="text-2xl font-bold text-gray-900 mb-4">
2 ( )
</h2>
<div className="text-gray-700 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-green-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2"></h3>
<ul className="space-y-1 text-sm">
<li> </li>
<li> ( )</li>
<li> </li>
<li> </li>
<li> IP </li>
</ul>
</div>
<div className="bg-yellow-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2"></h3>
<ul className="space-y-1 text-sm">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
<p className="text-sm text-gray-600">
.
</p>
</div>
</section>
{/* Section 3 */}
<section>
<h2 className="text-2xl font-bold text-gray-900 mb-4">
3 ( )
</h2>
<div className="text-gray-700 space-y-4">
<p>
·
· ·.
</p>
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-semibold mb-3"></h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span> :</span>
<span className="font-medium"> </span>
</div>
<div className="flex justify-between">
<span> :</span>
<span className="font-medium">3</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-medium">5 ()</span>
</div>
<div className="flex justify-between">
<span> :</span>
<span className="font-medium">3</span>
</div>
</div>
</div>
</div>
</section>
{/* Section 4 */}
<section>
<h2 className="text-2xl font-bold text-gray-900 mb-4">
4 ( 3 )
</h2>
<div className="text-gray-700 space-y-4">
<p>
1( )
, ,
17
3 .
</p>
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
<h3 className="font-semibold mb-2 text-red-800">
3
</h3>
<p className="text-sm text-red-700">
sheetEasy AI는 3
.
.
</p>
</div>
</div>
</section>
{/* Section 5 */}
<section>
<h2 className="text-2xl font-bold text-gray-900 mb-4">
5 ( )
</h2>
<div className="text-gray-700 space-y-4">
<p>
.
</p>
<div className="overflow-x-auto">
<table className="w-full border border-gray-300 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-300 p-2 text-left">
</th>
<th className="border border-gray-300 p-2 text-left">
</th>
<th className="border border-gray-300 p-2 text-left">
</th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-300 p-2">
</td>
<td className="border border-gray-300 p-2">
</td>
<td className="border border-gray-300 p-2">
</td>
</tr>
<tr>
<td className="border border-gray-300 p-2">
</td>
<td className="border border-gray-300 p-2">
</td>
<td className="border border-gray-300 p-2">
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
{/* Section 6 */}
<section>
<h2 className="text-2xl font-bold text-gray-900 mb-4">
6 ( · )
</h2>
<div className="text-gray-700 space-y-4">
<p>
.
</p>
<div className="bg-blue-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2"> </h3>
<ul className="space-y-2 text-sm">
<li> </li>
<li> </li>
<li> ·</li>
<li> </li>
</ul>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2"> </h3>
<ul className="space-y-1 text-sm">
<li> 이메일: privacy@sheeteasy.ai</li>
<li> , , </li>
<li> .</li>
</ul>
</div>
</div>
</section>
{/* Section 7 */}
<section>
<h2 className="text-2xl font-bold text-gray-900 mb-4">
7 ( )
</h2>
<div className="text-gray-700 space-y-4">
<p>
29
/ .
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-purple-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2"> </h3>
<ul className="space-y-1 text-sm">
<li> SSL/TLS </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
<div className="bg-orange-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2"> </h3>
<ul className="space-y-1 text-sm">
<li> </li>
<li> </li>
<li> </li>
<li> · </li>
</ul>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2"> </h3>
<ul className="space-y-1 text-sm">
<li> , </li>
<li> </li>
</ul>
</div>
</div>
</div>
</section>
{/* Section 8 */}
<section>
<h2 className="text-2xl font-bold text-gray-900 mb-4">
8 ()
</h2>
<div className="text-gray-700 space-y-4">
<p>
,
.
</p>
<div className="bg-gray-50 p-6 rounded-lg">
<h3 className="font-semibold mb-4"></h3>
<div className="space-y-2 text-sm">
<div className="flex">
<span className="w-20 text-gray-600">:</span>
<span className="font-medium"></span>
</div>
<div className="flex">
<span className="w-20 text-gray-600">:</span>
<span className="font-medium"></span>
</div>
<div className="flex">
<span className="w-20 text-gray-600">:</span>
<span className="font-medium">
privacy@sheeteasy.ai
</span>
</div>
</div>
</div>
</div>
</section>
{/* Section 9 */}
<section>
<h2 className="text-2xl font-bold text-gray-900 mb-4">
9 ( )
</h2>
<div className="text-gray-700 space-y-4">
<p>
,
,
7
.
</p>
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
<p className="text-sm text-blue-800">
<strong>:</strong> ,
</p>
</div>
</div>
</section>
{/* Contact Info */}
<section className="bg-gradient-to-r from-green-50 to-blue-50 p-6 rounded-lg">
<h2 className="text-xl font-bold text-gray-900 mb-4"></h2>
<div className="space-y-2 text-sm text-gray-700">
<p>
.
</p>
<div className="space-y-1">
<p>
<strong>:</strong> privacy@sheeteasy.ai
</p>
<p>
<strong> :</strong>{" "}
dpo@sheeteasy.ai
</p>
<p>
<strong>
:
</strong>{" "}
privacy.go.kr
</p>
<p>
<strong> :</strong> privacy.go.kr
( 182)
</p>
</div>
</div>
</section>
</div>
</CardContent>
</Card>
</div>
</div>
);
});
PrivacyPolicyPage.displayName = "PrivacyPolicyPage";
export default PrivacyPolicyPage;

View File

@@ -0,0 +1,258 @@
import * as React from "react";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Badge } from "./ui/badge";
import { cn } from "../lib/utils";
interface RoadmapPageProps {
className?: string;
onBack?: () => void;
}
const RoadmapPage = React.forwardRef<HTMLDivElement, RoadmapPageProps>(
({ className, onBack, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50",
className,
)}
{...props}
>
<div className="container py-12">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-4xl font-bold text-gray-900 mb-2"></h1>
<p className="text-lg text-gray-600">
sheetEasy AI의
</p>
</div>
{onBack && (
<Button
variant="outline"
onClick={onBack}
className="flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</Button>
)}
</div>
{/* Roadmap Items */}
<div className="space-y-8">
{/* Q3 2025 */}
<Card className="border-l-4 border-l-green-500">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-2xl font-bold text-gray-900">
Q3 2025
</CardTitle>
<Badge variant="default" className="bg-green-500">
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-100 text-green-600 mt-1">
<span className="text-sm font-bold">🚀</span>
</div>
<div>
<h3 className="font-semibold text-gray-900 mb-1">
v1.0
</h3>
<p className="text-gray-600">
.
.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-100 text-green-600 mt-1">
<span className="text-sm font-bold">🔧</span>
</div>
<div>
<h3 className="font-semibold text-gray-900 mb-1">
</h3>
<p className="text-gray-600">
.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-100 text-green-600 mt-1">
<span className="text-sm font-bold">🌐</span>
</div>
<div>
<h3 className="font-semibold text-gray-900 mb-1">
</h3>
<p className="text-gray-600">
, , /
.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Q4 2025 */}
<Card className="border-l-4 border-l-blue-500">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-2xl font-bold text-gray-900">
Q4 2025
</CardTitle>
<Badge
variant="outline"
className="border-blue-500 text-blue-600"
>
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-blue-600 mt-1">
<span className="text-sm font-bold">🤖</span>
</div>
<div>
<h3 className="font-semibold text-gray-900 mb-1">
</h3>
<p className="text-gray-600">
"지난 3개월 매출 추이를 보여줘"
AI
.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-blue-600 mt-1">
<span className="text-sm font-bold">📊</span>
</div>
<div>
<h3 className="font-semibold text-gray-900 mb-1">
</h3>
<p className="text-gray-600">
.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-blue-600 mt-1">
<span className="text-sm font-bold">🔗</span>
</div>
<div>
<h3 className="font-semibold text-gray-900 mb-1">
</h3>
<p className="text-gray-600">
Google Sheets, Notion, Airtable
.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Future Plans */}
<Card className="border-l-4 border-l-purple-500 bg-gradient-to-r from-purple-50 to-pink-50">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-2xl font-bold text-gray-900">
(2026+)
</CardTitle>
<Badge
variant="outline"
className="border-purple-500 text-purple-600"
>
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<p className="text-gray-700 font-medium">
:
</p>
<ul className="space-y-2 text-gray-600">
<li className="flex items-center gap-2">
<span className="text-purple-500"></span>
AI
</li>
<li className="flex items-center gap-2">
<span className="text-purple-500"></span>
</li>
<li className="flex items-center gap-2">
<span className="text-purple-500"></span>
(iOS/Android)
</li>
<li className="flex items-center gap-2">
<span className="text-purple-500"></span>
</li>
</ul>
</div>
</CardContent>
</Card>
</div>
{/* CTA Section */}
<div className="mt-12 text-center">
<Card className="bg-gradient-to-r from-green-500 to-blue-600 text-white">
<CardContent className="p-8">
<h2 className="text-2xl font-bold mb-2">
?
</h2>
<p className="mb-4 opacity-90">
.
.
</p>
<Button
variant="secondary"
className="bg-white text-gray-900 hover:bg-gray-100"
>
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
);
},
);
RoadmapPage.displayName = "RoadmapPage";
export default RoadmapPage;

View File

@@ -0,0 +1,380 @@
import * as React from "react";
import { useState } from "react";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Badge } from "./ui/badge";
import { cn } from "../lib/utils";
import { useAppStore } from "../stores/useAppStore";
interface SupportPageProps {
className?: string;
onBack?: () => void;
}
const SupportPage = React.forwardRef<HTMLDivElement, SupportPageProps>(
({ className, onBack, ...props }, ref) => {
const { user, isAuthenticated } = useAppStore();
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [subject, setSubject] = useState<string>("");
const [message, setMessage] = useState<string>("");
const [priority, setPriority] = useState<string>("medium");
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const categories = [
{ value: "account", label: "계정 관련 문제", icon: "👤" },
{ value: "billing", label: "구독 및 결제", icon: "💳" },
{ value: "bug", label: "버그 신고", icon: "🐛" },
{ value: "feature", label: "기능 문의", icon: "✨" },
{ value: "performance", label: "성능 이슈", icon: "⚡" },
{ value: "data", label: "데이터 관련", icon: "📊" },
{ value: "other", label: "기타", icon: "❓" },
];
const priorities = [
{ value: "low", label: "낮음", color: "bg-green-100 text-green-800" },
{
value: "medium",
label: "보통",
color: "bg-yellow-100 text-yellow-800",
},
{ value: "high", label: "높음", color: "bg-orange-100 text-orange-800" },
{ value: "urgent", label: "긴급", color: "bg-red-100 text-red-800" },
];
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
// 실제 이메일 전송 로직 (모의)
try {
const emailData = {
to: "support@sheeteasy.ai",
from: user?.email || "anonymous@sheeteasy.ai",
subject: `[${selectedCategory.toUpperCase()}] ${subject}`,
html: `
<h2>지원 요청</h2>
<p><strong>사용자:</strong> ${user?.name || "익명"} (${user?.email || "이메일 없음"})</p>
<p><strong>카테고리:</strong> ${categories.find((c) => c.value === selectedCategory)?.label}</p>
<p><strong>우선순위:</strong> ${priorities.find((p) => p.value === priority)?.label}</p>
<p><strong>제목:</strong> ${subject}</p>
<hr>
<p><strong>내용:</strong></p>
<p>${message.replace(/\n/g, "<br>")}</p>
`,
};
// TODO: 실제 이메일 서비스 연동
console.log("지원 요청 전송:", emailData);
// 성공 시 폼 초기화
setTimeout(() => {
alert(
"지원 요청이 성공적으로 전송되었습니다. 빠른 시일 내에 답변드리겠습니다.",
);
setSelectedCategory("");
setSubject("");
setMessage("");
setPriority("medium");
setIsSubmitting(false);
}, 1000);
} catch (error) {
alert("지원 요청 전송에 실패했습니다. 다시 시도해주세요.");
setIsSubmitting(false);
}
};
// 로그인하지 않은 사용자 처리
if (!isAuthenticated) {
return (
<div
ref={ref}
className={cn(
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex items-center justify-center",
className,
)}
{...props}
>
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl">🔒</span>
</div>
<CardTitle className="text-2xl text-gray-900">
</CardTitle>
</CardHeader>
<CardContent className="text-center">
<p className="text-gray-600 mb-6">
.
</p>
<div className="space-y-3">
{onBack && (
<Button onClick={onBack} className="w-full">
</Button>
)}
<p className="text-sm text-gray-500">
<strong></strong>
.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div
ref={ref}
className={cn(
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50",
className,
)}
{...props}
>
<div className="container py-12">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-4xl font-bold text-gray-900 mb-2">
</h1>
<p className="text-lg text-gray-600">
</p>
</div>
{onBack && (
<Button
variant="outline"
onClick={onBack}
className="flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</Button>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Support Form */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">📝</span>
</CardTitle>
<p className="text-gray-600">
.
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Category Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{categories.map((category) => (
<button
key={category.value}
type="button"
onClick={() => setSelectedCategory(category.value)}
className={cn(
"p-3 rounded-lg border text-left transition-all",
selectedCategory === category.value
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-gray-200 hover:border-gray-300",
)}
>
<div className="flex items-center gap-2">
<span className="text-lg">{category.icon}</span>
<span className="text-sm font-medium">
{category.label}
</span>
</div>
</button>
))}
</div>
</div>
{/* Priority */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="flex gap-2">
{priorities.map((p) => (
<button
key={p.value}
type="button"
onClick={() => setPriority(p.value)}
className={cn(
"px-3 py-1 rounded-full text-sm font-medium transition-all",
priority === p.value
? p.color
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
)}
>
{p.label}
</button>
))}
</div>
</div>
{/* Subject */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="문제를 간단히 요약해주세요"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
{/* Message */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="문제에 대해 자세히 설명해주세요. 발생 시점, 브라우저 종류, 에러 메시지 등을 포함하면 더 빠른 해결이 가능합니다."
rows={6}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-vertical"
required
/>
</div>
<Button
type="submit"
disabled={
!selectedCategory ||
!subject.trim() ||
!message.trim() ||
isSubmitting
}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? "전송 중..." : "지원 요청 보내기"}
</Button>
</form>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* User Info */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">:</span>
<span className="font-medium">{user?.name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">:</span>
<span className="font-medium">{user?.email}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">:</span>
<Badge variant="outline">
{user?.subscription?.plan?.toUpperCase()}
</Badge>
</div>
</div>
</CardContent>
</Card>
{/* Quick Help */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3 text-sm">
<div>
<h4 className="font-medium text-gray-900 mb-1">
</h4>
<ul className="space-y-1 text-gray-600">
<li> </li>
<li> AI </li>
<li> </li>
</ul>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-1">
</h4>
<ul className="space-y-1 text-gray-600">
<li> 긴급: 2시간 </li>
<li> 높음: 24시간 </li>
<li> 보통: 48시간 </li>
<li> 낮음: 72시간 </li>
</ul>
</div>
</div>
</CardContent>
</Card>
{/* Contact Info */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-2">
<span className="text-lg">📧</span>
<a
href="mailto:support@sheeteasy.ai"
className="text-blue-600 hover:underline"
>
support@sheeteasy.ai
</a>
</div>
<div className="flex items-center gap-2">
<span className="text-lg">💬</span>
<span className="text-gray-600">
( 9-18)
</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
},
);
SupportPage.displayName = "SupportPage";
export default SupportPage;

View File

@@ -0,0 +1,277 @@
import * as React from "react";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { cn } from "../lib/utils";
interface TermsOfServicePageProps {
className?: string;
onBack?: () => void;
}
const TermsOfServicePage = React.forwardRef<
HTMLDivElement,
TermsOfServicePageProps
>(({ className, onBack, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50",
className,
)}
{...props}
>
<div className="container py-12">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-4xl font-bold text-gray-900 mb-2">
</h1>
<p className="text-lg text-gray-600">
sheetEasy AI
</p>
</div>
{onBack && (
<Button
variant="outline"
onClick={onBack}
className="flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</Button>
)}
</div>
{/* Terms Content */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">📋</span>
</CardTitle>
<div className="text-sm text-gray-600">
<p>시행일자: 2025년 1 1</p>
<p> 개정일: 2025년 1 1</p>
</div>
</CardHeader>
<CardContent>
<div className="prose max-w-none space-y-6">
{/* Section 1 */}
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">
1 ()
</h2>
<div className="text-gray-700 space-y-2">
<p>
sheetEasy AI( "회사") AI
( "서비스") ,
, ,
.
</p>
</div>
</section>
{/* Section 2 */}
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">
2 ()
</h2>
<div className="text-gray-700 space-y-2">
<div className="bg-gray-50 p-4 rounded-lg space-y-2">
<div>
<strong>1. "서비스"</strong>: AI
</div>
<div>
<strong>2. "이용자"</strong>:
</div>
<div>
<strong>3. "계정"</strong>:
</div>
<div>
<strong>4. "콘텐츠"</strong>:
</div>
</div>
</div>
</section>
{/* Section 3 */}
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">
3 ( )
</h2>
<div className="text-gray-700 space-y-2">
<p>
1. .
<br />
2.
.
<br />
3. 7 , 30
.
</p>
</div>
</section>
{/* Section 4 */}
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">
4 ( )
</h2>
<div className="text-gray-700 space-y-2">
<div className="bg-blue-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2"> :</h3>
<ul className="space-y-1 text-sm">
<li> AI </li>
<li> Excel, CSV </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</section>
{/* Section 5 */}
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">
5 ( )
</h2>
<div className="text-gray-700 space-y-2">
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
<h3 className="font-semibold mb-2 text-red-800">
:
</h3>
<ul className="space-y-1 text-sm text-red-700">
<li> , , </li>
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</section>
{/* Section 6 */}
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">
6 ( )
</h2>
<div className="text-gray-700 space-y-2">
<div className="bg-green-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2"> :</h3>
<ul className="space-y-1 text-sm">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</section>
{/* Section 7 */}
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">
7 ( )
</h2>
<div className="text-gray-700 space-y-2">
<p>
:
</p>
<ul className="space-y-1 text-sm ml-4">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</section>
{/* Section 8 */}
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">
8 ()
</h2>
<div className="text-gray-700 space-y-2">
<div className="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
<h3 className="font-semibold mb-2">
:
</h3>
<ul className="space-y-1 text-sm">
<li> , </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</section>
{/* Section 9 */}
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">
9 ()
</h2>
<div className="text-gray-700 space-y-2">
<p>
1. .
<br />
2.
.
<br />
3. .
</p>
</div>
</section>
{/* Contact Info */}
<section className="bg-gradient-to-r from-blue-50 to-purple-50 p-6 rounded-lg">
<h2 className="text-xl font-bold text-gray-900 mb-4"></h2>
<div className="space-y-2 text-sm text-gray-700">
<p>
.
</p>
<div className="space-y-1">
<p>
<strong>:</strong> legal@sheeteasy.ai
</p>
<p>
<strong>:</strong> support@sheeteasy.ai
</p>
<p>
<strong>:</strong> contact@sheeteasy.ai
</p>
</div>
</div>
</section>
</div>
</CardContent>
</Card>
</div>
</div>
);
});
TermsOfServicePage.displayName = "TermsOfServicePage";
export default TermsOfServicePage;

View File

@@ -0,0 +1,244 @@
import * as React from "react";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Badge } from "./ui/badge";
import { cn } from "../lib/utils";
interface UpdatesPageProps {
className?: string;
onBack?: () => void;
}
const UpdatesPage = React.forwardRef<HTMLDivElement, UpdatesPageProps>(
({ className, onBack, ...props }, ref) => {
const updates = [
{
version: "1.0.0",
date: "2025-07-15",
title: "🎉 공식 v1.0 출시",
description: "sheetEasy AI의 첫 번째 정식 버전 출시",
changes: [
"✨ 새로운 기능: AI 기반 스프레드시트 편집",
"🎨 사용자 인터페이스 대폭 개선",
"🚀 성능 최적화 및 안정성 향상",
"🌐 한국어/영어 다국어 지원",
"📊 Excel, CSV 파일 완벽 지원",
"🔒 브라우저 내 완전한 데이터 보안",
"🎯 튜토리얼 시스템 추가",
],
isLatest: true,
type: "major",
},
];
const getVersionBadge = (type: string, isLatest: boolean) => {
if (isLatest) {
return <Badge className="bg-green-500"></Badge>;
}
switch (type) {
case "major":
return <Badge variant="default">Major</Badge>;
case "minor":
return <Badge variant="secondary">Minor</Badge>;
case "patch":
return <Badge variant="outline">Patch</Badge>;
default:
return <Badge variant="outline">Update</Badge>;
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
});
};
return (
<div
ref={ref}
className={cn(
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50",
className,
)}
{...props}
>
<div className="container py-12">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-4xl font-bold text-gray-900 mb-2">
</h1>
<p className="text-lg text-gray-600">
sheetEasy AI의
</p>
</div>
{onBack && (
<Button
variant="outline"
onClick={onBack}
className="flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</Button>
)}
</div>
{/* Version History */}
<div className="space-y-6">
{updates.map((update, index) => (
<Card
key={update.version}
className={cn(
"transition-all hover:shadow-lg",
update.isLatest &&
"border-l-4 border-l-green-500 bg-gradient-to-r from-green-50 to-blue-50",
)}
>
<CardHeader>
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<CardTitle className="text-2xl font-bold text-gray-900">
v{update.version}
</CardTitle>
{getVersionBadge(update.type, update.isLatest)}
</div>
<div className="text-gray-500 text-sm">
{formatDate(update.date)}
</div>
</div>
<div className="mt-2">
<h2 className="text-xl font-semibold text-gray-800">
{update.title}
</h2>
<p className="text-gray-600 mt-1">{update.description}</p>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<h3 className="font-semibold text-gray-900 mb-3">
📝
</h3>
<ul className="space-y-2">
{update.changes.map((change, changeIndex) => (
<li
key={changeIndex}
className="flex items-start gap-2 text-gray-700"
>
<span className="text-sm mt-1"></span>
<span>{change}</span>
</li>
))}
</ul>
</div>
</CardContent>
</Card>
))}
</div>
{/* Coming Soon Section */}
<Card className="mt-8 bg-gradient-to-r from-blue-50 to-purple-50 border-dashed border-2 border-blue-200">
<CardContent className="p-8 text-center">
<div className="mb-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 text-blue-600 mb-4">
<svg
className="w-8 h-8"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
!
</h3>
<p className="text-gray-600 mb-4">
.
.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button variant="outline" className="flex items-center gap-2">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H9a2 2 0 01-2-2z"
/>
</svg>
</Button>
<Button className="bg-blue-600 hover:bg-blue-700">
</Button>
</div>
</CardContent>
</Card>
{/* Newsletter Signup */}
<Card className="mt-8">
<CardHeader>
<CardTitle className="text-center text-gray-900">
📮
</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-md mx-auto">
<p className="text-center text-gray-600 mb-4">
</p>
<div className="flex gap-2">
<input
type="email"
placeholder="이메일 주소를 입력하세요"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<Button className="bg-green-600 hover:bg-green-700">
</Button>
</div>
<p className="text-xs text-gray-500 text-center mt-2">
.
.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
},
);
UpdatesPage.displayName = "UpdatesPage";
export default UpdatesPage;

View File

@@ -0,0 +1,141 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "../../lib/utils";
import { buttonVariants } from "./button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,122 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "../../lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -1,12 +1,35 @@
import * as React from "react";
import { cn } from "../../lib/utils";
import { useAppStore } from "../../stores/useAppStore";
interface FooterProps {
className?: string;
onLicenseClick?: () => void;
onRoadmapClick?: () => void;
onUpdatesClick?: () => void;
onSupportClick?: () => void;
onContactClick?: () => void;
onPrivacyPolicyClick?: () => void;
onTermsOfServiceClick?: () => void;
}
const Footer = React.forwardRef<HTMLElement, FooterProps>(
({ className, ...props }, ref) => {
(
{
className,
onLicenseClick,
onRoadmapClick,
onUpdatesClick,
onSupportClick,
onContactClick,
onPrivacyPolicyClick,
onTermsOfServiceClick,
...props
},
ref,
) => {
const { isAuthenticated } = useAppStore();
return (
<footer
ref={ref}
@@ -36,77 +59,48 @@ const Footer = React.forwardRef<HTMLElement, FooterProps>(
<h3 className="text-sm font-semibold text-gray-200 mb-4"></h3>
<ul className="space-y-3 text-sm text-gray-400">
<li>
<a
href="#features"
className="hover:text-white transition-colors"
>
</a>
</li>
<li>
<a
href="#pricing"
className="hover:text-white transition-colors"
>
</a>
</li>
<li>
<a
href="#roadmap"
className="hover:text-white transition-colors"
<button
onClick={onRoadmapClick}
className="hover:text-white transition-colors text-left"
>
</a>
</button>
</li>
<li>
<a
href="#changelog"
className="hover:text-white transition-colors"
<button
onClick={onUpdatesClick}
className="hover:text-white transition-colors text-left"
>
</a>
</button>
</li>
</ul>
</div>
{/* Resources */}
{/* Support */}
<div>
<h3 className="text-sm font-semibold text-gray-200 mb-4">
</h3>
<h3 className="text-sm font-semibold text-gray-200 mb-4"></h3>
<ul className="space-y-3 text-sm text-gray-400">
<li>
<a
href="#docs"
className="hover:text-white transition-colors"
<button
onClick={onSupportClick}
disabled={!isAuthenticated}
className={cn(
"hover:text-white transition-colors text-left",
!isAuthenticated && "opacity-50 cursor-not-allowed",
)}
title={!isAuthenticated ? "로그인이 필요합니다" : undefined}
>
</a>
🔒 ( )
</button>
</li>
<li>
<a
href="#tutorials"
className="hover:text-white transition-colors"
<button
onClick={onContactClick}
className="hover:text-white transition-colors text-left"
>
</a>
</li>
<li>
<a
href="#community"
className="hover:text-white transition-colors"
>
</a>
</li>
<li>
<a
href="#support"
className="hover:text-white transition-colors"
>
</a>
</button>
</li>
</ul>
</div>
@@ -118,36 +112,28 @@ const Footer = React.forwardRef<HTMLElement, FooterProps>(
</h3>
<ul className="space-y-3 text-sm text-gray-400">
<li>
<a
href="#privacy"
className="hover:text-white transition-colors"
<button
onClick={onPrivacyPolicyClick}
className="hover:text-white transition-colors text-left"
>
</a>
</button>
</li>
<li>
<a
href="#terms"
className="hover:text-white transition-colors"
<button
onClick={onTermsOfServiceClick}
className="hover:text-white transition-colors text-left"
>
</a>
</button>
</li>
<li>
<a
href="#licenses"
className="hover:text-white transition-colors"
<button
onClick={onLicenseClick}
className="hover:text-white transition-colors text-left"
>
</a>
</li>
<li>
<a
href="#contact"
className="hover:text-white transition-colors"
>
</a>
📄 &
</button>
</li>
</ul>
</div>

View File

@@ -3,6 +3,8 @@ import { Card, CardContent, CardHeader } from "./card";
import { Button } from "./button";
import type { HistoryPanelProps, HistoryEntry } from "../../types/ai";
import { cn } from "../../lib/utils";
import { useAppStore } from "../../stores/useAppStore";
import { useTranslation } from "../../lib/i18n";
/**
* 히스토리 패널 컴포넌트
@@ -15,6 +17,8 @@ const HistoryPanel: React.FC<HistoryPanelProps> = ({
onReapply,
onClear,
}) => {
const { historyPanelPosition } = useAppStore();
const { t } = useTranslation();
// 키보드 접근성: Escape 키로 패널 닫기
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -115,13 +119,20 @@ const HistoryPanel: React.FC<HistoryPanelProps> = ({
className={cn(
"bg-white shadow-2xl z-50",
"transform transition-transform duration-300 ease-in-out",
"flex flex-col border-l border-gray-200",
isOpen ? "translate-x-0" : "translate-x-full",
"flex flex-col",
historyPanelPosition === "right"
? "border-l border-gray-200"
: "border-r border-gray-200",
isOpen
? "translate-x-0"
: historyPanelPosition === "right"
? "translate-x-full"
: "-translate-x-full",
)}
style={{
position: "fixed",
top: 64, // App.tsx 헤더와 일치 (h-16 = 64px)
right: 0,
[historyPanelPosition]: 0,
height: "calc(100vh - 64px)", // 헤더 높이만큼 조정
width: "384px", // w-96 = 384px
backgroundColor: "#ffffff",
@@ -141,13 +152,13 @@ const HistoryPanel: React.FC<HistoryPanelProps> = ({
id="history-panel-title"
className="text-lg font-semibold text-gray-900"
>
📝
📝 {t.history.title}
</h2>
<p
id="history-panel-description"
className="text-sm text-gray-500 mt-1"
>
AI ({history.length})
{t.history.subtitle} ({history.length})
</p>
</div>

View File

@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "../../lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -0,0 +1,157 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "../../lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -19,6 +19,7 @@ interface TopBarProps {
showAccount?: boolean;
showNavigation?: boolean;
showAuthButtons?: boolean;
showTestAccount?: boolean;
}
const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
@@ -39,6 +40,7 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
showAccount = true,
showNavigation = false,
showAuthButtons = false,
showTestAccount = false,
...props
},
ref,
@@ -135,7 +137,7 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
)}
{...props}
>
<div className="container flex h-16 items-center justify-between">
<div className="container flex h-16 items-center relative">
{/* Logo */}
<div className="flex items-center">
<button
@@ -152,9 +154,9 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
</button>
</div>
{/* Navigation Menu - Vooster.ai 스타일 */}
{/* Navigation Menu - 절대 중앙 위치 */}
{showNavigation && (
<nav className="hidden md:flex items-center space-x-8">
<nav className="hidden md:flex items-center space-x-8 absolute left-1/2 transform -translate-x-1/2">
<button
onClick={() => handleNavigation("home")}
className="text-sm font-medium text-gray-700 hover:text-green-600 transition-colors"
@@ -189,7 +191,7 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
)}
{/* Actions */}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-4 ml-auto">
{/* Auth Buttons - 랜딩 페이지용 */}
{showAuthButtons && (
<>
@@ -208,6 +210,18 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
>
</Button>
{/* 테스트용 어카운트 버튼 */}
{showTestAccount && (
<Button
variant="outline"
size="sm"
onClick={onAccountClick}
className="border-purple-500 text-purple-600 hover:bg-purple-50 hover:border-purple-600"
aria-label="테스트용 계정 페이지"
>
</Button>
)}
</>
)}

367
src/lib/i18n.tsx Normal file
View File

@@ -0,0 +1,367 @@
import * as React from "react";
import { createContext, useContext, useState, useEffect } from "react";
// 지원하는 언어 타입
export type Language = "ko" | "en";
// 번역 키 타입 정의
export interface Translations {
// 공통 UI
common: {
loading: string;
error: string;
success: string;
cancel: string;
confirm: string;
save: string;
delete: string;
edit: string;
close: string;
};
// 계정 페이지
account: {
title: string;
subtitle: string;
userInfo: string;
subscriptionPlan: string;
usageSummary: string;
usageAnalytics: string;
usageAnalyticsDescription: string;
settings: string;
logout: string;
email: string;
name: string;
joinDate: string;
lastLogin: string;
currentPlan: string;
planStatus: string;
nextBilling: string;
aiQueries: string;
cellCount: string;
language: string;
historyPanelPosition: string;
changeSettings: string;
changePlan: string;
cancelSubscription: string;
confirmCancel: string;
goToEditor: string;
left: string;
right: string;
korean: string;
english: string;
usageWarning: string;
dailyUsage: string;
last30Days: string;
date: string;
tooltipLabels: {
aiQueries: string;
cellCount: string;
promptCount: string;
editedCells: string;
};
};
// 히스토리 패널
history: {
title: string;
subtitle: string;
empty: string;
emptyDescription: string;
clearAll: string;
reapply: string;
prompt: string;
range: string;
sheet: string;
actions: string;
error: string;
today: string;
yesterday: string;
formula: string;
style: string;
chart: string;
other: string;
};
// 프롬프트 입력
prompt: {
placeholder: string;
send: string;
processing: string;
selectRange: string;
insertAddress: string;
};
}
// 한국어 번역
const koTranslations: Translations = {
common: {
loading: "로딩 중...",
error: "오류",
success: "성공",
cancel: "취소",
confirm: "확인",
save: "저장",
delete: "삭제",
edit: "편집",
close: "닫기",
},
account: {
title: "계정 관리",
subtitle: "구독 정보와 사용량을 관리하세요",
userInfo: "사용자 정보",
subscriptionPlan: "구독 플랜",
usageSummary: "사용량 요약",
usageAnalytics: "사용량 분석",
usageAnalyticsDescription: "최근 30일간 사용량 추이",
settings: "설정",
logout: "로그아웃",
email: "이메일",
name: "이름",
joinDate: "가입일",
lastLogin: "최근 로그인",
currentPlan: "현재 플랜",
planStatus: "플랜 상태",
nextBilling: "다음 결제일",
aiQueries: "AI 쿼리",
cellCount: "셀 수",
language: "언어",
historyPanelPosition: "히스토리 패널 위치",
changeSettings: "설정 변경",
changePlan: "플랜 변경",
cancelSubscription: "구독 취소",
confirmCancel: "정말로 구독을 취소하시겠습니까?",
goToEditor: "에디터로 이동",
left: "좌측",
right: "우측",
korean: "한국어",
english: "English",
usageWarning: "사용량 경고",
dailyUsage: "일일 사용량",
last30Days: "최근 30일",
date: "날짜",
tooltipLabels: {
aiQueries: "AI 쿼리",
cellCount: "셀 카운트",
promptCount: "프롬프트 수",
editedCells: "편집된 셀",
},
},
history: {
title: "작업 히스토리",
subtitle: "AI 프롬프트 실행 기록",
empty: "아직 실행된 AI 프롬프트가 없습니다.",
emptyDescription: "프롬프트를 입력하고 실행해보세요!",
clearAll: "전체 삭제",
reapply: "다시 적용",
prompt: "프롬프트:",
range: "범위:",
sheet: "시트:",
actions: "실행된 액션:",
error: "오류:",
today: "오늘",
yesterday: "어제",
formula: "수식",
style: "스타일",
chart: "차트",
other: "기타",
},
prompt: {
placeholder:
"AI에게 명령을 입력하세요... (예: A열의 모든 빈 셀을 0으로 채워주세요)",
send: "전송",
processing: "처리 중...",
selectRange: "범위 선택",
insertAddress: "주소 삽입",
},
};
// 영어 번역
const enTranslations: Translations = {
common: {
loading: "Loading...",
error: "Error",
success: "Success",
cancel: "Cancel",
confirm: "Confirm",
save: "Save",
delete: "Delete",
edit: "Edit",
close: "Close",
},
account: {
title: "Account Management",
subtitle: "Manage your subscription and usage",
userInfo: "User Information",
subscriptionPlan: "Subscription Plan",
usageSummary: "Usage Summary",
usageAnalytics: "Usage Analytics",
usageAnalyticsDescription: "30-day usage trends",
settings: "Settings",
logout: "Logout",
email: "Email",
name: "Name",
joinDate: "Join Date",
lastLogin: "Last Login",
currentPlan: "Current Plan",
planStatus: "Plan Status",
nextBilling: "Next Billing",
aiQueries: "AI Queries",
cellCount: "Cell Count",
language: "Language",
historyPanelPosition: "History Panel Position",
changeSettings: "Change Settings",
changePlan: "Change Plan",
cancelSubscription: "Cancel Subscription",
confirmCancel: "Are you sure you want to cancel your subscription?",
goToEditor: "Go to Editor",
left: "Left",
right: "Right",
korean: "한국어",
english: "English",
usageWarning: "Usage Warning",
dailyUsage: "Daily Usage",
last30Days: "Last 30 Days",
date: "Date",
tooltipLabels: {
aiQueries: "AI Queries",
cellCount: "Cell Count",
promptCount: "Prompt Count",
editedCells: "Edited Cells",
},
},
history: {
title: "Work History",
subtitle: "AI prompt execution history",
empty: "No AI prompts have been executed yet.",
emptyDescription: "Try entering and executing a prompt!",
clearAll: "Clear All",
reapply: "Reapply",
prompt: "Prompt:",
range: "Range:",
sheet: "Sheet:",
actions: "Executed Actions:",
error: "Error:",
today: "Today",
yesterday: "Yesterday",
formula: "Formula",
style: "Style",
chart: "Chart",
other: "Other",
},
prompt: {
placeholder:
"Enter AI command... (e.g., Fill all empty cells in column A with 0)",
send: "Send",
processing: "Processing...",
selectRange: "Select Range",
insertAddress: "Insert Address",
},
};
// 번역 데이터
const translations: Record<Language, Translations> = {
ko: koTranslations,
en: enTranslations,
};
// i18n Context 타입
interface I18nContextType {
language: Language;
setLanguage: (lang: Language) => void;
t: Translations;
isLoading: boolean;
}
// Context 생성
const I18nContext = createContext<I18nContextType | undefined>(undefined);
// localStorage 키
const LANGUAGE_STORAGE_KEY = "sheeteasy-language";
// Provider 컴포넌트
export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [language, setLanguageState] = useState<Language>("ko");
const [isLoading, setIsLoading] = useState(true);
// 초기 언어 설정 로드
useEffect(() => {
const savedLanguage = localStorage.getItem(
LANGUAGE_STORAGE_KEY,
) as Language;
if (savedLanguage && (savedLanguage === "ko" || savedLanguage === "en")) {
setLanguageState(savedLanguage);
} else {
// 브라우저 언어 감지
const browserLanguage = navigator.language.toLowerCase();
if (browserLanguage.startsWith("ko")) {
setLanguageState("ko");
} else {
setLanguageState("en");
}
}
setIsLoading(false);
}, []);
// 언어 변경 함수
const setLanguage = (lang: Language) => {
setLanguageState(lang);
localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
};
// 현재 번역 객체
const t = translations[language];
const value: I18nContextType = {
language,
setLanguage,
t,
isLoading,
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
};
// useTranslation 훅
export const useTranslation = () => {
const context = useContext(I18nContext);
if (context === undefined) {
throw new Error("useTranslation must be used within an I18nProvider");
}
return context;
};
// 편의 함수들
export const getLanguageDisplayName = (lang: Language): string => {
return lang === "ko" ? "한국어" : "English";
};
export const formatDate = (date: Date, language: Language): string => {
const locale = language === "ko" ? "ko-KR" : "en-US";
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
};
export const formatDateTime = (date: Date, language: Language): string => {
const locale = language === "ko" ? "ko-KR" : "en-US";
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};

View File

@@ -8,12 +8,17 @@ import type {
import type { AIHistory } from "../types/ai";
import type { User } from "../types/user";
import type { TutorialSessionState, TutorialItem } from "../types/tutorial";
import type { Language } from "../lib/i18n.tsx";
interface AppState {
// 사용자 상태
user: User | null;
isAuthenticated: boolean;
// 언어 및 UI 설정
language: Language;
historyPanelPosition: "left" | "right";
// 파일 및 시트 상태
currentFile: {
name: string;
@@ -48,6 +53,10 @@ interface AppState {
setUser: (user: User | null) => void;
setAuthenticated: (authenticated: boolean) => void;
// 언어 및 UI 설정 액션
setLanguage: (language: Language) => void;
setHistoryPanelPosition: (position: "left" | "right") => void;
setCurrentFile: (
file: {
name: string;
@@ -92,6 +101,8 @@ interface AppState {
const initialState = {
user: null,
isAuthenticated: false,
language: "ko" as Language,
historyPanelPosition: "right" as "left" | "right",
currentFile: null,
sheets: [],
activeSheetId: null,
@@ -123,6 +134,16 @@ export const useAppStore = create<AppState>()(
setAuthenticated: (authenticated) =>
set({ isAuthenticated: authenticated }),
// 언어 및 UI 설정 액션
setLanguage: (language) => {
set({ language });
localStorage.setItem("sheeteasy-language", language);
},
setHistoryPanelPosition: (position) => {
set({ historyPanelPosition: position });
localStorage.setItem("sheeteasy-history-panel-position", position);
},
// 파일 및 시트 액션
setCurrentFile: (file) => set({ currentFile: file }),
setSheets: (sheets) => set({ sheets }),