feat: Footer/TopBar 라우팅 및 모든 페이지 네비게이션 핸들러 개선, Suspense 오류 대응, sheetEasyAI 로고 클릭 시 랜딩 이동 기능 구현
This commit is contained in:
587
package-lock.json
generated
587
package-lock.json
generated
@@ -9,6 +9,10 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"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/core": "^0.8.2",
|
||||||
"@univerjs/design": "^0.8.2",
|
"@univerjs/design": "^0.8.2",
|
||||||
"@univerjs/docs": "^0.8.2",
|
"@univerjs/docs": "^0.8.2",
|
||||||
@@ -29,11 +33,15 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
|
"i18next": "^25.3.0",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"luckyexcel": "^1.0.1",
|
"luckyexcel": "^1.0.1",
|
||||||
"luckysheet": "^2.1.13",
|
"luckysheet": "^2.1.13",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^15.5.3",
|
||||||
|
"recharts": "^3.0.2",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
@@ -1682,12 +1690,46 @@
|
|||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"peer": true
|
"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": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
|
||||||
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
|
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
"version": "1.1.10",
|
"version": "1.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-separator": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-use-rect": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
"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"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.11",
|
"version": "1.0.0-beta.11",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",
|
||||||
@@ -2752,6 +2917,18 @@
|
|||||||
"@sinonjs/commons": "^3.0.0"
|
"@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": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
||||||
@@ -2930,6 +3107,69 @@
|
|||||||
"@types/deep-eql": "*"
|
"@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": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -3134,6 +3374,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.33",
|
"version": "17.0.33",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||||
@@ -7831,6 +8077,127 @@
|
|||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/data-urls": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
|
||||||
@@ -7876,6 +8243,12 @@
|
|||||||
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
|
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/deep-eql": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||||
@@ -8125,6 +8498,16 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.5",
|
"version": "0.25.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
|
||||||
@@ -8425,6 +8808,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/expect": {
|
||||||
"version": "30.0.1",
|
"version": "30.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/expect/-/expect-30.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/expect/-/expect-30.0.1.tgz",
|
||||||
@@ -8995,6 +9384,15 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/http-proxy-agent": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
||||||
@@ -9024,6 +9422,46 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
@@ -9073,6 +9511,16 @@
|
|||||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -9127,6 +9575,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -11459,6 +11916,32 @@
|
|||||||
"react-dom": ">= 16.3.0"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
@@ -11666,6 +12149,64 @@
|
|||||||
"node": ">=8.10.0"
|
"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": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -11715,6 +12256,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/resize-observer-polyfill": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
"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": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@@ -12850,6 +13406,28 @@
|
|||||||
"uuid": "dist/bin/uuid"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "6.3.5",
|
"version": "6.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||||
@@ -13062,6 +13640,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/vue": {
|
||||||
"version": "3.5.17",
|
"version": "3.5.17",
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.17.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.17.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/core": "^0.8.2",
|
||||||
"@univerjs/design": "^0.8.2",
|
"@univerjs/design": "^0.8.2",
|
||||||
"@univerjs/docs": "^0.8.2",
|
"@univerjs/docs": "^0.8.2",
|
||||||
@@ -38,11 +42,15 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
|
"i18next": "^25.3.0",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"luckyexcel": "^1.0.1",
|
"luckyexcel": "^1.0.1",
|
||||||
"luckysheet": "^2.1.13",
|
"luckysheet": "^2.1.13",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^15.5.3",
|
||||||
|
"recharts": "^3.0.2",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
|
|||||||
328
src/App.tsx
328
src/App.tsx
@@ -1,12 +1,16 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "./components/ui/button";
|
import { Button } from "./components/ui/button";
|
||||||
import { TopBar } from "./components/ui/topbar";
|
import { TopBar } from "./components/ui/topbar";
|
||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
import LandingPage from "./components/LandingPage";
|
import LandingPage from "./components/LandingPage";
|
||||||
import { SignUpPage } from "./components/auth/SignUpPage";
|
import { SignUpPage } from "./components/auth/SignUpPage";
|
||||||
import { SignInPage } from "./components/auth/SignInPage";
|
import { SignInPage } from "./components/auth/SignInPage";
|
||||||
|
import AccountPage from "./components/AccountPage";
|
||||||
|
import LicensePage from "./components/LicensePage";
|
||||||
import { useAppStore } from "./stores/useAppStore";
|
import { useAppStore } from "./stores/useAppStore";
|
||||||
|
import { I18nProvider } from "./lib/i18n";
|
||||||
import type { TutorialItem } from "./types/tutorial";
|
import type { TutorialItem } from "./types/tutorial";
|
||||||
|
import { startTransition } from "react";
|
||||||
|
|
||||||
// TutorialSheetViewer 동적 import
|
// TutorialSheetViewer 동적 import
|
||||||
const TutorialSheetViewer = lazy(
|
const TutorialSheetViewer = lazy(
|
||||||
@@ -18,6 +22,16 @@ const EditSheetViewer = lazy(
|
|||||||
() => import("./components/sheet/EditSheetViewer"),
|
() => 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 =
|
type AppView =
|
||||||
| "landing"
|
| "landing"
|
||||||
@@ -25,7 +39,14 @@ type AppView =
|
|||||||
| "signIn"
|
| "signIn"
|
||||||
| "editor"
|
| "editor"
|
||||||
| "account"
|
| "account"
|
||||||
| "tutorial";
|
| "tutorial"
|
||||||
|
| "license"
|
||||||
|
| "roadmap"
|
||||||
|
| "updates"
|
||||||
|
| "support"
|
||||||
|
| "contact"
|
||||||
|
| "privacy-policy"
|
||||||
|
| "terms-of-service";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [currentView, setCurrentView] = useState<AppView>("landing");
|
const [currentView, setCurrentView] = useState<AppView>("landing");
|
||||||
@@ -36,8 +57,27 @@ function App() {
|
|||||||
user,
|
user,
|
||||||
currentFile,
|
currentFile,
|
||||||
startTutorial,
|
startTutorial,
|
||||||
|
setLanguage,
|
||||||
|
setHistoryPanelPosition,
|
||||||
} = useAppStore();
|
} = 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 버튼 클릭 핸들러 - 인증 상태에 따른 분기 처리
|
// CTA 버튼 클릭 핸들러 - 인증 상태에 따른 분기 처리
|
||||||
const handleGetStarted = () => {
|
const handleGetStarted = () => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
@@ -134,6 +174,45 @@ function App() {
|
|||||||
}, 100);
|
}, 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) => {
|
const handleTutorialSelect = (tutorial: TutorialItem) => {
|
||||||
console.log("🎯 튜토리얼 선택됨:", tutorial.metadata.title);
|
console.log("🎯 튜토리얼 선택됨:", tutorial.metadata.title);
|
||||||
@@ -225,6 +304,11 @@ function App() {
|
|||||||
const handleGoToSignIn = () => setCurrentView("signIn");
|
const handleGoToSignIn = () => setCurrentView("signIn");
|
||||||
const handleGoToSignUp = () => setCurrentView("signUp");
|
const handleGoToSignUp = () => setCurrentView("signUp");
|
||||||
const handleGoToEditor = () => setCurrentView("editor");
|
const handleGoToEditor = () => setCurrentView("editor");
|
||||||
|
const handleGoToLicense = () => {
|
||||||
|
startTransition(() => {
|
||||||
|
setCurrentView("license");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 에디터에서 홈으로 돌아가기 핸들러 (워닝 포함)
|
// 에디터에서 홈으로 돌아가기 핸들러 (워닝 포함)
|
||||||
const handleEditorLogoClick = () => {
|
const handleEditorLogoClick = () => {
|
||||||
@@ -290,110 +374,27 @@ function App() {
|
|||||||
showAuthButtons={false}
|
showAuthButtons={false}
|
||||||
onAccountClick={handleAccountClick}
|
onAccountClick={handleAccountClick}
|
||||||
/>
|
/>
|
||||||
<main className="container mx-auto py-8">
|
<main>
|
||||||
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-6">
|
<AccountPage
|
||||||
<h1 className="text-2xl font-bold mb-6">계정 정보</h1>
|
onGoToEditor={handleGoToEditor}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{/* 사용자 정보 */}
|
case "license":
|
||||||
<div className="mb-6">
|
return (
|
||||||
<h2 className="text-lg font-semibold mb-3">사용자 정보</h2>
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="space-y-2">
|
<TopBar
|
||||||
<p>
|
showDownload={false}
|
||||||
<strong>이메일:</strong> {user?.email}
|
showAccount={false}
|
||||||
</p>
|
showNavigation={false}
|
||||||
<p>
|
showAuthButtons={false}
|
||||||
<strong>이름:</strong> {user?.name}
|
onAccountClick={handleAccountClick}
|
||||||
</p>
|
/>
|
||||||
<p>
|
<main>
|
||||||
<strong>가입일:</strong>{" "}
|
<LicensePage onBack={handleBackToLanding} />
|
||||||
{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>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -464,6 +465,108 @@ function App() {
|
|||||||
</div>
|
</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":
|
case "landing":
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
@@ -473,6 +576,7 @@ function App() {
|
|||||||
showAccount={false}
|
showAccount={false}
|
||||||
showNavigation={true}
|
showNavigation={true}
|
||||||
showAuthButtons={true}
|
showAuthButtons={true}
|
||||||
|
showTestAccount={true}
|
||||||
onSignInClick={handleGoToSignIn}
|
onSignInClick={handleGoToSignIn}
|
||||||
onGetStartedClick={handleGetStarted}
|
onGetStartedClick={handleGetStarted}
|
||||||
onAccountClick={handleAccountClick}
|
onAccountClick={handleAccountClick}
|
||||||
@@ -481,6 +585,7 @@ function App() {
|
|||||||
onFeaturesClick={handleFeaturesClick}
|
onFeaturesClick={handleFeaturesClick}
|
||||||
onFAQClick={handleFAQClick}
|
onFAQClick={handleFAQClick}
|
||||||
onPricingClick={handlePricingClick}
|
onPricingClick={handlePricingClick}
|
||||||
|
onLogoClick={handleBackToLanding}
|
||||||
/>
|
/>
|
||||||
<LandingPage
|
<LandingPage
|
||||||
onGetStarted={handleGetStarted}
|
onGetStarted={handleGetStarted}
|
||||||
@@ -488,13 +593,24 @@ function App() {
|
|||||||
onAccountClick={handleAccountClick}
|
onAccountClick={handleAccountClick}
|
||||||
onDemoClick={handleDemoClick}
|
onDemoClick={handleDemoClick}
|
||||||
onTutorialSelect={handleTutorialSelect}
|
onTutorialSelect={handleTutorialSelect}
|
||||||
|
onLicenseClick={handleGoToLicense}
|
||||||
|
onRoadmapClick={handleRoadmapClick}
|
||||||
|
onUpdatesClick={handleUpdatesClick}
|
||||||
|
onSupportClick={handleSupportClick}
|
||||||
|
onContactClick={handleContactClick}
|
||||||
|
onPrivacyPolicyClick={handlePrivacyPolicyClick}
|
||||||
|
onTermsOfServiceClick={handleTermsOfServiceClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <div className="min-h-screen">{renderCurrentView()}</div>;
|
return (
|
||||||
|
<I18nProvider>
|
||||||
|
<div className="min-h-screen">{renderCurrentView()}</div>
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
552
src/components/AccountPage.tsx
Normal file
552
src/components/AccountPage.tsx
Normal 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;
|
||||||
417
src/components/ContactPage.tsx
Normal file
417
src/components/ContactPage.tsx
Normal 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;
|
||||||
@@ -13,6 +13,13 @@ interface LandingPageProps {
|
|||||||
onAccountClick?: () => void;
|
onAccountClick?: () => void;
|
||||||
onDemoClick?: () => void;
|
onDemoClick?: () => void;
|
||||||
onTutorialSelect?: (tutorial: TutorialItem) => 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,
|
onGetStarted,
|
||||||
onDemoClick,
|
onDemoClick,
|
||||||
onTutorialSelect,
|
onTutorialSelect,
|
||||||
|
onLicenseClick,
|
||||||
|
onRoadmapClick,
|
||||||
|
onUpdatesClick,
|
||||||
|
onSupportClick,
|
||||||
|
onContactClick,
|
||||||
|
onPrivacyPolicyClick,
|
||||||
|
onTermsOfServiceClick,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
@@ -48,7 +62,15 @@ const LandingPage: React.FC<LandingPageProps> = ({
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer - 푸터 정보 */}
|
{/* Footer - 푸터 정보 */}
|
||||||
<Footer />
|
<Footer
|
||||||
|
onLicenseClick={onLicenseClick}
|
||||||
|
onRoadmapClick={onRoadmapClick}
|
||||||
|
onUpdatesClick={onUpdatesClick}
|
||||||
|
onSupportClick={onSupportClick}
|
||||||
|
onContactClick={onContactClick}
|
||||||
|
onPrivacyPolicyClick={onPrivacyPolicyClick}
|
||||||
|
onTermsOfServiceClick={onTermsOfServiceClick}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
389
src/components/LicensePage.tsx
Normal file
389
src/components/LicensePage.tsx
Normal 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;
|
||||||
410
src/components/PrivacyPolicyPage.tsx
Normal file
410
src/components/PrivacyPolicyPage.tsx
Normal 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;
|
||||||
258
src/components/RoadmapPage.tsx
Normal file
258
src/components/RoadmapPage.tsx
Normal 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;
|
||||||
380
src/components/SupportPage.tsx
Normal file
380
src/components/SupportPage.tsx
Normal 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;
|
||||||
277
src/components/TermsOfServicePage.tsx
Normal file
277
src/components/TermsOfServicePage.tsx
Normal 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;
|
||||||
244
src/components/UpdatesPage.tsx
Normal file
244
src/components/UpdatesPage.tsx
Normal 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;
|
||||||
141
src/components/ui/alert-dialog.tsx
Normal file
141
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal 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 };
|
||||||
122
src/components/ui/dialog.tsx
Normal file
122
src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
@@ -1,12 +1,35 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
import { useAppStore } from "../../stores/useAppStore";
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
onLicenseClick?: () => void;
|
||||||
|
onRoadmapClick?: () => void;
|
||||||
|
onUpdatesClick?: () => void;
|
||||||
|
onSupportClick?: () => void;
|
||||||
|
onContactClick?: () => void;
|
||||||
|
onPrivacyPolicyClick?: () => void;
|
||||||
|
onTermsOfServiceClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
||||||
({ className, ...props }, ref) => {
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
onLicenseClick,
|
||||||
|
onRoadmapClick,
|
||||||
|
onUpdatesClick,
|
||||||
|
onSupportClick,
|
||||||
|
onContactClick,
|
||||||
|
onPrivacyPolicyClick,
|
||||||
|
onTermsOfServiceClick,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const { isAuthenticated } = useAppStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -36,77 +59,48 @@ const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
|||||||
<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">
|
<ul className="space-y-3 text-sm text-gray-400">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<button
|
||||||
href="#features"
|
onClick={onRoadmapClick}
|
||||||
className="hover:text-white transition-colors"
|
className="hover:text-white transition-colors text-left"
|
||||||
>
|
|
||||||
기능
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="#pricing"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
가격
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="#roadmap"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
>
|
||||||
로드맵
|
로드맵
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<button
|
||||||
href="#changelog"
|
onClick={onUpdatesClick}
|
||||||
className="hover:text-white transition-colors"
|
className="hover:text-white transition-colors text-left"
|
||||||
>
|
>
|
||||||
업데이트
|
업데이트
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resources */}
|
{/* Support */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-200 mb-4">
|
<h3 className="text-sm font-semibold text-gray-200 mb-4">지원</h3>
|
||||||
리소스
|
|
||||||
</h3>
|
|
||||||
<ul className="space-y-3 text-sm text-gray-400">
|
<ul className="space-y-3 text-sm text-gray-400">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<button
|
||||||
href="#docs"
|
onClick={onSupportClick}
|
||||||
className="hover:text-white transition-colors"
|
disabled={!isAuthenticated}
|
||||||
|
className={cn(
|
||||||
|
"hover:text-white transition-colors text-left",
|
||||||
|
!isAuthenticated && "opacity-50 cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
title={!isAuthenticated ? "로그인이 필요합니다" : undefined}
|
||||||
>
|
>
|
||||||
문서
|
🔒 지원 (로그인 필요)
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<button
|
||||||
href="#tutorials"
|
onClick={onContactClick}
|
||||||
className="hover:text-white transition-colors"
|
className="hover:text-white transition-colors text-left"
|
||||||
>
|
>
|
||||||
튜토리얼
|
문의하기
|
||||||
</a>
|
</button>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="#community"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
커뮤니티
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="#support"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
지원
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,36 +112,28 @@ const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
|||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-3 text-sm text-gray-400">
|
<ul className="space-y-3 text-sm text-gray-400">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<button
|
||||||
href="#privacy"
|
onClick={onPrivacyPolicyClick}
|
||||||
className="hover:text-white transition-colors"
|
className="hover:text-white transition-colors text-left"
|
||||||
>
|
>
|
||||||
개인정보처리방침
|
개인정보처리방침
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<button
|
||||||
href="#terms"
|
onClick={onTermsOfServiceClick}
|
||||||
className="hover:text-white transition-colors"
|
className="hover:text-white transition-colors text-left"
|
||||||
>
|
>
|
||||||
이용약관
|
이용약관
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<button
|
||||||
href="#licenses"
|
onClick={onLicenseClick}
|
||||||
className="hover:text-white transition-colors"
|
className="hover:text-white transition-colors text-left"
|
||||||
>
|
>
|
||||||
라이센스
|
📄 오픈소스 라이브러리 & 라이센스
|
||||||
</a>
|
</button>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="#contact"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
문의
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Card, CardContent, CardHeader } from "./card";
|
|||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
import type { HistoryPanelProps, HistoryEntry } from "../../types/ai";
|
import type { HistoryPanelProps, HistoryEntry } from "../../types/ai";
|
||||||
import { cn } from "../../lib/utils";
|
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,
|
onReapply,
|
||||||
onClear,
|
onClear,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { historyPanelPosition } = useAppStore();
|
||||||
|
const { t } = useTranslation();
|
||||||
// 키보드 접근성: Escape 키로 패널 닫기
|
// 키보드 접근성: Escape 키로 패널 닫기
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -115,13 +119,20 @@ const HistoryPanel: React.FC<HistoryPanelProps> = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"bg-white shadow-2xl z-50",
|
"bg-white shadow-2xl z-50",
|
||||||
"transform transition-transform duration-300 ease-in-out",
|
"transform transition-transform duration-300 ease-in-out",
|
||||||
"flex flex-col border-l border-gray-200",
|
"flex flex-col",
|
||||||
isOpen ? "translate-x-0" : "translate-x-full",
|
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={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
top: 64, // App.tsx 헤더와 일치 (h-16 = 64px)
|
top: 64, // App.tsx 헤더와 일치 (h-16 = 64px)
|
||||||
right: 0,
|
[historyPanelPosition]: 0,
|
||||||
height: "calc(100vh - 64px)", // 헤더 높이만큼 조정
|
height: "calc(100vh - 64px)", // 헤더 높이만큼 조정
|
||||||
width: "384px", // w-96 = 384px
|
width: "384px", // w-96 = 384px
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
@@ -141,13 +152,13 @@ const HistoryPanel: React.FC<HistoryPanelProps> = ({
|
|||||||
id="history-panel-title"
|
id="history-panel-title"
|
||||||
className="text-lg font-semibold text-gray-900"
|
className="text-lg font-semibold text-gray-900"
|
||||||
>
|
>
|
||||||
📝 작업 히스토리
|
📝 {t.history.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
id="history-panel-description"
|
id="history-panel-description"
|
||||||
className="text-sm text-gray-500 mt-1"
|
className="text-sm text-gray-500 mt-1"
|
||||||
>
|
>
|
||||||
AI 프롬프트 실행 기록 ({history.length}개)
|
{t.history.subtitle} ({history.length}개)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
28
src/components/ui/progress.tsx
Normal file
28
src/components/ui/progress.tsx
Normal 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 };
|
||||||
157
src/components/ui/select.tsx
Normal file
157
src/components/ui/select.tsx
Normal 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,
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ interface TopBarProps {
|
|||||||
showAccount?: boolean;
|
showAccount?: boolean;
|
||||||
showNavigation?: boolean;
|
showNavigation?: boolean;
|
||||||
showAuthButtons?: boolean;
|
showAuthButtons?: boolean;
|
||||||
|
showTestAccount?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
||||||
@@ -39,6 +40,7 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
|||||||
showAccount = true,
|
showAccount = true,
|
||||||
showNavigation = false,
|
showNavigation = false,
|
||||||
showAuthButtons = false,
|
showAuthButtons = false,
|
||||||
|
showTestAccount = false,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -135,7 +137,7 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="container flex h-16 items-center justify-between">
|
<div className="container flex h-16 items-center relative">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button
|
<button
|
||||||
@@ -152,9 +154,9 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Menu - Vooster.ai 스타일 */}
|
{/* Navigation Menu - 절대 중앙 위치 */}
|
||||||
{showNavigation && (
|
{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
|
<button
|
||||||
onClick={() => handleNavigation("home")}
|
onClick={() => handleNavigation("home")}
|
||||||
className="text-sm font-medium text-gray-700 hover:text-green-600 transition-colors"
|
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 */}
|
{/* Actions */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4 ml-auto">
|
||||||
{/* Auth Buttons - 랜딩 페이지용 */}
|
{/* Auth Buttons - 랜딩 페이지용 */}
|
||||||
{showAuthButtons && (
|
{showAuthButtons && (
|
||||||
<>
|
<>
|
||||||
@@ -208,6 +210,18 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
|||||||
>
|
>
|
||||||
무료로 시작하기
|
무료로 시작하기
|
||||||
</Button>
|
</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
367
src/lib/i18n.tsx
Normal 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);
|
||||||
|
};
|
||||||
@@ -8,12 +8,17 @@ import type {
|
|||||||
import type { AIHistory } from "../types/ai";
|
import type { AIHistory } from "../types/ai";
|
||||||
import type { User } from "../types/user";
|
import type { User } from "../types/user";
|
||||||
import type { TutorialSessionState, TutorialItem } from "../types/tutorial";
|
import type { TutorialSessionState, TutorialItem } from "../types/tutorial";
|
||||||
|
import type { Language } from "../lib/i18n.tsx";
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
// 사용자 상태
|
// 사용자 상태
|
||||||
user: User | null;
|
user: User | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
|
||||||
|
// 언어 및 UI 설정
|
||||||
|
language: Language;
|
||||||
|
historyPanelPosition: "left" | "right";
|
||||||
|
|
||||||
// 파일 및 시트 상태
|
// 파일 및 시트 상태
|
||||||
currentFile: {
|
currentFile: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -48,6 +53,10 @@ interface AppState {
|
|||||||
setUser: (user: User | null) => void;
|
setUser: (user: User | null) => void;
|
||||||
setAuthenticated: (authenticated: boolean) => void;
|
setAuthenticated: (authenticated: boolean) => void;
|
||||||
|
|
||||||
|
// 언어 및 UI 설정 액션
|
||||||
|
setLanguage: (language: Language) => void;
|
||||||
|
setHistoryPanelPosition: (position: "left" | "right") => void;
|
||||||
|
|
||||||
setCurrentFile: (
|
setCurrentFile: (
|
||||||
file: {
|
file: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -92,6 +101,8 @@ interface AppState {
|
|||||||
const initialState = {
|
const initialState = {
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
language: "ko" as Language,
|
||||||
|
historyPanelPosition: "right" as "left" | "right",
|
||||||
currentFile: null,
|
currentFile: null,
|
||||||
sheets: [],
|
sheets: [],
|
||||||
activeSheetId: null,
|
activeSheetId: null,
|
||||||
@@ -123,6 +134,16 @@ export const useAppStore = create<AppState>()(
|
|||||||
setAuthenticated: (authenticated) =>
|
setAuthenticated: (authenticated) =>
|
||||||
set({ isAuthenticated: 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 }),
|
setCurrentFile: (file) => set({ currentFile: file }),
|
||||||
setSheets: (sheets) => set({ sheets }),
|
setSheets: (sheets) => set({ sheets }),
|
||||||
|
|||||||
Reference in New Issue
Block a user