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",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@univerjs/core": "^0.8.2",
|
||||
"@univerjs/design": "^0.8.2",
|
||||
"@univerjs/docs": "^0.8.2",
|
||||
@@ -29,11 +33,15 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"i18next": "^25.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"luckyexcel": "^1.0.1",
|
||||
"luckysheet": "^2.1.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.5.3",
|
||||
"recharts": "^3.0.2",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
@@ -1682,12 +1690,46 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
|
||||
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz",
|
||||
"integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dialog": "1.1.14",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
@@ -2143,6 +2185,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
|
||||
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
|
||||
@@ -2174,6 +2240,49 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",
|
||||
"integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.10",
|
||||
"@radix-ui/react-focus-guards": "1.1.2",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.7",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
|
||||
@@ -2334,6 +2443,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-previous": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
|
||||
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||
@@ -2438,6 +2562,47 @@
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
|
||||
"integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^10.0.3",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",
|
||||
@@ -2752,6 +2917,18 @@
|
||||
"@sinonjs/commons": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
||||
@@ -2930,6 +3107,69 @@
|
||||
"@types/deep-eql": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -3134,6 +3374,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||
@@ -7831,6 +8077,127 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
|
||||
@@ -7876,6 +8243,12 @@
|
||||
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
@@ -8125,6 +8498,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.39.5",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.5.tgz",
|
||||
"integrity": "sha512-z9V0qU4lx1TBXDNFWfAASWk6RNU6c6+TJBKE+FLIg8u0XJ6Yw58Hi0yX8ftEouj6p1QARRlXLFfHbIli93BdQQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
|
||||
@@ -8425,6 +8808,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expect": {
|
||||
"version": "30.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expect/-/expect-30.0.1.tgz",
|
||||
@@ -8995,6 +9384,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
||||
@@ -9024,6 +9422,46 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.3.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.0.tgz",
|
||||
"integrity": "sha512-ZSQIiNGfqSG6yoLHaCvrkPp16UejHI8PCDxFYaNG/1qxtmqNmqEg4JlWKlxkrUmrin2sEjsy+Mjy1TRozBhOgw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com/i18next.html"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
|
||||
"integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -9073,6 +9511,16 @@
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
|
||||
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -9127,6 +9575,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -11459,6 +11916,32 @@
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "15.5.3",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.3.tgz",
|
||||
"integrity": "sha512-ypYmOKOnjqPEJZO4m1BI0kS8kWqkBNsKYyhVUfij0gvjy9xJNoG/VcGkxq5dRlVwzmrmY1BQMAmpbbUBLwC4Kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"html-parse-stringify": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 23.2.3",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -11666,6 +12149,64 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.0.2.tgz",
|
||||
"integrity": "sha512-eDc3ile9qJU9Dp/EekSthQPhAVPG48/uM47jk+PF7VBQngxeW3cwQpPHb/GHC1uqwyCRWXcIrDzuHRVrnRryoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -11715,6 +12256,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
@@ -12835,6 +13382,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -12850,6 +13406,28 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||
@@ -13062,6 +13640,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.17",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.17.tgz",
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@univerjs/core": "^0.8.2",
|
||||
"@univerjs/design": "^0.8.2",
|
||||
"@univerjs/docs": "^0.8.2",
|
||||
@@ -38,11 +42,15 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"i18next": "^25.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"luckyexcel": "^1.0.1",
|
||||
"luckysheet": "^2.1.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.5.3",
|
||||
"recharts": "^3.0.2",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
|
||||
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 { TopBar } from "./components/ui/topbar";
|
||||
import { lazy, Suspense } from "react";
|
||||
import LandingPage from "./components/LandingPage";
|
||||
import { SignUpPage } from "./components/auth/SignUpPage";
|
||||
import { SignInPage } from "./components/auth/SignInPage";
|
||||
import AccountPage from "./components/AccountPage";
|
||||
import LicensePage from "./components/LicensePage";
|
||||
import { useAppStore } from "./stores/useAppStore";
|
||||
import { I18nProvider } from "./lib/i18n";
|
||||
import type { TutorialItem } from "./types/tutorial";
|
||||
import { startTransition } from "react";
|
||||
|
||||
// TutorialSheetViewer 동적 import
|
||||
const TutorialSheetViewer = lazy(
|
||||
@@ -18,6 +22,16 @@ const EditSheetViewer = lazy(
|
||||
() => import("./components/sheet/EditSheetViewer"),
|
||||
);
|
||||
|
||||
// 새로운 페이지들 동적 import
|
||||
const RoadmapPage = lazy(() => import("./components/RoadmapPage"));
|
||||
const UpdatesPage = lazy(() => import("./components/UpdatesPage"));
|
||||
const SupportPage = lazy(() => import("./components/SupportPage"));
|
||||
const ContactPage = lazy(() => import("./components/ContactPage"));
|
||||
const PrivacyPolicyPage = lazy(() => import("./components/PrivacyPolicyPage"));
|
||||
const TermsOfServicePage = lazy(
|
||||
() => import("./components/TermsOfServicePage"),
|
||||
);
|
||||
|
||||
// 앱 상태 타입 정의
|
||||
type AppView =
|
||||
| "landing"
|
||||
@@ -25,7 +39,14 @@ type AppView =
|
||||
| "signIn"
|
||||
| "editor"
|
||||
| "account"
|
||||
| "tutorial";
|
||||
| "tutorial"
|
||||
| "license"
|
||||
| "roadmap"
|
||||
| "updates"
|
||||
| "support"
|
||||
| "contact"
|
||||
| "privacy-policy"
|
||||
| "terms-of-service";
|
||||
|
||||
function App() {
|
||||
const [currentView, setCurrentView] = useState<AppView>("landing");
|
||||
@@ -36,8 +57,27 @@ function App() {
|
||||
user,
|
||||
currentFile,
|
||||
startTutorial,
|
||||
setLanguage,
|
||||
setHistoryPanelPosition,
|
||||
} = useAppStore();
|
||||
|
||||
// 초기화: localStorage에서 설정 로드
|
||||
useEffect(() => {
|
||||
// 언어 설정 로드
|
||||
const savedLanguage = localStorage.getItem("sheeteasy-language");
|
||||
if (savedLanguage === "ko" || savedLanguage === "en") {
|
||||
setLanguage(savedLanguage);
|
||||
}
|
||||
|
||||
// 히스토리 패널 위치 설정 로드
|
||||
const savedPosition = localStorage.getItem(
|
||||
"sheeteasy-history-panel-position",
|
||||
);
|
||||
if (savedPosition === "left" || savedPosition === "right") {
|
||||
setHistoryPanelPosition(savedPosition);
|
||||
}
|
||||
}, [setLanguage, setHistoryPanelPosition]);
|
||||
|
||||
// CTA 버튼 클릭 핸들러 - 인증 상태에 따른 분기 처리
|
||||
const handleGetStarted = () => {
|
||||
if (isAuthenticated) {
|
||||
@@ -134,6 +174,45 @@ function App() {
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Footer 핸들러들 - 새로운 페이지 네비게이션
|
||||
const handleRoadmapClick = () => {
|
||||
startTransition(() => {
|
||||
setCurrentView("roadmap");
|
||||
});
|
||||
};
|
||||
const handleUpdatesClick = () => {
|
||||
startTransition(() => {
|
||||
setCurrentView("updates");
|
||||
});
|
||||
};
|
||||
const handleSupportClick = () => {
|
||||
if (isAuthenticated) {
|
||||
startTransition(() => {
|
||||
setCurrentView("support");
|
||||
});
|
||||
} else {
|
||||
alert("지원 서비스는 로그인 후 이용 가능합니다.");
|
||||
startTransition(() => {
|
||||
setCurrentView("signIn");
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleContactClick = () => {
|
||||
startTransition(() => {
|
||||
setCurrentView("contact");
|
||||
});
|
||||
};
|
||||
const handlePrivacyPolicyClick = () => {
|
||||
startTransition(() => {
|
||||
setCurrentView("privacy-policy");
|
||||
});
|
||||
};
|
||||
const handleTermsOfServiceClick = () => {
|
||||
startTransition(() => {
|
||||
setCurrentView("terms-of-service");
|
||||
});
|
||||
};
|
||||
|
||||
// 튜토리얼 선택 핸들러 - 튜토리얼 시작 후 튜토리얼 페이지로 전환
|
||||
const handleTutorialSelect = (tutorial: TutorialItem) => {
|
||||
console.log("🎯 튜토리얼 선택됨:", tutorial.metadata.title);
|
||||
@@ -225,6 +304,11 @@ function App() {
|
||||
const handleGoToSignIn = () => setCurrentView("signIn");
|
||||
const handleGoToSignUp = () => setCurrentView("signUp");
|
||||
const handleGoToEditor = () => setCurrentView("editor");
|
||||
const handleGoToLicense = () => {
|
||||
startTransition(() => {
|
||||
setCurrentView("license");
|
||||
});
|
||||
};
|
||||
|
||||
// 에디터에서 홈으로 돌아가기 핸들러 (워닝 포함)
|
||||
const handleEditorLogoClick = () => {
|
||||
@@ -290,110 +374,27 @@ function App() {
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
/>
|
||||
<main className="container mx-auto py-8">
|
||||
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">계정 정보</h1>
|
||||
<main>
|
||||
<AccountPage
|
||||
onGoToEditor={handleGoToEditor}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-3">사용자 정보</h2>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
<strong>이메일:</strong> {user?.email}
|
||||
</p>
|
||||
<p>
|
||||
<strong>이름:</strong> {user?.name}
|
||||
</p>
|
||||
<p>
|
||||
<strong>가입일:</strong>{" "}
|
||||
{user?.createdAt?.toLocaleDateString()}
|
||||
</p>
|
||||
<p>
|
||||
<strong>최근 로그인:</strong>{" "}
|
||||
{user?.lastLoginAt?.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구독 정보 */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-3">구독 정보</h2>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
<strong>플랜:</strong>{" "}
|
||||
{user?.subscription?.plan?.toUpperCase()}
|
||||
</p>
|
||||
<p>
|
||||
<strong>상태:</strong> {user?.subscription?.status}
|
||||
</p>
|
||||
<p>
|
||||
<strong>다음 결제일:</strong>{" "}
|
||||
{user?.subscription?.currentPeriodEnd?.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용량 */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-3">사용량</h2>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
<strong>AI 쿼리:</strong>{" "}
|
||||
{user?.subscription?.usage?.aiQueries} /{" "}
|
||||
{user?.subscription?.plan === "free"
|
||||
? "30"
|
||||
: user?.subscription?.plan === "lite"
|
||||
? "100"
|
||||
: "500"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>셀 카운트:</strong>{" "}
|
||||
{user?.subscription?.usage?.cellCount} /{" "}
|
||||
{user?.subscription?.plan === "free"
|
||||
? "300"
|
||||
: user?.subscription?.plan === "lite"
|
||||
? "1000"
|
||||
: "5000"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설정 */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-3">설정</h2>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
<strong>언어:</strong>{" "}
|
||||
{user?.preferences?.language === "ko"
|
||||
? "한국어"
|
||||
: "English"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>히스토리 패널 위치:</strong>{" "}
|
||||
{user?.preferences?.historyPanelPosition === "right"
|
||||
? "우측"
|
||||
: "좌측"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div className="flex space-x-4">
|
||||
<Button
|
||||
onClick={handleGoToEditor}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
에디터로 이동
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
variant="outline"
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
로그아웃
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
case "license":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
/>
|
||||
<main>
|
||||
<LicensePage onBack={handleBackToLanding} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
@@ -464,6 +465,108 @@ function App() {
|
||||
</div>
|
||||
);
|
||||
|
||||
case "roadmap":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
/>
|
||||
<main>
|
||||
<RoadmapPage />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "updates":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
/>
|
||||
<main>
|
||||
<UpdatesPage />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "support":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
/>
|
||||
<main>
|
||||
<SupportPage />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "contact":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
/>
|
||||
<main>
|
||||
<ContactPage />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "privacy-policy":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
/>
|
||||
<main>
|
||||
<PrivacyPolicyPage />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "terms-of-service":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
/>
|
||||
<main>
|
||||
<TermsOfServicePage />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "landing":
|
||||
default:
|
||||
return (
|
||||
@@ -473,6 +576,7 @@ function App() {
|
||||
showAccount={false}
|
||||
showNavigation={true}
|
||||
showAuthButtons={true}
|
||||
showTestAccount={true}
|
||||
onSignInClick={handleGoToSignIn}
|
||||
onGetStartedClick={handleGetStarted}
|
||||
onAccountClick={handleAccountClick}
|
||||
@@ -481,6 +585,7 @@ function App() {
|
||||
onFeaturesClick={handleFeaturesClick}
|
||||
onFAQClick={handleFAQClick}
|
||||
onPricingClick={handlePricingClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
/>
|
||||
<LandingPage
|
||||
onGetStarted={handleGetStarted}
|
||||
@@ -488,13 +593,24 @@ function App() {
|
||||
onAccountClick={handleAccountClick}
|
||||
onDemoClick={handleDemoClick}
|
||||
onTutorialSelect={handleTutorialSelect}
|
||||
onLicenseClick={handleGoToLicense}
|
||||
onRoadmapClick={handleRoadmapClick}
|
||||
onUpdatesClick={handleUpdatesClick}
|
||||
onSupportClick={handleSupportClick}
|
||||
onContactClick={handleContactClick}
|
||||
onPrivacyPolicyClick={handlePrivacyPolicyClick}
|
||||
onTermsOfServiceClick={handleTermsOfServiceClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="min-h-screen">{renderCurrentView()}</div>;
|
||||
return (
|
||||
<I18nProvider>
|
||||
<div className="min-h-screen">{renderCurrentView()}</div>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
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;
|
||||
onDemoClick?: () => void;
|
||||
onTutorialSelect?: (tutorial: TutorialItem) => void;
|
||||
onLicenseClick?: () => void;
|
||||
onRoadmapClick?: () => void;
|
||||
onUpdatesClick?: () => void;
|
||||
onSupportClick?: () => void;
|
||||
onContactClick?: () => void;
|
||||
onPrivacyPolicyClick?: () => void;
|
||||
onTermsOfServiceClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,6 +33,13 @@ const LandingPage: React.FC<LandingPageProps> = ({
|
||||
onGetStarted,
|
||||
onDemoClick,
|
||||
onTutorialSelect,
|
||||
onLicenseClick,
|
||||
onRoadmapClick,
|
||||
onUpdatesClick,
|
||||
onSupportClick,
|
||||
onContactClick,
|
||||
onPrivacyPolicyClick,
|
||||
onTermsOfServiceClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
@@ -48,7 +62,15 @@ const LandingPage: React.FC<LandingPageProps> = ({
|
||||
</main>
|
||||
|
||||
{/* Footer - 푸터 정보 */}
|
||||
<Footer />
|
||||
<Footer
|
||||
onLicenseClick={onLicenseClick}
|
||||
onRoadmapClick={onRoadmapClick}
|
||||
onUpdatesClick={onUpdatesClick}
|
||||
onSupportClick={onSupportClick}
|
||||
onContactClick={onContactClick}
|
||||
onPrivacyPolicyClick={onPrivacyPolicyClick}
|
||||
onTermsOfServiceClick={onTermsOfServiceClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 { cn } from "../../lib/utils";
|
||||
import { useAppStore } from "../../stores/useAppStore";
|
||||
|
||||
interface FooterProps {
|
||||
className?: string;
|
||||
onLicenseClick?: () => void;
|
||||
onRoadmapClick?: () => void;
|
||||
onUpdatesClick?: () => void;
|
||||
onSupportClick?: () => void;
|
||||
onContactClick?: () => void;
|
||||
onPrivacyPolicyClick?: () => void;
|
||||
onTermsOfServiceClick?: () => void;
|
||||
}
|
||||
|
||||
const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
onLicenseClick,
|
||||
onRoadmapClick,
|
||||
onUpdatesClick,
|
||||
onSupportClick,
|
||||
onContactClick,
|
||||
onPrivacyPolicyClick,
|
||||
onTermsOfServiceClick,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { isAuthenticated } = useAppStore();
|
||||
|
||||
return (
|
||||
<footer
|
||||
ref={ref}
|
||||
@@ -36,77 +59,48 @@ const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
||||
<h3 className="text-sm font-semibold text-gray-200 mb-4">제품</h3>
|
||||
<ul className="space-y-3 text-sm text-gray-400">
|
||||
<li>
|
||||
<a
|
||||
href="#features"
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
기능
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#pricing"
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
가격
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#roadmap"
|
||||
className="hover:text-white transition-colors"
|
||||
<button
|
||||
onClick={onRoadmapClick}
|
||||
className="hover:text-white transition-colors text-left"
|
||||
>
|
||||
로드맵
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#changelog"
|
||||
className="hover:text-white transition-colors"
|
||||
<button
|
||||
onClick={onUpdatesClick}
|
||||
className="hover:text-white transition-colors text-left"
|
||||
>
|
||||
업데이트
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
{/* Support */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-200 mb-4">
|
||||
리소스
|
||||
</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-200 mb-4">지원</h3>
|
||||
<ul className="space-y-3 text-sm text-gray-400">
|
||||
<li>
|
||||
<a
|
||||
href="#docs"
|
||||
className="hover:text-white transition-colors"
|
||||
<button
|
||||
onClick={onSupportClick}
|
||||
disabled={!isAuthenticated}
|
||||
className={cn(
|
||||
"hover:text-white transition-colors text-left",
|
||||
!isAuthenticated && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
title={!isAuthenticated ? "로그인이 필요합니다" : undefined}
|
||||
>
|
||||
문서
|
||||
</a>
|
||||
🔒 지원 (로그인 필요)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#tutorials"
|
||||
className="hover:text-white transition-colors"
|
||||
<button
|
||||
onClick={onContactClick}
|
||||
className="hover:text-white transition-colors text-left"
|
||||
>
|
||||
튜토리얼
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#community"
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
커뮤니티
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#support"
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
지원
|
||||
</a>
|
||||
문의하기
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -118,36 +112,28 @@ const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
||||
</h3>
|
||||
<ul className="space-y-3 text-sm text-gray-400">
|
||||
<li>
|
||||
<a
|
||||
href="#privacy"
|
||||
className="hover:text-white transition-colors"
|
||||
<button
|
||||
onClick={onPrivacyPolicyClick}
|
||||
className="hover:text-white transition-colors text-left"
|
||||
>
|
||||
개인정보처리방침
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#terms"
|
||||
className="hover:text-white transition-colors"
|
||||
<button
|
||||
onClick={onTermsOfServiceClick}
|
||||
className="hover:text-white transition-colors text-left"
|
||||
>
|
||||
이용약관
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#licenses"
|
||||
className="hover:text-white transition-colors"
|
||||
<button
|
||||
onClick={onLicenseClick}
|
||||
className="hover:text-white transition-colors text-left"
|
||||
>
|
||||
라이센스
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#contact"
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
문의
|
||||
</a>
|
||||
📄 오픈소스 라이브러리 & 라이센스
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Card, CardContent, CardHeader } from "./card";
|
||||
import { Button } from "./button";
|
||||
import type { HistoryPanelProps, HistoryEntry } from "../../types/ai";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useAppStore } from "../../stores/useAppStore";
|
||||
import { useTranslation } from "../../lib/i18n";
|
||||
|
||||
/**
|
||||
* 히스토리 패널 컴포넌트
|
||||
@@ -15,6 +17,8 @@ const HistoryPanel: React.FC<HistoryPanelProps> = ({
|
||||
onReapply,
|
||||
onClear,
|
||||
}) => {
|
||||
const { historyPanelPosition } = useAppStore();
|
||||
const { t } = useTranslation();
|
||||
// 키보드 접근성: Escape 키로 패널 닫기
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -115,13 +119,20 @@ const HistoryPanel: React.FC<HistoryPanelProps> = ({
|
||||
className={cn(
|
||||
"bg-white shadow-2xl z-50",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
"flex flex-col border-l border-gray-200",
|
||||
isOpen ? "translate-x-0" : "translate-x-full",
|
||||
"flex flex-col",
|
||||
historyPanelPosition === "right"
|
||||
? "border-l border-gray-200"
|
||||
: "border-r border-gray-200",
|
||||
isOpen
|
||||
? "translate-x-0"
|
||||
: historyPanelPosition === "right"
|
||||
? "translate-x-full"
|
||||
: "-translate-x-full",
|
||||
)}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 64, // App.tsx 헤더와 일치 (h-16 = 64px)
|
||||
right: 0,
|
||||
[historyPanelPosition]: 0,
|
||||
height: "calc(100vh - 64px)", // 헤더 높이만큼 조정
|
||||
width: "384px", // w-96 = 384px
|
||||
backgroundColor: "#ffffff",
|
||||
@@ -141,13 +152,13 @@ const HistoryPanel: React.FC<HistoryPanelProps> = ({
|
||||
id="history-panel-title"
|
||||
className="text-lg font-semibold text-gray-900"
|
||||
>
|
||||
📝 작업 히스토리
|
||||
📝 {t.history.title}
|
||||
</h2>
|
||||
<p
|
||||
id="history-panel-description"
|
||||
className="text-sm text-gray-500 mt-1"
|
||||
>
|
||||
AI 프롬프트 실행 기록 ({history.length}개)
|
||||
{t.history.subtitle} ({history.length}개)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
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;
|
||||
showNavigation?: boolean;
|
||||
showAuthButtons?: boolean;
|
||||
showTestAccount?: boolean;
|
||||
}
|
||||
|
||||
const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
||||
@@ -39,6 +40,7 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
||||
showAccount = true,
|
||||
showNavigation = false,
|
||||
showAuthButtons = false,
|
||||
showTestAccount = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -135,7 +137,7 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="container flex h-16 items-center justify-between">
|
||||
<div className="container flex h-16 items-center relative">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
@@ -152,9 +154,9 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation Menu - Vooster.ai 스타일 */}
|
||||
{/* Navigation Menu - 절대 중앙 위치 */}
|
||||
{showNavigation && (
|
||||
<nav className="hidden md:flex items-center space-x-8">
|
||||
<nav className="hidden md:flex items-center space-x-8 absolute left-1/2 transform -translate-x-1/2">
|
||||
<button
|
||||
onClick={() => handleNavigation("home")}
|
||||
className="text-sm font-medium text-gray-700 hover:text-green-600 transition-colors"
|
||||
@@ -189,7 +191,7 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-4 ml-auto">
|
||||
{/* Auth Buttons - 랜딩 페이지용 */}
|
||||
{showAuthButtons && (
|
||||
<>
|
||||
@@ -208,6 +210,18 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
||||
>
|
||||
무료로 시작하기
|
||||
</Button>
|
||||
{/* 테스트용 어카운트 버튼 */}
|
||||
{showTestAccount && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAccountClick}
|
||||
className="border-purple-500 text-purple-600 hover:bg-purple-50 hover:border-purple-600"
|
||||
aria-label="테스트용 계정 페이지"
|
||||
>
|
||||
어카운트
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
367
src/lib/i18n.tsx
Normal file
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 { User } from "../types/user";
|
||||
import type { TutorialSessionState, TutorialItem } from "../types/tutorial";
|
||||
import type { Language } from "../lib/i18n.tsx";
|
||||
|
||||
interface AppState {
|
||||
// 사용자 상태
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
|
||||
// 언어 및 UI 설정
|
||||
language: Language;
|
||||
historyPanelPosition: "left" | "right";
|
||||
|
||||
// 파일 및 시트 상태
|
||||
currentFile: {
|
||||
name: string;
|
||||
@@ -48,6 +53,10 @@ interface AppState {
|
||||
setUser: (user: User | null) => void;
|
||||
setAuthenticated: (authenticated: boolean) => void;
|
||||
|
||||
// 언어 및 UI 설정 액션
|
||||
setLanguage: (language: Language) => void;
|
||||
setHistoryPanelPosition: (position: "left" | "right") => void;
|
||||
|
||||
setCurrentFile: (
|
||||
file: {
|
||||
name: string;
|
||||
@@ -92,6 +101,8 @@ interface AppState {
|
||||
const initialState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
language: "ko" as Language,
|
||||
historyPanelPosition: "right" as "left" | "right",
|
||||
currentFile: null,
|
||||
sheets: [],
|
||||
activeSheetId: null,
|
||||
@@ -123,6 +134,16 @@ export const useAppStore = create<AppState>()(
|
||||
setAuthenticated: (authenticated) =>
|
||||
set({ isAuthenticated: authenticated }),
|
||||
|
||||
// 언어 및 UI 설정 액션
|
||||
setLanguage: (language) => {
|
||||
set({ language });
|
||||
localStorage.setItem("sheeteasy-language", language);
|
||||
},
|
||||
setHistoryPanelPosition: (position) => {
|
||||
set({ historyPanelPosition: position });
|
||||
localStorage.setItem("sheeteasy-history-panel-position", position);
|
||||
},
|
||||
|
||||
// 파일 및 시트 액션
|
||||
setCurrentFile: (file) => set({ currentFile: file }),
|
||||
setSheets: (sheets) => set({ sheets }),
|
||||
|
||||
Reference in New Issue
Block a user