Changes
This commit is contained in:
791
package-lock.json
generated
791
package-lock.json
generated
@@ -38,6 +38,11 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@supabase/supabase-js": "^2.88.0",
|
"@supabase/supabase-js": "^2.88.0",
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
|
"@tiptap/extension-image": "^3.13.0",
|
||||||
|
"@tiptap/extension-link": "^3.13.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.13.0",
|
||||||
|
"@tiptap/react": "^3.13.0",
|
||||||
|
"@tiptap/starter-kit": "^3.13.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -2267,6 +2272,12 @@
|
|||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@remirror/core-constants": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.0",
|
"version": "1.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||||
@@ -2868,6 +2879,466 @@
|
|||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/core": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-iUelgiTMgPVMpY5ZqASUpk8mC8HuR9FWKaDzK27w9oWip9tuB54Z8mePTxNcQaSPb6ErzEaC8x8egrRt7OsdGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/pm": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-K1z/PAIIwEmiWbzrP//4cC7iG1TZknDlF1yb42G7qkx2S2X4P0NiqX7sKOej3yqrPjKjGwPujLMSuDnCF87QkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bold": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-VYiDN9EEwR6ShaDLclG8mphkb/wlIzqfk7hxaKboq1G+NSDj8PcaSI9hldKKtTCLeaSNu6UR5nkdu/YHdzYWTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bubble-menu": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-qZ3j2DBsqP9DjG2UlExQ+tHMRhAnWlCKNreKddKocb/nAFrPdBCtvkqIEu+68zPlbLD4ukpoyjUklRJg+NipFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0",
|
||||||
|
"@tiptap/pm": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bullet-list": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-fFQmmEUoPzRGiQJ/KKutG35ZX21GE+1UCDo8Q6PoWH7Al9lex47nvyeU1BiDYOhcTKgIaJRtEH5lInsOsRJcSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-sF5raBni6iSVpXWvwJCAcOXw5/kZ+djDHx1YSGWhopm4+fsj0xW7GvVO+VTwiFjZGKSw+K5NeAxzcQTJZd3Vhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code-block": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-kIwfQ4iqootsWg9e74iYJK54/YMIj6ahUxEltjZRML5z/h4gTDcQt2eTpnEC8yjDjHeUVOR94zH9auCySyk9CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0",
|
||||||
|
"@tiptap/pm": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-document": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-RjU7hTJwjKXIdY57o/Pc+Yr8swLkrwT7PBQ/m+LCX5oO/V2wYoWCjoBYnK5KSHrWlNy/aLzC33BvLeqZZ9nzlQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-dropcursor": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-m7GPT3c/83ni+bbU8c+3dpNa8ug+aQ4phNB1Q52VQG3oTonDJnZS7WCtn3lB/Hi1LqoqMtEHwhepU2eD+JeXqQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-floating-menu": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-OsezV2cMofZM4c13gvgi93IEYBUzZgnu8BXTYZQiQYekz4bX4uulBmLa1KOA9EN71FzS+SoLkXHU0YzlbLjlxA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0",
|
||||||
|
"@tiptap/core": "^3.13.0",
|
||||||
|
"@tiptap/pm": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-gapcursor": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-KVxjQKkd964nin+1IdM2Dvej/Jy4JTMcMgq5seusUhJ9T9P8F9s2D5Iefwgkps3OCzub/aF+eAsZe+1P5KSIgA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-hard-break": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-nH1OBaO+/pakhu+P1jF208mPgB70IKlrR/9d46RMYoYbqJTNf4KVLx5lHAOHytIhjcNg+MjyTfJWfkK+dyCCyg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-heading": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-8VKWX8waYPtUWN97J89em9fOtxNteh6pvUEd0htcOAtoxjt2uZjbW5N4lKyWhNKifZBrVhH2Cc2NUPuftCVgxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-ZUFyORtjj22ib8ykbxRhWFQOTZjNKqOsMQjaAGof30cuD2DN5J5pMz7Haj2fFRtLpugWYH+f0Mi+WumQXC3hCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0",
|
||||||
|
"@tiptap/pm": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-image": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-223uzLUkIa1rkK7aQK3AcIXe6LbCtmnpVb7sY5OEp+LpSaSPyXwyrZ4A0EO1o98qXG68/0B2OqMntFtA9c5Fbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-italic": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-XbVTgmzk1kgUMTirA6AGdLTcKHUvEJoh3R4qMdPtwwygEOe7sBuvKuLtF6AwUtpnOM+Y3tfWUTNEDWv9AcEdww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-link": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-LuFPJ5GoL12GHW4A+USsj60O90pLcwUPdvEUSWewl9USyG6gnLnY/j5ZOXPYH7LiwYW8+lhq7ABwrDF2PKyBbA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linkifyjs": "^4.3.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0",
|
||||||
|
"@tiptap/pm": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-MMFH0jQ4LeCPkJJFyZ77kt6eM/vcKujvTbMzW1xSHCIEA6s4lEcx9QdZMPpfmnOvTzeoVKR4nsu2t2qT9ZXzAw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0",
|
||||||
|
"@tiptap/pm": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-63NbcS/XeQP2jcdDEnEAE3rjJICDj8y1SN1h/MsJmSt1LusnEo8WQ2ub86QELO6XnD3M04V03cY6Knf6I5mTkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-keymap": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-P+HtIa1iwosb1feFc8B/9MN5EAwzS+/dZ0UH0CTF2E4wnp5Z9OMxKl1IYjfiCwHzZrU5Let+S/maOvJR/EmV0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-ordered-list": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-QuDyLzuK/3vCvx9GeKhgvHWrGECBzmJyAx6gli2HY+Iil7XicbfltV4nvhIxgxzpx3LDHLKzJN9pBi+2MzX60g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-paragraph": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-9csQde1i0yeZI5oQQ9e1GYNtGL2JcC2d8Fwtw9FsGC8yz2W0h+Fmk+3bc2kobbtO5LGqupSc1fKM8fAg5rSRDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-placeholder": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-Au4ktRBraQktX9gjSzGWyJV6kPof7+kOhzE8ej+rOMjIrHbx3DCHy1CJWftSO9BbqIyonjsFmm4nE+vjzZ3Z5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-strike": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-VHhWNqTAMOfrC48m2FcPIZB0nhl6XHQviAV16SBc+EFznKNv9tQUsqQrnuQ2y6ZVfqq5UxvZ3hKF/JlN/Ff7xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-text": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-VcZIna93rixw7hRkHGCxDbL3kvJWi80vIT25a2pXg0WP1e7Pi3nBYvZIL4SQtkbBCji9EHrbZx3p8nNPzfazYw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-underline": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-VDQi+UYw0tFnfghpthJTFmtJ3yx90kXeDwFvhmT8G+O+si5VmP05xYDBYBmYCix5jqKigJxEASiBL0gYOgMDEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extensions": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-i7O0ptSibEtTy+2PIPsNKEvhTvMaFJg1W4Oxfnbuxvaigs7cJV9Q0lwDUcc7CPsNw2T1+44wcxg431CzTvdYoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0",
|
||||||
|
"@tiptap/pm": "^3.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/pm": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-WKR4ucALq+lwx0WJZW17CspeTpXorbIOpvKv5mulZica6QxqfMhn8n1IXCkDws/mCoLRx4Drk5d377tIjFNsvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-changeset": "^2.3.0",
|
||||||
|
"prosemirror-collab": "^1.3.1",
|
||||||
|
"prosemirror-commands": "^1.6.2",
|
||||||
|
"prosemirror-dropcursor": "^1.8.1",
|
||||||
|
"prosemirror-gapcursor": "^1.3.2",
|
||||||
|
"prosemirror-history": "^1.4.1",
|
||||||
|
"prosemirror-inputrules": "^1.4.0",
|
||||||
|
"prosemirror-keymap": "^1.2.2",
|
||||||
|
"prosemirror-markdown": "^1.13.1",
|
||||||
|
"prosemirror-menu": "^1.2.4",
|
||||||
|
"prosemirror-model": "^1.24.1",
|
||||||
|
"prosemirror-schema-basic": "^1.2.3",
|
||||||
|
"prosemirror-schema-list": "^1.5.0",
|
||||||
|
"prosemirror-state": "^1.4.3",
|
||||||
|
"prosemirror-tables": "^1.6.4",
|
||||||
|
"prosemirror-trailing-node": "^3.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2",
|
||||||
|
"prosemirror-view": "^1.38.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/react": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-VqpqNZ9qtPr3pWK4NsZYxXgLSEiAnzl6oS7tEGmkkvJbcGSC+F7R13Xc9twv/zT5QCLxaHdEbmxHbuAIkrMgJQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"fast-equals": "^5.3.3",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tiptap/extension-bubble-menu": "^3.13.0",
|
||||||
|
"@tiptap/extension-floating-menu": "^3.13.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0",
|
||||||
|
"@tiptap/pm": "^3.13.0",
|
||||||
|
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/starter-kit": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-Ojn6sRub04CRuyQ+9wqN62JUOMv+rG1vXhc2s6DCBCpu28lkCMMW+vTe7kXJcEdbot82+5swPbERw9vohswFzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/core": "^3.13.0",
|
||||||
|
"@tiptap/extension-blockquote": "^3.13.0",
|
||||||
|
"@tiptap/extension-bold": "^3.13.0",
|
||||||
|
"@tiptap/extension-bullet-list": "^3.13.0",
|
||||||
|
"@tiptap/extension-code": "^3.13.0",
|
||||||
|
"@tiptap/extension-code-block": "^3.13.0",
|
||||||
|
"@tiptap/extension-document": "^3.13.0",
|
||||||
|
"@tiptap/extension-dropcursor": "^3.13.0",
|
||||||
|
"@tiptap/extension-gapcursor": "^3.13.0",
|
||||||
|
"@tiptap/extension-hard-break": "^3.13.0",
|
||||||
|
"@tiptap/extension-heading": "^3.13.0",
|
||||||
|
"@tiptap/extension-horizontal-rule": "^3.13.0",
|
||||||
|
"@tiptap/extension-italic": "^3.13.0",
|
||||||
|
"@tiptap/extension-link": "^3.13.0",
|
||||||
|
"@tiptap/extension-list": "^3.13.0",
|
||||||
|
"@tiptap/extension-list-item": "^3.13.0",
|
||||||
|
"@tiptap/extension-list-keymap": "^3.13.0",
|
||||||
|
"@tiptap/extension-ordered-list": "^3.13.0",
|
||||||
|
"@tiptap/extension-paragraph": "^3.13.0",
|
||||||
|
"@tiptap/extension-strike": "^3.13.0",
|
||||||
|
"@tiptap/extension-text": "^3.13.0",
|
||||||
|
"@tiptap/extension-underline": "^3.13.0",
|
||||||
|
"@tiptap/extensions": "^3.13.0",
|
||||||
|
"@tiptap/pm": "^3.13.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/d3-array": {
|
"node_modules/@types/d3-array": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||||
@@ -2945,6 +3416,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/markdown-it": {
|
||||||
|
"version": "14.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||||
|
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/linkify-it": "^5",
|
||||||
|
"@types/mdurl": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.16.5",
|
"version": "22.16.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.5.tgz",
|
||||||
@@ -2988,6 +3481,12 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -3370,7 +3869,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/aria-hidden": {
|
"node_modules/aria-hidden": {
|
||||||
@@ -3669,6 +4167,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3943,6 +4447,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@@ -3996,7 +4512,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -4197,9 +4712,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-equals": {
|
"node_modules/fast-equals": {
|
||||||
"version": "5.2.2",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||||
"integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
|
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -4745,6 +5260,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linkifyjs": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -5259,6 +5789,29 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/markdown-it": {
|
||||||
|
"version": "14.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||||
|
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1",
|
||||||
|
"entities": "^4.4.0",
|
||||||
|
"linkify-it": "^5.0.0",
|
||||||
|
"mdurl": "^2.0.0",
|
||||||
|
"punycode.js": "^2.3.1",
|
||||||
|
"uc.micro": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"markdown-it": "bin/markdown-it.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -5425,6 +5978,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/orderedmap": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/p-limit": {
|
"node_modules/p-limit": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
@@ -5738,6 +6297,201 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/prosemirror-changeset": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-collab": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-commands": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-dropcursor": {
|
||||||
|
"version": "1.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||||
|
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0",
|
||||||
|
"prosemirror-view": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-gapcursor": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.0.0",
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-history": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.2.2",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.31.0",
|
||||||
|
"rope-sequence": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-inputrules": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-keymap": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"w3c-keyname": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-markdown": {
|
||||||
|
"version": "1.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz",
|
||||||
|
"integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^14.0.0",
|
||||||
|
"markdown-it": "^14.0.0",
|
||||||
|
"prosemirror-model": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-menu": {
|
||||||
|
"version": "1.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz",
|
||||||
|
"integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crelt": "^1.0.0",
|
||||||
|
"prosemirror-commands": "^1.0.0",
|
||||||
|
"prosemirror-history": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-model": {
|
||||||
|
"version": "1.25.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||||
|
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"orderedmap": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-basic": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-list": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-state": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.27.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-tables": {
|
||||||
|
"version": "1.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.3.tgz",
|
||||||
|
"integrity": "sha512-wbqCR/RlRPRe41a4LFtmhKElzBEfBTdtAYWNIGHM6X2e24NN/MTNUKyXjjphfAfdQce37Kh/5yf765mLPYDe7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.4",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-transform": "^1.10.5",
|
||||||
|
"prosemirror-view": "^1.41.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-trailing-node": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@remirror/core-constants": "3.0.0",
|
||||||
|
"escape-string-regexp": "^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prosemirror-model": "^1.22.1",
|
||||||
|
"prosemirror-state": "^1.4.2",
|
||||||
|
"prosemirror-view": "^1.33.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-transform": {
|
||||||
|
"version": "1.10.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz",
|
||||||
|
"integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-view": {
|
||||||
|
"version": "1.41.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||||
|
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.20.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -5748,6 +6502,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -6102,6 +6865,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rope-sequence": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/run-parallel": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
@@ -6546,6 +7315,12 @@
|
|||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <5.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
@@ -6747,6 +7522,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -41,6 +41,11 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@supabase/supabase-js": "^2.88.0",
|
"@supabase/supabase-js": "^2.88.0",
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
|
"@tiptap/extension-image": "^3.13.0",
|
||||||
|
"@tiptap/extension-link": "^3.13.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.13.0",
|
||||||
|
"@tiptap/react": "^3.13.0",
|
||||||
|
"@tiptap/starter-kit": "^3.13.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import Checkout from "./pages/Checkout";
|
|||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import Admin from "./pages/Admin";
|
import Admin from "./pages/Admin";
|
||||||
import Bootcamp from "./pages/Bootcamp";
|
import Bootcamp from "./pages/Bootcamp";
|
||||||
|
import Events from "./pages/Events";
|
||||||
|
import AdminEvents from "./pages/admin/AdminEvents";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -32,7 +34,13 @@ const App = () => (
|
|||||||
<Route path="/products/:slug" element={<ProductDetail />} />
|
<Route path="/products/:slug" element={<ProductDetail />} />
|
||||||
<Route path="/checkout" element={<Checkout />} />
|
<Route path="/checkout" element={<Checkout />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/access" element={<Dashboard />} />
|
||||||
|
<Route path="/orders" element={<Dashboard />} />
|
||||||
|
<Route path="/profile" element={<Dashboard />} />
|
||||||
|
<Route path="/events" element={<Events />} />
|
||||||
<Route path="/admin" element={<Admin />} />
|
<Route path="/admin" element={<Admin />} />
|
||||||
|
<Route path="/admin/products" element={<Admin />} />
|
||||||
|
<Route path="/admin/events" element={<AdminEvents />} />
|
||||||
<Route path="/bootcamp/:slug" element={<Bootcamp />} />
|
<Route path="/bootcamp/:slug" element={<Bootcamp />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
246
src/components/AppLayout.tsx
Normal file
246
src/components/AppLayout.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { ReactNode, useState } from 'react';
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useCart } from '@/contexts/CartContext';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Package,
|
||||||
|
BookOpen,
|
||||||
|
ShoppingCart,
|
||||||
|
Receipt,
|
||||||
|
User,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
Calendar,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
Home,
|
||||||
|
MoreHorizontal,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userNavItems: NavItem[] = [
|
||||||
|
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||||
|
{ label: 'Produk', href: '/products', icon: Package },
|
||||||
|
{ label: 'Akses', href: '/access', icon: BookOpen },
|
||||||
|
{ label: 'Order', href: '/orders', icon: Receipt },
|
||||||
|
{ label: 'Profil', href: '/profile', icon: User },
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminNavItems: NavItem[] = [
|
||||||
|
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||||
|
{ label: 'Produk', href: '/admin/products', icon: Package },
|
||||||
|
{ label: 'Bootcamp', href: '/admin/bootcamp', icon: BookOpen },
|
||||||
|
{ label: 'Order', href: '/admin/orders', icon: Receipt },
|
||||||
|
{ label: 'Member', href: '/admin/members', icon: Users },
|
||||||
|
{ label: 'Kalender', href: '/admin/events', icon: Calendar },
|
||||||
|
{ label: 'Pengaturan', href: '/admin/settings', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mobileUserNav: NavItem[] = [
|
||||||
|
{ label: 'Home', href: '/dashboard', icon: Home },
|
||||||
|
{ label: 'Kelas', href: '/access', icon: BookOpen },
|
||||||
|
{ label: 'Pesanan', href: '/orders', icon: Receipt },
|
||||||
|
{ label: 'Profil', href: '/profile', icon: User },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mobileAdminNav: NavItem[] = [
|
||||||
|
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||||
|
{ label: 'Produk', href: '/admin/products', icon: Package },
|
||||||
|
{ label: 'Pesanan', href: '/admin/orders', icon: Receipt },
|
||||||
|
{ label: 'Pengguna', href: '/admin/members', icon: Users },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AppLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppLayout({ children }: AppLayoutProps) {
|
||||||
|
const { user, isAdmin, signOut } = useAuth();
|
||||||
|
const { items } = useCart();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
|
||||||
|
const navItems = isAdmin ? adminNavItems : userNavItems;
|
||||||
|
const mobileNav = isAdmin ? mobileAdminNav : mobileUserNav;
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
await signOut();
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActive = (href: string) => {
|
||||||
|
if (href === '/dashboard' && location.pathname === '/dashboard') return true;
|
||||||
|
if (href === '/admin' && location.pathname === '/admin') return true;
|
||||||
|
if (href !== '/dashboard' && href !== '/admin') {
|
||||||
|
return location.pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get additional items for "More" menu
|
||||||
|
const moreItems = navItems.filter(item => !mobileNav.some(m => m.href === item.href));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Public layout for non-authenticated pages
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<header className="border-b-2 border-border bg-background sticky top-0 z-50">
|
||||||
|
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
|
<Link to="/" className="text-2xl font-bold">LearnHub</Link>
|
||||||
|
<nav className="flex items-center gap-4">
|
||||||
|
<Link to="/products" className="hover:underline font-medium">Produk</Link>
|
||||||
|
<Link to="/events" className="hover:underline font-medium">Kalender</Link>
|
||||||
|
<Link to="/auth">
|
||||||
|
<Button variant="outline" size="sm" className="border-2">
|
||||||
|
<User className="w-4 h-4 mr-2" />
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/checkout">
|
||||||
|
<Button variant="outline" size="sm" className="relative border-2">
|
||||||
|
<ShoppingCart className="w-4 h-4" />
|
||||||
|
{items.length > 0 && (
|
||||||
|
<span className="absolute -top-2 -right-2 bg-primary text-primary-foreground text-xs w-5 h-5 flex items-center justify-center">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex">
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
<aside className="hidden md:flex flex-col w-64 border-r-2 border-border bg-sidebar fixed h-screen">
|
||||||
|
<div className="p-4 border-b-2 border-border">
|
||||||
|
<Link to="/" className="text-xl font-bold">LearnHub</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
to={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-3 py-2 rounded-none text-sm font-medium transition-colors",
|
||||||
|
isActive(item.href)
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t-2 border-border space-y-2">
|
||||||
|
<Link to="/checkout" className="flex items-center gap-3 px-3 py-2 hover:bg-muted text-sm font-medium">
|
||||||
|
<ShoppingCart className="w-5 h-5" />
|
||||||
|
Keranjang
|
||||||
|
{items.length > 0 && (
|
||||||
|
<span className="ml-auto bg-primary text-primary-foreground text-xs px-2 py-0.5">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 hover:bg-muted text-sm font-medium w-full text-left"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 md:ml-64">
|
||||||
|
{/* Mobile Header */}
|
||||||
|
<header className="md:hidden sticky top-0 z-50 border-b-2 border-border bg-background px-4 py-3 flex items-center justify-between">
|
||||||
|
<Link to="/" className="text-xl font-bold">LearnHub</Link>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link to="/checkout" className="relative p-2">
|
||||||
|
<ShoppingCart className="w-5 h-5" />
|
||||||
|
{items.length > 0 && (
|
||||||
|
<span className="absolute top-0 right-0 bg-primary text-primary-foreground text-xs w-4 h-4 flex items-center justify-center">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="pb-20 md:pb-0">{children}</main>
|
||||||
|
|
||||||
|
{/* Mobile Bottom Navigation */}
|
||||||
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 border-t-2 border-border bg-background z-50">
|
||||||
|
<div className="flex items-center justify-around py-2">
|
||||||
|
{mobileNav.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
to={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-1 px-3 py-1 text-xs font-medium",
|
||||||
|
isActive(item.href) ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{moreItems.length > 0 && (
|
||||||
|
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<button className="flex flex-col items-center gap-1 px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
<MoreHorizontal className="w-5 h-5" />
|
||||||
|
Lainnya
|
||||||
|
</button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="bottom" className="border-t-2 border-border">
|
||||||
|
<div className="space-y-2 py-4">
|
||||||
|
{moreItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
to={item.href}
|
||||||
|
onClick={() => setMoreOpen(false)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 hover:bg-muted text-sm font-medium"
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => { handleSignOut(); setMoreOpen(false); }}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 hover:bg-muted text-sm font-medium w-full text-left text-destructive"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
src/components/RichTextEditor.tsx
Normal file
209
src/components/RichTextEditor.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import Link from '@tiptap/extension-link';
|
||||||
|
import Image from '@tiptap/extension-image';
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon,
|
||||||
|
Image as ImageIcon, Heading1, Heading2, Undo, Redo
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
interface RichTextEditorProps {
|
||||||
|
content: string;
|
||||||
|
onChange: (html: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten...', className }: RichTextEditorProps) {
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'text-primary underline',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Image.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'max-w-full h-auto rounded-md',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
onChange(editor.getHTML());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addLink = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const url = window.prompt('Masukkan URL:');
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||||
|
}
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleImageUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file || !editor) return;
|
||||||
|
|
||||||
|
// For now, convert to base64 data URL since storage bucket may not be configured
|
||||||
|
// In production, you would upload to Supabase Storage
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = reader.result as string;
|
||||||
|
editor.chain().focus().setImage({ src: dataUrl }).run();
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items || !editor) return;
|
||||||
|
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (!file) continue;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = reader.result as string;
|
||||||
|
editor.chain().focus().setImage({ src: dataUrl }).run();
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
if (!editor) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("border-2 border-border rounded-md", className)}>
|
||||||
|
<div className="flex flex-wrap gap-1 p-2 border-b-2 border-border bg-muted">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
className={editor.isActive('bold') ? 'bg-accent' : ''}
|
||||||
|
>
|
||||||
|
<Bold className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
className={editor.isActive('italic') ? 'bg-accent' : ''}
|
||||||
|
>
|
||||||
|
<Italic className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
className={editor.isActive('heading', { level: 1 }) ? 'bg-accent' : ''}
|
||||||
|
>
|
||||||
|
<Heading1 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
className={editor.isActive('heading', { level: 2 }) ? 'bg-accent' : ''}
|
||||||
|
>
|
||||||
|
<Heading2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
className={editor.isActive('bulletList') ? 'bg-accent' : ''}
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
className={editor.isActive('orderedList') ? 'bg-accent' : ''}
|
||||||
|
>
|
||||||
|
<ListOrdered className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
className={editor.isActive('blockquote') ? 'bg-accent' : ''}
|
||||||
|
>
|
||||||
|
<Quote className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={addLink}
|
||||||
|
className={editor.isActive('link') ? 'bg-accent' : ''}
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<label>
|
||||||
|
<Button type="button" variant="ghost" size="sm" asChild>
|
||||||
|
<span>
|
||||||
|
<ImageIcon className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
|
disabled={!editor.can().undo()}
|
||||||
|
>
|
||||||
|
<Undo className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
|
disabled={!editor.can().redo()}
|
||||||
|
>
|
||||||
|
<Redo className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div onPaste={handlePaste}>
|
||||||
|
<EditorContent
|
||||||
|
editor={editor}
|
||||||
|
className="prose prose-sm max-w-none p-4 min-h-[200px] focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[180px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/lib/format.ts
Normal file
78
src/lib/format.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Format duration in seconds to HH:mm:ss or mm:ss
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds: number | string | null | undefined): string {
|
||||||
|
if (seconds === null || seconds === undefined) return '';
|
||||||
|
|
||||||
|
// If it's already formatted (contains :), return as-is
|
||||||
|
if (typeof seconds === 'string' && seconds.includes(':')) {
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numSeconds = typeof seconds === 'string' ? parseInt(seconds, 10) : seconds;
|
||||||
|
if (isNaN(numSeconds) || numSeconds < 0) return '';
|
||||||
|
|
||||||
|
const hours = Math.floor(numSeconds / 3600);
|
||||||
|
const minutes = Math.floor((numSeconds % 3600) / 60);
|
||||||
|
const secs = numSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format price in IDR
|
||||||
|
*/
|
||||||
|
export function formatIDR(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount).replace('IDR', 'Rp');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse duration string (HH:mm:ss or mm:ss) to seconds
|
||||||
|
*/
|
||||||
|
export function parseDurationToSeconds(duration: string): number {
|
||||||
|
if (!duration) return 0;
|
||||||
|
|
||||||
|
// If it's already a number string, return as seconds
|
||||||
|
if (/^\d+$/.test(duration.trim())) {
|
||||||
|
return parseInt(duration, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = duration.split(':').map(p => parseInt(p, 10));
|
||||||
|
|
||||||
|
if (parts.length === 3) {
|
||||||
|
// HH:mm:ss
|
||||||
|
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
// mm:ss
|
||||||
|
return parts[0] * 60 + parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date to Indonesian locale
|
||||||
|
*/
|
||||||
|
export function formatDate(date: string | Date): string {
|
||||||
|
return new Intl.DateTimeFormat('id-ID', {
|
||||||
|
dateStyle: 'long',
|
||||||
|
}).format(new Date(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format datetime to Indonesian locale
|
||||||
|
*/
|
||||||
|
export function formatDateTime(date: string | Date): string {
|
||||||
|
return new Intl.DateTimeFormat('id-ID', {
|
||||||
|
dateStyle: 'long',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(date));
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { ChevronLeft, ChevronRight, Check, Play, BookOpen } from 'lucide-react';
|
import { formatDuration } from '@/lib/format';
|
||||||
|
import { ChevronLeft, ChevronRight, Check, Play, BookOpen, Clock, Menu, X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +29,7 @@ interface Lesson {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
video_url: string | null;
|
video_url: string | null;
|
||||||
|
duration_seconds: number | null;
|
||||||
position: number;
|
position: number;
|
||||||
release_at: string | null;
|
release_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -40,14 +43,14 @@ export default function Bootcamp() {
|
|||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
const [product, setProduct] = useState<Product | null>(null);
|
const [product, setProduct] = useState<Product | null>(null);
|
||||||
const [modules, setModules] = useState<Module[]>([]);
|
const [modules, setModules] = useState<Module[]>([]);
|
||||||
const [progress, setProgress] = useState<Progress[]>([]);
|
const [progress, setProgress] = useState<Progress[]>([]);
|
||||||
const [selectedLesson, setSelectedLesson] = useState<Lesson | null>(null);
|
const [selectedLesson, setSelectedLesson] = useState<Lesson | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [hasAccess, setHasAccess] = useState(false);
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !user) {
|
if (!authLoading && !user) {
|
||||||
@@ -58,7 +61,6 @@ export default function Bootcamp() {
|
|||||||
}, [user, authLoading, slug]);
|
}, [user, authLoading, slug]);
|
||||||
|
|
||||||
const checkAccessAndFetch = async () => {
|
const checkAccessAndFetch = async () => {
|
||||||
// First get the product
|
|
||||||
const { data: productData, error: productError } = await supabase
|
const { data: productData, error: productError } = await supabase
|
||||||
.from('products')
|
.from('products')
|
||||||
.select('id, title, slug')
|
.select('id, title, slug')
|
||||||
@@ -67,14 +69,13 @@ export default function Bootcamp() {
|
|||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (productError || !productData) {
|
if (productError || !productData) {
|
||||||
toast({ title: 'Error', description: 'Bootcamp not found', variant: 'destructive' });
|
toast({ title: 'Error', description: 'Bootcamp tidak ditemukan', variant: 'destructive' });
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProduct(productData);
|
setProduct(productData);
|
||||||
|
|
||||||
// Check access
|
|
||||||
const { data: accessData } = await supabase
|
const { data: accessData } = await supabase
|
||||||
.from('user_access')
|
.from('user_access')
|
||||||
.select('id')
|
.select('id')
|
||||||
@@ -83,14 +84,11 @@ export default function Bootcamp() {
|
|||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (!accessData) {
|
if (!accessData) {
|
||||||
toast({ title: 'Access denied', description: 'You don\'t have access to this bootcamp', variant: 'destructive' });
|
toast({ title: 'Akses ditolak', description: 'Anda tidak memiliki akses ke bootcamp ini', variant: 'destructive' });
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setHasAccess(true);
|
|
||||||
|
|
||||||
// Fetch modules with lessons
|
|
||||||
const { data: modulesData } = await supabase
|
const { data: modulesData } = await supabase
|
||||||
.from('bootcamp_modules')
|
.from('bootcamp_modules')
|
||||||
.select(`
|
.select(`
|
||||||
@@ -102,6 +100,7 @@ export default function Bootcamp() {
|
|||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
video_url,
|
video_url,
|
||||||
|
duration_seconds,
|
||||||
position,
|
position,
|
||||||
release_at
|
release_at
|
||||||
)
|
)
|
||||||
@@ -116,13 +115,11 @@ export default function Bootcamp() {
|
|||||||
}));
|
}));
|
||||||
setModules(sortedModules);
|
setModules(sortedModules);
|
||||||
|
|
||||||
// Select first lesson
|
|
||||||
if (sortedModules.length > 0 && sortedModules[0].lessons.length > 0) {
|
if (sortedModules.length > 0 && sortedModules[0].lessons.length > 0) {
|
||||||
setSelectedLesson(sortedModules[0].lessons[0]);
|
setSelectedLesson(sortedModules[0].lessons[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch progress
|
|
||||||
const { data: progressData } = await supabase
|
const { data: progressData } = await supabase
|
||||||
.from('lesson_progress')
|
.from('lesson_progress')
|
||||||
.select('lesson_id, completed_at')
|
.select('lesson_id, completed_at')
|
||||||
@@ -148,23 +145,20 @@ export default function Bootcamp() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
if (error.code === '23505') {
|
if (error.code === '23505') {
|
||||||
toast({ title: 'Already completed', description: 'This lesson is already marked as completed' });
|
toast({ title: 'Info', description: 'Pelajaran sudah ditandai selesai' });
|
||||||
} else {
|
} else {
|
||||||
toast({ title: 'Error', description: 'Failed to mark as completed', variant: 'destructive' });
|
toast({ title: 'Error', description: 'Gagal menandai selesai', variant: 'destructive' });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProgress([...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }]);
|
setProgress([...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }]);
|
||||||
toast({ title: 'Completed!', description: 'Lesson marked as completed' });
|
toast({ title: 'Selesai!', description: 'Pelajaran ditandai selesai' });
|
||||||
|
|
||||||
// Auto-advance to next lesson
|
|
||||||
goToNextLesson();
|
goToNextLesson();
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToNextLesson = () => {
|
const goToNextLesson = () => {
|
||||||
if (!selectedLesson) return;
|
if (!selectedLesson) return;
|
||||||
|
|
||||||
const allLessons = modules.flatMap(m => m.lessons);
|
const allLessons = modules.flatMap(m => m.lessons);
|
||||||
const currentIndex = allLessons.findIndex(l => l.id === selectedLesson.id);
|
const currentIndex = allLessons.findIndex(l => l.id === selectedLesson.id);
|
||||||
if (currentIndex < allLessons.length - 1) {
|
if (currentIndex < allLessons.length - 1) {
|
||||||
@@ -174,7 +168,6 @@ export default function Bootcamp() {
|
|||||||
|
|
||||||
const goToPrevLesson = () => {
|
const goToPrevLesson = () => {
|
||||||
if (!selectedLesson) return;
|
if (!selectedLesson) return;
|
||||||
|
|
||||||
const allLessons = modules.flatMap(m => m.lessons);
|
const allLessons = modules.flatMap(m => m.lessons);
|
||||||
const currentIndex = allLessons.findIndex(l => l.id === selectedLesson.id);
|
const currentIndex = allLessons.findIndex(l => l.id === selectedLesson.id);
|
||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
@@ -183,27 +176,70 @@ export default function Bootcamp() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getVideoEmbed = (url: string) => {
|
const getVideoEmbed = (url: string) => {
|
||||||
// Handle YouTube URLs
|
|
||||||
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
||||||
if (youtubeMatch) {
|
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
||||||
return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
|
||||||
}
|
|
||||||
// Handle Vimeo URLs
|
|
||||||
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
|
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
|
||||||
if (vimeoMatch) {
|
if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
|
||||||
return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
|
|
||||||
}
|
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
const completedCount = progress.length;
|
const completedCount = progress.length;
|
||||||
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
||||||
|
|
||||||
|
const renderSidebarContent = () => (
|
||||||
|
<div className="p-4">
|
||||||
|
{modules.map((module) => (
|
||||||
|
<div key={module.id} className="mb-4">
|
||||||
|
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
{module.title}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{module.lessons.map((lesson) => {
|
||||||
|
const isCompleted = isLessonCompleted(lesson.id);
|
||||||
|
const isSelected = selectedLesson?.id === lesson.id;
|
||||||
|
const isReleased = !lesson.release_at || new Date(lesson.release_at) <= new Date();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={lesson.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (isReleased) {
|
||||||
|
setSelectedLesson(lesson);
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isReleased}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-3 py-2 rounded-none text-sm flex items-center gap-2 transition-colors",
|
||||||
|
isSelected ? "bg-primary text-primary-foreground" : "hover:bg-muted",
|
||||||
|
!isReleased && "opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<Check className="w-4 h-4 shrink-0 text-accent" />
|
||||||
|
) : lesson.video_url ? (
|
||||||
|
<Play className="w-4 h-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<BookOpen className="w-4 h-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate flex-1">{lesson.title}</span>
|
||||||
|
{lesson.duration_seconds && (
|
||||||
|
<span className="text-xs text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (authLoading || loading) {
|
if (authLoading || loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="w-80 border-r border-border p-4">
|
<div className="w-80 border-r border-border p-4 hidden md:block">
|
||||||
<Skeleton className="h-8 w-full mb-4" />
|
<Skeleton className="h-8 w-full mb-4" />
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<Skeleton key={i} className="h-10 w-full mb-2" />
|
<Skeleton key={i} className="h-10 w-full mb-2" />
|
||||||
@@ -221,94 +257,77 @@ export default function Bootcamp() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b border-border bg-card">
|
<header className="border-b-2 border-border bg-card sticky top-0 z-50">
|
||||||
<div className="flex items-center justify-between px-4 h-16">
|
<div className="flex items-center justify-between px-4 h-16">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate('/dashboard')}>
|
<Button variant="ghost" size="sm" onClick={() => navigate('/dashboard')}>
|
||||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||||
Back to Dashboard
|
<span className="hidden sm:inline">Kembali ke Dashboard</span>
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-xl font-bold">{product?.title}</h1>
|
<h1 className="text-lg md:text-xl font-bold truncate">{product?.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground hidden sm:inline">
|
||||||
{completedCount} / {totalLessons} completed
|
{completedCount} / {totalLessons} selesai
|
||||||
</span>
|
</span>
|
||||||
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
|
<div className="w-24 md:w-32 h-2 bg-muted rounded-none overflow-hidden border border-border">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary transition-all"
|
className="h-full bg-primary transition-all"
|
||||||
style={{ width: `${totalLessons > 0 ? (completedCount / totalLessons) * 100 : 0}%` }}
|
style={{ width: `${totalLessons > 0 ? (completedCount / totalLessons) * 100 : 0}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Mobile menu trigger */}
|
||||||
|
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="md:hidden">
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="w-80 p-0 border-r-2 border-border">
|
||||||
|
<div className="p-4 border-b-2 border-border font-bold">Kurikulum</div>
|
||||||
|
<div className="overflow-y-auto h-[calc(100vh-60px)]">
|
||||||
|
{renderSidebarContent()}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{/* Sidebar */}
|
{/* Desktop Sidebar */}
|
||||||
<aside className={cn(
|
<aside className={cn(
|
||||||
"border-r border-border bg-card transition-all overflow-y-auto h-[calc(100vh-64px)]",
|
"hidden md:block border-r-2 border-border bg-card transition-all overflow-y-auto h-[calc(100vh-64px)] sticky top-16",
|
||||||
sidebarOpen ? "w-80" : "w-0"
|
sidebarOpen ? "w-80" : "w-0"
|
||||||
)}>
|
)}>
|
||||||
{sidebarOpen && (
|
{sidebarOpen && renderSidebarContent()}
|
||||||
<div className="p-4">
|
|
||||||
{modules.map((module) => (
|
|
||||||
<div key={module.id} className="mb-4">
|
|
||||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide mb-2">
|
|
||||||
{module.title}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{module.lessons.map((lesson) => {
|
|
||||||
const isCompleted = isLessonCompleted(lesson.id);
|
|
||||||
const isSelected = selectedLesson?.id === lesson.id;
|
|
||||||
const isReleased = !lesson.release_at || new Date(lesson.release_at) <= new Date();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={lesson.id}
|
|
||||||
onClick={() => isReleased && setSelectedLesson(lesson)}
|
|
||||||
disabled={!isReleased}
|
|
||||||
className={cn(
|
|
||||||
"w-full text-left px-3 py-2 rounded-md text-sm flex items-center gap-2 transition-colors",
|
|
||||||
isSelected ? "bg-primary text-primary-foreground" : "hover:bg-muted",
|
|
||||||
!isReleased && "opacity-50 cursor-not-allowed"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isCompleted ? (
|
|
||||||
<Check className="w-4 h-4 shrink-0 text-accent" />
|
|
||||||
) : lesson.video_url ? (
|
|
||||||
<Play className="w-4 h-4 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<BookOpen className="w-4 h-4 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="truncate">{lesson.title}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Toggle sidebar button */}
|
{/* Toggle sidebar button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
className="absolute left-0 top-1/2 -translate-y-1/2 bg-card border border-border rounded-r-md p-1 z-10"
|
className="hidden md:flex absolute top-1/2 -translate-y-1/2 bg-card border-2 border-border rounded-r-none p-1 z-10"
|
||||||
style={{ left: sidebarOpen ? '320px' : '0' }}
|
style={{ left: sidebarOpen ? '320px' : '0' }}
|
||||||
>
|
>
|
||||||
{sidebarOpen ? <ChevronLeft className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
{sidebarOpen ? <ChevronLeft className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 p-8 h-[calc(100vh-64px)] overflow-y-auto">
|
<main className="flex-1 p-4 md:p-8 min-h-[calc(100vh-64px)] overflow-y-auto">
|
||||||
{selectedLesson ? (
|
{selectedLesson ? (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<h2 className="text-3xl font-bold mb-6">{selectedLesson.title}</h2>
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold">{selectedLesson.title}</h2>
|
||||||
|
{selectedLesson.duration_seconds && (
|
||||||
|
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
{formatDuration(selectedLesson.duration_seconds)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedLesson.video_url && (
|
{selectedLesson.video_url && (
|
||||||
<div className="aspect-video bg-muted rounded-lg overflow-hidden mb-6">
|
<div className="aspect-video bg-muted rounded-none overflow-hidden mb-6 border-2 border-border">
|
||||||
<iframe
|
<iframe
|
||||||
src={getVideoEmbed(selectedLesson.video_url)}
|
src={getVideoEmbed(selectedLesson.video_url)}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
@@ -321,7 +340,7 @@ export default function Bootcamp() {
|
|||||||
{selectedLesson.content && (
|
{selectedLesson.content && (
|
||||||
<Card className="border-2 border-border mb-6">
|
<Card className="border-2 border-border mb-6">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div
|
<div
|
||||||
className="prose max-w-none"
|
className="prose max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: selectedLesson.content }}
|
dangerouslySetInnerHTML={{ __html: selectedLesson.content }}
|
||||||
/>
|
/>
|
||||||
@@ -329,7 +348,7 @@ export default function Bootcamp() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={goToPrevLesson}
|
onClick={goToPrevLesson}
|
||||||
@@ -337,7 +356,7 @@ export default function Bootcamp() {
|
|||||||
className="border-2"
|
className="border-2"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||||
Previous
|
Sebelumnya
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -348,10 +367,10 @@ export default function Bootcamp() {
|
|||||||
{isLessonCompleted(selectedLesson.id) ? (
|
{isLessonCompleted(selectedLesson.id) ? (
|
||||||
<>
|
<>
|
||||||
<Check className="w-4 h-4 mr-2" />
|
<Check className="w-4 h-4 mr-2" />
|
||||||
Completed
|
Sudah Selesai
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Mark as Completed'
|
'Tandai Selesai'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -361,7 +380,7 @@ export default function Bootcamp() {
|
|||||||
disabled={modules.flatMap(m => m.lessons).findIndex(l => l.id === selectedLesson.id) === modules.flatMap(m => m.lessons).length - 1}
|
disabled={modules.flatMap(m => m.lessons).findIndex(l => l.id === selectedLesson.id) === modules.flatMap(m => m.lessons).length - 1}
|
||||||
className="border-2"
|
className="border-2"
|
||||||
>
|
>
|
||||||
Next
|
Selanjutnya
|
||||||
<ChevronRight className="w-4 h-4 ml-2" />
|
<ChevronRight className="w-4 h-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -372,7 +391,7 @@ export default function Bootcamp() {
|
|||||||
<CardContent className="py-12 text-center">
|
<CardContent className="py-12 text-center">
|
||||||
<BookOpen className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
<BookOpen className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{modules.length === 0 ? 'No lessons available yet' : 'Select a lesson to begin'}
|
{modules.length === 0 ? 'Belum ada pelajaran tersedia' : 'Pilih pelajaran untuk memulai'}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Layout } from '@/components/Layout';
|
import { AppLayout } from '@/components/AppLayout';
|
||||||
import { useCart } from '@/contexts/CartContext';
|
import { useCart } from '@/contexts/CartContext';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { formatIDR } from '@/lib/format';
|
||||||
|
import { Trash2, CreditCard, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
// Pakasir configuration - replace with your actual project slug
|
||||||
|
const PAKASIR_PROJECT_SLUG = 'learnhub'; // TODO: Replace with actual Pakasir project slug
|
||||||
|
|
||||||
export default function Checkout() {
|
export default function Checkout() {
|
||||||
const { items, removeItem, clearCart, total } = useCart();
|
const { items, removeItem, clearCart, total } = useCart();
|
||||||
@@ -17,84 +21,94 @@ export default function Checkout() {
|
|||||||
|
|
||||||
const handleCheckout = async () => {
|
const handleCheckout = async () => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
toast({ title: 'Login required', description: 'Please login to complete your purchase' });
|
toast({ title: 'Login diperlukan', description: 'Silakan login untuk melanjutkan pembayaran' });
|
||||||
navigate('/auth');
|
navigate('/auth');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
toast({ title: 'Cart is empty', description: 'Add some products to your cart first', variant: 'destructive' });
|
toast({ title: 'Keranjang kosong', description: 'Tambahkan produk ke keranjang terlebih dahulu', variant: 'destructive' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// Create order
|
try {
|
||||||
const { data: order, error: orderError } = await supabase
|
// Generate a unique order reference
|
||||||
.from('orders')
|
const orderRef = `ORD${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substring(2, 6).toUpperCase()}`;
|
||||||
.insert({
|
|
||||||
user_id: user.id,
|
|
||||||
total_amount: total,
|
|
||||||
status: 'pending'
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (orderError || !order) {
|
// Create order with pending payment status
|
||||||
toast({ title: 'Error', description: 'Failed to create order', variant: 'destructive' });
|
const { data: order, error: orderError } = await supabase
|
||||||
|
.from('orders')
|
||||||
|
.insert({
|
||||||
|
user_id: user.id,
|
||||||
|
total_amount: total,
|
||||||
|
status: 'pending',
|
||||||
|
payment_provider: 'pakasir',
|
||||||
|
payment_reference: orderRef,
|
||||||
|
payment_status: 'pending'
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (orderError || !order) {
|
||||||
|
throw new Error('Gagal membuat order');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create order items
|
||||||
|
const orderItems = items.map(item => ({
|
||||||
|
order_id: order.id,
|
||||||
|
product_id: item.id,
|
||||||
|
unit_price: item.sale_price ?? item.price,
|
||||||
|
quantity: 1
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { error: itemsError } = await supabase
|
||||||
|
.from('order_items')
|
||||||
|
.insert(orderItems);
|
||||||
|
|
||||||
|
if (itemsError) {
|
||||||
|
throw new Error('Gagal menambahkan item order');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build Pakasir payment URL
|
||||||
|
const amountInRupiah = Math.round(total);
|
||||||
|
const pakasirUrl = `https://app.pakasir.com/pay/${PAKASIR_PROJECT_SLUG}/${amountInRupiah}?order_id=${order.id}`;
|
||||||
|
|
||||||
|
// Clear cart and redirect to Pakasir
|
||||||
|
clearCart();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Mengarahkan ke pembayaran...',
|
||||||
|
description: 'Anda akan diarahkan ke halaman pembayaran Pakasir'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to Pakasir payment page
|
||||||
|
window.location.href = pakasirUrl;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Checkout error:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error instanceof Error ? error.message : 'Terjadi kesalahan saat checkout',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create order items
|
|
||||||
const orderItems = items.map(item => ({
|
|
||||||
order_id: order.id,
|
|
||||||
product_id: item.id,
|
|
||||||
unit_price: item.sale_price ?? item.price,
|
|
||||||
quantity: 1
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { error: itemsError } = await supabase
|
|
||||||
.from('order_items')
|
|
||||||
.insert(orderItems);
|
|
||||||
|
|
||||||
if (itemsError) {
|
|
||||||
toast({ title: 'Error', description: 'Failed to add order items', variant: 'destructive' });
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For demo: mark as paid and grant access
|
|
||||||
await supabase
|
|
||||||
.from('orders')
|
|
||||||
.update({ status: 'paid' })
|
|
||||||
.eq('id', order.id);
|
|
||||||
|
|
||||||
// Grant access to products
|
|
||||||
const accessRecords = items.map(item => ({
|
|
||||||
user_id: user.id,
|
|
||||||
product_id: item.id
|
|
||||||
}));
|
|
||||||
|
|
||||||
await supabase.from('user_access').insert(accessRecords);
|
|
||||||
|
|
||||||
clearCart();
|
|
||||||
toast({ title: 'Success', description: 'Your order has been placed!' });
|
|
||||||
navigate('/dashboard');
|
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<AppLayout>
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<h1 className="text-4xl font-bold mb-8">Checkout</h1>
|
<h1 className="text-4xl font-bold mb-8">Checkout</h1>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardContent className="py-12 text-center">
|
<CardContent className="py-12 text-center">
|
||||||
<p className="text-muted-foreground mb-4">Your cart is empty</p>
|
<p className="text-muted-foreground mb-4">Keranjang belanja Anda kosong</p>
|
||||||
<Button onClick={() => navigate('/products')} variant="outline" className="border-2">
|
<Button onClick={() => navigate('/products')} variant="outline" className="border-2">
|
||||||
Browse Products
|
Lihat Produk
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -111,11 +125,11 @@ export default function Checkout() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
${item.sale_price ?? item.price}
|
{formatIDR(item.sale_price ?? item.price)}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeItem(item.id)}
|
onClick={() => removeItem(item.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
@@ -130,22 +144,34 @@ export default function Checkout() {
|
|||||||
<div>
|
<div>
|
||||||
<Card className="border-2 border-border shadow-md sticky top-4">
|
<Card className="border-2 border-border shadow-md sticky top-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Order Summary</CardTitle>
|
<CardTitle>Ringkasan Order</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex justify-between text-lg">
|
<div className="flex justify-between text-lg">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span className="font-bold">${total}</span>
|
<span className="font-bold">{formatIDR(total)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCheckout}
|
onClick={handleCheckout}
|
||||||
className="w-full shadow-sm"
|
className="w-full shadow-sm"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? 'Processing...' : user ? 'Complete Purchase' : 'Login to Checkout'}
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Memproses...
|
||||||
|
</>
|
||||||
|
) : user ? (
|
||||||
|
<>
|
||||||
|
<CreditCard className="w-4 h-4 mr-2" />
|
||||||
|
Bayar Sekarang
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Login untuk Checkout'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
This is a demo checkout. No actual payment will be processed.
|
Pembayaran diproses melalui Pakasir (QRIS, Transfer Bank)
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -153,6 +179,6 @@ export default function Checkout() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Layout } from '@/components/Layout';
|
import { AppLayout } from '@/components/AppLayout';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -8,10 +8,31 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { formatIDR, formatDate } from '@/lib/format';
|
||||||
import { Video, Calendar, BookOpen, ArrowRight } from 'lucide-react';
|
import { Video, Calendar, BookOpen, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
interface UserAccess { id: string; granted_at: string; expires_at: string | null; product: { id: string; title: string; slug: string; type: string; meeting_link: string | null; recording_url: string | null; description: string; }; }
|
interface UserAccess {
|
||||||
interface Order { id: string; total_amount: number; status: string; created_at: string; }
|
id: string;
|
||||||
|
granted_at: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
product: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
type: string;
|
||||||
|
meeting_link: string | null;
|
||||||
|
recording_url: string | null;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Order {
|
||||||
|
id: string;
|
||||||
|
total_amount: number;
|
||||||
|
status: string;
|
||||||
|
payment_status: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
@@ -36,39 +57,158 @@ export default function Dashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) { case 'paid': return 'bg-accent'; case 'pending': return 'bg-secondary'; case 'cancelled': return 'bg-destructive'; case 'refunded': return 'bg-muted'; default: return 'bg-secondary'; }
|
switch (status) {
|
||||||
|
case 'paid': return 'bg-accent';
|
||||||
|
case 'pending': return 'bg-secondary';
|
||||||
|
case 'cancelled': return 'bg-destructive';
|
||||||
|
default: return 'bg-secondary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPaymentStatusLabel = (status: string | null) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'paid': return 'Lunas';
|
||||||
|
case 'pending': return 'Menunggu Pembayaran';
|
||||||
|
case 'failed': return 'Gagal';
|
||||||
|
default: return status || 'Pending';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAccessActions = (item: UserAccess) => {
|
const renderAccessActions = (item: UserAccess) => {
|
||||||
switch (item.product.type) {
|
switch (item.product.type) {
|
||||||
case 'consulting': return (<Button asChild variant="outline" className="border-2"><a href={item.product.meeting_link || '#'} target="_blank" rel="noopener noreferrer"><Calendar className="w-4 h-4 mr-2" />Book Session</a></Button>);
|
case 'consulting':
|
||||||
case 'webinar': return (<div className="flex gap-2">{item.product.meeting_link && <Button asChild variant="outline" className="border-2"><a href={item.product.meeting_link} target="_blank" rel="noopener noreferrer"><Video className="w-4 h-4 mr-2" />Join Live</a></Button>}{item.product.recording_url && <Button asChild variant="outline" className="border-2"><a href={item.product.recording_url} target="_blank" rel="noopener noreferrer"><Video className="w-4 h-4 mr-2" />Watch Recording</a></Button>}</div>);
|
return (
|
||||||
case 'bootcamp': return (<Button onClick={() => navigate(`/bootcamp/${item.product.slug}`)} className="shadow-sm"><BookOpen className="w-4 h-4 mr-2" />Continue Bootcamp<ArrowRight className="w-4 h-4 ml-2" /></Button>);
|
<Button asChild variant="outline" className="border-2">
|
||||||
default: return null;
|
<a href={item.product.meeting_link || '#'} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Calendar className="w-4 h-4 mr-2" />
|
||||||
|
Jadwalkan Konsultasi
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
case 'webinar':
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{item.product.meeting_link && (
|
||||||
|
<Button asChild variant="outline" className="border-2">
|
||||||
|
<a href={item.product.meeting_link} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Video className="w-4 h-4 mr-2" />
|
||||||
|
Gabung Webinar
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{item.product.recording_url && (
|
||||||
|
<Button asChild variant="outline" className="border-2">
|
||||||
|
<a href={item.product.recording_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Video className="w-4 h-4 mr-2" />
|
||||||
|
Tonton Rekaman
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'bootcamp':
|
||||||
|
return (
|
||||||
|
<Button onClick={() => navigate(`/bootcamp/${item.product.slug}`)} className="shadow-sm">
|
||||||
|
<BookOpen className="w-4 h-4 mr-2" />
|
||||||
|
Lanjutkan Bootcamp
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authLoading || loading) return (<Layout><div className="container mx-auto px-4 py-8"><Skeleton className="h-10 w-1/3 mb-8" /><div className="grid gap-4">{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}</div></div></Layout>);
|
if (authLoading || loading) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<Skeleton className="h-10 w-1/3 mb-8" />
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<AppLayout>
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<h1 className="text-4xl font-bold mb-2">Dashboard</h1>
|
<h1 className="text-4xl font-bold mb-2">Dashboard</h1>
|
||||||
<p className="text-muted-foreground mb-8">Manage your purchases and access your content</p>
|
<p className="text-muted-foreground mb-8">Kelola akses dan riwayat pembelian Anda</p>
|
||||||
|
|
||||||
<Tabs defaultValue="access" className="space-y-6">
|
<Tabs defaultValue="access" className="space-y-6">
|
||||||
<TabsList className="border-2 border-border"><TabsTrigger value="access">My Access</TabsTrigger><TabsTrigger value="orders">Order History</TabsTrigger></TabsList>
|
<TabsList className="border-2 border-border">
|
||||||
|
<TabsTrigger value="access">Akses Saya</TabsTrigger>
|
||||||
|
<TabsTrigger value="orders">Riwayat Order</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="access">
|
<TabsContent value="access">
|
||||||
{access.length === 0 ? (<Card className="border-2 border-border"><CardContent className="py-12 text-center"><p className="text-muted-foreground mb-4">You don't have access to any products yet</p><Button onClick={() => navigate('/products')} variant="outline" className="border-2">Browse Products</Button></CardContent></Card>) : (
|
{access.length === 0 ? (
|
||||||
<div className="grid gap-4">{access.map((item) => (<Card key={item.id} className="border-2 border-border"><CardHeader><div className="flex items-start justify-between"><div><CardTitle>{item.product.title}</CardTitle><CardDescription className="capitalize">{item.product.type}</CardDescription></div><Badge className="bg-accent">Active</Badge></div></CardHeader><CardContent><p className="text-muted-foreground mb-4">{item.product.description}</p>{renderAccessActions(item)}</CardContent></Card>))}</div>
|
<Card className="border-2 border-border">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground mb-4">Anda belum memiliki akses ke produk apapun</p>
|
||||||
|
<Button onClick={() => navigate('/products')} variant="outline" className="border-2">
|
||||||
|
Lihat Produk
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{access.map((item) => (
|
||||||
|
<Card key={item.id} className="border-2 border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{item.product.title}</CardTitle>
|
||||||
|
<CardDescription className="capitalize">{item.product.type}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-accent">Aktif</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground mb-4 line-clamp-2">{item.product.description}</p>
|
||||||
|
{renderAccessActions(item)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="orders">
|
<TabsContent value="orders">
|
||||||
{orders.length === 0 ? (<Card className="border-2 border-border"><CardContent className="py-12 text-center"><p className="text-muted-foreground">No orders yet</p></CardContent></Card>) : (
|
{orders.length === 0 ? (
|
||||||
<div className="space-y-4">{orders.map((order) => (<Card key={order.id} className="border-2 border-border"><CardContent className="py-4"><div className="flex items-center justify-between"><div><p className="font-mono text-sm text-muted-foreground">{order.id.slice(0, 8)}</p><p className="text-sm text-muted-foreground">{new Date(order.created_at).toLocaleDateString()}</p></div><div className="flex items-center gap-4"><Badge className={getStatusColor(order.status)}>{order.status}</Badge><span className="font-bold">${order.total_amount}</span></div></div></CardContent></Card>))}</div>
|
<Card className="border-2 border-border">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground">Belum ada order</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{orders.map((order) => (
|
||||||
|
<Card key={order.id} className="border-2 border-border">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-sm text-muted-foreground">{order.id.slice(0, 8)}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{formatDate(order.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge className={getStatusColor(order.payment_status || order.status)}>
|
||||||
|
{getPaymentStatusLabel(order.payment_status || order.status)}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
130
src/pages/Events.tsx
Normal file
130
src/pages/Events.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { AppLayout } from '@/components/AppLayout';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
import { Calendar, Video, Users, BookOpen } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Event {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
product_id: string | null;
|
||||||
|
title: string;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string;
|
||||||
|
status: string;
|
||||||
|
product?: {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Events() {
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEvents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchEvents = async () => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('events')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
product:products (slug, title)
|
||||||
|
`)
|
||||||
|
.eq('status', 'confirmed')
|
||||||
|
.gte('ends_at', new Date().toISOString())
|
||||||
|
.order('starts_at', { ascending: true });
|
||||||
|
|
||||||
|
if (!error && data) {
|
||||||
|
setEvents(data as unknown as Event[]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'bootcamp': return BookOpen;
|
||||||
|
case 'webinar': return Video;
|
||||||
|
case 'consulting': return Users;
|
||||||
|
default: return Calendar;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'bootcamp': return 'Bootcamp';
|
||||||
|
case 'webinar': return 'Webinar';
|
||||||
|
case 'consulting': return 'Konsultasi';
|
||||||
|
default: return type;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
<Calendar className="w-8 h-8" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold">Kalender Acara</h1>
|
||||||
|
<p className="text-muted-foreground">Jadwal webinar, bootcamp, dan sesi konsultasi</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-32 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : events.length === 0 ? (
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<Calendar className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground">Belum ada acara terjadwal</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{events.map((event) => {
|
||||||
|
const Icon = getTypeIcon(event.type);
|
||||||
|
return (
|
||||||
|
<Card key={event.id} className="border-2 border-border">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-muted rounded-none">
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{event.title}</CardTitle>
|
||||||
|
<CardDescription>{formatDateTime(event.starts_at)}</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-secondary">{getTypeLabel(event.type)}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{event.product && (
|
||||||
|
<Link to={`/products/${event.product.slug}`}>
|
||||||
|
<Button variant="outline" size="sm" className="border-2">
|
||||||
|
Lihat Produk
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { Layout } from '@/components/Layout';
|
import { AppLayout } from '@/components/AppLayout';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -9,7 +9,9 @@ import { useCart } from '@/contexts/CartContext';
|
|||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Video, Calendar, BookOpen } from 'lucide-react';
|
import { formatIDR, formatDuration } from '@/lib/format';
|
||||||
|
import { Video, Calendar, BookOpen, Play, Clock, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,13 +27,29 @@ interface Product {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Module {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
position: number;
|
||||||
|
lessons: Lesson[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Lesson {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
duration_seconds: number | null;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProductDetail() {
|
export default function ProductDetail() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [product, setProduct] = useState<Product | null>(null);
|
const [product, setProduct] = useState<Product | null>(null);
|
||||||
|
const [modules, setModules] = useState<Module[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [hasAccess, setHasAccess] = useState(false);
|
const [hasAccess, setHasAccess] = useState(false);
|
||||||
const [checkingAccess, setCheckingAccess] = useState(true);
|
const [checkingAccess, setCheckingAccess] = useState(true);
|
||||||
|
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
|
||||||
const { addItem, items } = useCart();
|
const { addItem, items } = useCart();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
@@ -47,6 +65,12 @@ export default function ProductDetail() {
|
|||||||
}
|
}
|
||||||
}, [product, user]);
|
}, [product, user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (product?.type === 'bootcamp') {
|
||||||
|
fetchCurriculum();
|
||||||
|
}
|
||||||
|
}, [product]);
|
||||||
|
|
||||||
const fetchProduct = async () => {
|
const fetchProduct = async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('products')
|
.from('products')
|
||||||
@@ -56,13 +80,45 @@ export default function ProductDetail() {
|
|||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
toast({ title: 'Error', description: 'Product not found', variant: 'destructive' });
|
toast({ title: 'Error', description: 'Produk tidak ditemukan', variant: 'destructive' });
|
||||||
} else {
|
} else {
|
||||||
setProduct(data);
|
setProduct(data);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchCurriculum = async () => {
|
||||||
|
if (!product) return;
|
||||||
|
|
||||||
|
const { data: modulesData } = await supabase
|
||||||
|
.from('bootcamp_modules')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
position,
|
||||||
|
bootcamp_lessons (
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
duration_seconds,
|
||||||
|
position
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('product_id', product.id)
|
||||||
|
.order('position');
|
||||||
|
|
||||||
|
if (modulesData) {
|
||||||
|
const sorted = modulesData.map(m => ({
|
||||||
|
...m,
|
||||||
|
lessons: (m.bootcamp_lessons as Lesson[]).sort((a, b) => a.position - b.position)
|
||||||
|
}));
|
||||||
|
setModules(sorted);
|
||||||
|
// Expand first module by default
|
||||||
|
if (sorted.length > 0) {
|
||||||
|
setExpandedModules(new Set([sorted[0].id]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const checkUserAccess = async () => {
|
const checkUserAccess = async () => {
|
||||||
if (!product || !user) return;
|
if (!product || !user) return;
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
@@ -78,7 +134,7 @@ export default function ProductDetail() {
|
|||||||
const handleAddToCart = () => {
|
const handleAddToCart = () => {
|
||||||
if (!product) return;
|
if (!product) return;
|
||||||
addItem({ id: product.id, title: product.title, price: product.price, sale_price: product.sale_price, type: product.type });
|
addItem({ id: product.id, title: product.title, price: product.price, sale_price: product.sale_price, type: product.type });
|
||||||
toast({ title: 'Added to cart', description: `${product.title} has been added to your cart` });
|
toast({ title: 'Ditambahkan', description: `${product.title} sudah ditambahkan ke keranjang` });
|
||||||
};
|
};
|
||||||
|
|
||||||
const isInCart = product ? items.some(item => item.id === product.id) : false;
|
const isInCart = product ? items.some(item => item.id === product.id) : false;
|
||||||
@@ -91,53 +147,200 @@ export default function ProductDetail() {
|
|||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const totalDuration = modules.reduce((total, m) =>
|
||||||
|
total + m.lessons.reduce((sum, l) => sum + (l.duration_seconds || 0), 0), 0
|
||||||
|
);
|
||||||
|
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
||||||
|
|
||||||
|
const toggleModule = (id: string) => {
|
||||||
|
const newSet = new Set(expandedModules);
|
||||||
|
if (newSet.has(id)) newSet.delete(id);
|
||||||
|
else newSet.add(id);
|
||||||
|
setExpandedModules(newSet);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (<Layout><div className="container mx-auto px-4 py-8"><Skeleton className="h-10 w-1/2 mb-4" /><Skeleton className="h-6 w-1/4 mb-8" /><Skeleton className="h-64 w-full" /></div></Layout>);
|
return (<AppLayout><div className="container mx-auto px-4 py-8"><Skeleton className="h-10 w-1/2 mb-4" /><Skeleton className="h-6 w-1/4 mb-8" /><Skeleton className="h-64 w-full" /></div></AppLayout>);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return (<Layout><div className="container mx-auto px-4 py-8 text-center"><h1 className="text-2xl font-bold">Product not found</h1></div></Layout>);
|
return (<AppLayout><div className="container mx-auto px-4 py-8 text-center"><h1 className="text-2xl font-bold">Produk tidak ditemukan</h1></div></AppLayout>);
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderActionButtons = () => {
|
const renderActionButtons = () => {
|
||||||
if (checkingAccess) return <Skeleton className="h-10 w-40" />;
|
if (checkingAccess) return <Skeleton className="h-10 w-40" />;
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return (<Button onClick={handleAddToCart} disabled={isInCart} size="lg" className="shadow-sm">{isInCart ? 'Already in Cart' : 'Add to Cart'}</Button>);
|
return (
|
||||||
|
<Button onClick={handleAddToCart} disabled={isInCart} size="lg" className="shadow-sm">
|
||||||
|
{isInCart ? 'Sudah di Keranjang' : 'Tambah ke Keranjang'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (product.type) {
|
switch (product.type) {
|
||||||
case 'consulting':
|
case 'consulting':
|
||||||
return (<Button asChild size="lg" className="shadow-sm"><a href={product.meeting_link || '#'} target="_blank" rel="noopener noreferrer"><Calendar className="w-4 h-4 mr-2" />Book Consulting Session</a></Button>);
|
return (
|
||||||
|
<Button asChild size="lg" className="shadow-sm">
|
||||||
|
<a href={product.meeting_link || '#'} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Calendar className="w-4 h-4 mr-2" />
|
||||||
|
Jadwalkan Konsultasi
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
case 'webinar':
|
case 'webinar':
|
||||||
if (product.recording_url) {
|
if (product.recording_url) {
|
||||||
return (<div className="aspect-video bg-muted rounded-lg overflow-hidden"><iframe src={getVideoEmbed(product.recording_url)} className="w-full h-full" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen /></div>);
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
|
||||||
|
<iframe
|
||||||
|
src={getVideoEmbed(product.recording_url)}
|
||||||
|
className="w-full h-full"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" className="border-2">
|
||||||
|
<a href={product.recording_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Video className="w-4 h-4 mr-2" />
|
||||||
|
Tonton Rekaman
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return product.meeting_link ? (<Button asChild size="lg" className="shadow-sm"><a href={product.meeting_link} target="_blank" rel="noopener noreferrer"><Video className="w-4 h-4 mr-2" />Join Live Webinar</a></Button>) : <Badge variant="secondary">Recording coming soon</Badge>;
|
return product.meeting_link ? (
|
||||||
|
<Button asChild size="lg" className="shadow-sm">
|
||||||
|
<a href={product.meeting_link} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Video className="w-4 h-4 mr-2" />
|
||||||
|
Gabung Webinar
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : <Badge className="bg-secondary">Rekaman segera tersedia</Badge>;
|
||||||
case 'bootcamp':
|
case 'bootcamp':
|
||||||
return (<Button onClick={() => navigate(`/bootcamp/${product.slug}`)} size="lg" className="shadow-sm"><BookOpen className="w-4 h-4 mr-2" />Start Bootcamp</Button>);
|
return (
|
||||||
|
<Button onClick={() => navigate(`/bootcamp/${product.slug}`)} size="lg" className="shadow-sm">
|
||||||
|
<BookOpen className="w-4 h-4 mr-2" />
|
||||||
|
Mulai Bootcamp
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const renderCurriculumPreview = () => {
|
||||||
<Layout>
|
if (product.type !== 'bootcamp' || modules.length === 0) return null;
|
||||||
<div className="container mx-auto px-4 py-8">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
return (
|
||||||
<div className="flex items-start justify-between mb-6">
|
<Card className="border-2 border-border mb-6">
|
||||||
<div>
|
<CardContent className="pt-6">
|
||||||
<h1 className="text-4xl font-bold mb-2">{product.title}</h1>
|
<div className="flex items-center justify-between mb-4">
|
||||||
<Badge className="bg-secondary capitalize">{product.type}</Badge>
|
<h3 className="text-xl font-bold">Kurikulum</h3>
|
||||||
{hasAccess && <Badge className="bg-accent ml-2">You have access</Badge>}
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
</div>
|
<span className="flex items-center gap-1">
|
||||||
<div className="text-right">
|
<BookOpen className="w-4 h-4" />
|
||||||
{product.sale_price ? (<div><span className="text-3xl font-bold">${product.sale_price}</span><span className="text-muted-foreground line-through ml-2">${product.price}</span></div>) : (<span className="text-3xl font-bold">${product.price}</span>)}
|
{totalLessons} Pelajaran
|
||||||
|
</span>
|
||||||
|
{totalDuration > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
{formatDuration(totalDuration)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg text-muted-foreground mb-6">{product.description}</p>
|
<div className="space-y-2">
|
||||||
<Card className="border-2 border-border mb-6"><CardContent className="pt-6"><div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: product.content || '<p>No content available</p>' }} /></CardContent></Card>
|
{modules.map((module) => (
|
||||||
{renderActionButtons()}
|
<Collapsible
|
||||||
|
key={module.id}
|
||||||
|
open={expandedModules.has(module.id)}
|
||||||
|
onOpenChange={() => toggleModule(module.id)}
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger className="flex items-center justify-between w-full p-3 bg-muted hover:bg-accent transition-colors text-left">
|
||||||
|
<span className="font-medium">{module.title}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">{module.lessons.length} pelajaran</span>
|
||||||
|
{expandedModules.has(module.id) ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="border-l-2 border-border ml-4 pl-4 py-2 space-y-2">
|
||||||
|
{module.lessons.map((lesson) => (
|
||||||
|
<div key={lesson.id} className="flex items-center justify-between py-1 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Play className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span>{lesson.title}</span>
|
||||||
|
</div>
|
||||||
|
{lesson.duration_seconds && (
|
||||||
|
<span className="text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold mb-2">{product.title}</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge className="bg-secondary capitalize">{product.type}</Badge>
|
||||||
|
{hasAccess && <Badge className="bg-accent">Anda memiliki akses</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
{product.sale_price ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-3xl font-bold">{formatIDR(product.sale_price)}</span>
|
||||||
|
<span className="text-muted-foreground line-through ml-2">{formatIDR(product.price)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-3xl font-bold">{formatIDR(product.price)}</span>
|
||||||
|
)}
|
||||||
|
{product.type === 'bootcamp' && totalDuration > 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Total: {formatDuration(totalDuration)} waktu belajar
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{product.description && (
|
||||||
|
<div
|
||||||
|
className="prose max-w-none mb-6 text-muted-foreground"
|
||||||
|
dangerouslySetInnerHTML={{ __html: product.description }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderCurriculumPreview()}
|
||||||
|
|
||||||
|
{product.content && (
|
||||||
|
<Card className="border-2 border-border mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div
|
||||||
|
className="prose max-w-none"
|
||||||
|
dangerouslySetInnerHTML={{ __html: product.content }}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{renderActionButtons()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { Layout } from '@/components/Layout';
|
import { AppLayout } from '@/components/AppLayout';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useCart } from '@/contexts/CartContext';
|
import { useCart } from '@/contexts/CartContext';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { formatIDR } from '@/lib/format';
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,7 +38,7 @@ export default function Products() {
|
|||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
toast({ title: 'Error', description: 'Failed to load products', variant: 'destructive' });
|
toast({ title: 'Error', description: 'Gagal memuat produk', variant: 'destructive' });
|
||||||
} else {
|
} else {
|
||||||
setProducts(data || []);
|
setProducts(data || []);
|
||||||
}
|
}
|
||||||
@@ -52,25 +53,25 @@ export default function Products() {
|
|||||||
sale_price: product.sale_price,
|
sale_price: product.sale_price,
|
||||||
type: product.type,
|
type: product.type,
|
||||||
});
|
});
|
||||||
toast({ title: 'Added to cart', description: `${product.title} has been added to your cart` });
|
toast({ title: 'Ditambahkan', description: `${product.title} sudah ditambahkan ke keranjang` });
|
||||||
};
|
};
|
||||||
|
|
||||||
const isInCart = (productId: string) => items.some(item => item.id === productId);
|
const isInCart = (productId: string) => items.some(item => item.id === productId);
|
||||||
|
|
||||||
const getTypeColor = (type: string) => {
|
const getTypeLabel = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'consulting': return 'bg-secondary';
|
case 'consulting': return 'Konsultasi';
|
||||||
case 'webinar': return 'bg-accent';
|
case 'webinar': return 'Webinar';
|
||||||
case 'bootcamp': return 'bg-muted';
|
case 'bootcamp': return 'Bootcamp';
|
||||||
default: return 'bg-secondary';
|
default: return type;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<AppLayout>
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<h1 className="text-4xl font-bold mb-2">Products</h1>
|
<h1 className="text-4xl font-bold mb-2">Produk</h1>
|
||||||
<p className="text-muted-foreground mb-8">Browse our consulting, webinars, and bootcamps</p>
|
<p className="text-muted-foreground mb-8">Jelajahi konsultasi, webinar, dan bootcamp kami</p>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@@ -88,7 +89,7 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
) : products.length === 0 ? (
|
) : products.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-muted-foreground">No products available yet.</p>
|
<p className="text-muted-foreground">Belum ada produk tersedia.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@@ -97,7 +98,7 @@ export default function Products() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<CardTitle className="text-xl">{product.title}</CardTitle>
|
<CardTitle className="text-xl">{product.title}</CardTitle>
|
||||||
<Badge className={getTypeColor(product.type)}>{product.type}</Badge>
|
<Badge className="bg-secondary">{getTypeLabel(product.type)}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="line-clamp-2">{product.description}</CardDescription>
|
<CardDescription className="line-clamp-2">{product.description}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -105,23 +106,23 @@ export default function Products() {
|
|||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
{product.sale_price ? (
|
{product.sale_price ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-2xl font-bold">${product.sale_price}</span>
|
<span className="text-2xl font-bold">{formatIDR(product.sale_price)}</span>
|
||||||
<span className="text-muted-foreground line-through">${product.price}</span>
|
<span className="text-muted-foreground line-through">{formatIDR(product.price)}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-2xl font-bold">${product.price}</span>
|
<span className="text-2xl font-bold">{formatIDR(product.price)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link to={`/products/${product.slug}`} className="flex-1">
|
<Link to={`/products/${product.slug}`} className="flex-1">
|
||||||
<Button variant="outline" className="w-full border-2">View Details</Button>
|
<Button variant="outline" className="w-full border-2">Lihat Detail</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleAddToCart(product)}
|
onClick={() => handleAddToCart(product)}
|
||||||
disabled={isInCart(product.id)}
|
disabled={isInCart(product.id)}
|
||||||
className="shadow-xs"
|
className="shadow-xs"
|
||||||
>
|
>
|
||||||
{isInCart(product.id) ? 'In Cart' : 'Add'}
|
{isInCart(product.id) ? 'Di Keranjang' : 'Tambah'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -130,6 +131,6 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
463
src/pages/admin/AdminEvents.tsx
Normal file
463
src/pages/admin/AdminEvents.tsx
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { AppLayout } from '@/components/AppLayout';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Plus, Pencil, Trash2, Calendar, Clock } from 'lucide-react';
|
||||||
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
|
||||||
|
interface Event {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
product_id: string | null;
|
||||||
|
title: string;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvailabilityBlock {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyEvent = {
|
||||||
|
type: 'webinar',
|
||||||
|
product_id: '' as string,
|
||||||
|
title: '',
|
||||||
|
starts_at: '',
|
||||||
|
ends_at: '',
|
||||||
|
status: 'confirmed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyBlock = {
|
||||||
|
kind: 'blocked',
|
||||||
|
starts_at: '',
|
||||||
|
ends_at: '',
|
||||||
|
note: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminEvents() {
|
||||||
|
const { user, isAdmin, loading: authLoading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [blocks, setBlocks] = useState<AvailabilityBlock[]>([]);
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [eventDialogOpen, setEventDialogOpen] = useState(false);
|
||||||
|
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||||
|
const [eventForm, setEventForm] = useState(emptyEvent);
|
||||||
|
|
||||||
|
const [blockDialogOpen, setBlockDialogOpen] = useState(false);
|
||||||
|
const [editingBlock, setEditingBlock] = useState<AvailabilityBlock | null>(null);
|
||||||
|
const [blockForm, setBlockForm] = useState(emptyBlock);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading) {
|
||||||
|
if (!user) navigate('/auth');
|
||||||
|
else if (!isAdmin) navigate('/dashboard');
|
||||||
|
else fetchData();
|
||||||
|
}
|
||||||
|
}, [user, isAdmin, authLoading]);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
const [eventsRes, blocksRes, productsRes] = await Promise.all([
|
||||||
|
supabase.from('events').select('*').order('starts_at', { ascending: false }),
|
||||||
|
supabase.from('availability_blocks').select('*').order('starts_at', { ascending: false }),
|
||||||
|
supabase.from('products').select('id, title').eq('is_active', true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (eventsRes.data) setEvents(eventsRes.data);
|
||||||
|
if (blocksRes.data) setBlocks(blocksRes.data);
|
||||||
|
if (productsRes.data) setProducts(productsRes.data);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
const handleNewEvent = () => {
|
||||||
|
setEditingEvent(null);
|
||||||
|
setEventForm(emptyEvent);
|
||||||
|
setEventDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditEvent = (event: Event) => {
|
||||||
|
setEditingEvent(event);
|
||||||
|
setEventForm({
|
||||||
|
type: event.type,
|
||||||
|
product_id: event.product_id || '',
|
||||||
|
title: event.title,
|
||||||
|
starts_at: event.starts_at.slice(0, 16),
|
||||||
|
ends_at: event.ends_at.slice(0, 16),
|
||||||
|
status: event.status,
|
||||||
|
});
|
||||||
|
setEventDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEvent = async () => {
|
||||||
|
if (!eventForm.title || !eventForm.starts_at || !eventForm.ends_at) {
|
||||||
|
toast({ title: 'Error', description: 'Lengkapi semua field yang wajib diisi', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventData = {
|
||||||
|
type: eventForm.type,
|
||||||
|
product_id: eventForm.product_id || null,
|
||||||
|
title: eventForm.title,
|
||||||
|
starts_at: new Date(eventForm.starts_at).toISOString(),
|
||||||
|
ends_at: new Date(eventForm.ends_at).toISOString(),
|
||||||
|
status: eventForm.status,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingEvent) {
|
||||||
|
const { error } = await supabase.from('events').update(eventData).eq('id', editingEvent.id);
|
||||||
|
if (error) toast({ title: 'Error', description: 'Gagal mengupdate event', variant: 'destructive' });
|
||||||
|
else { toast({ title: 'Berhasil', description: 'Event diupdate' }); setEventDialogOpen(false); fetchData(); }
|
||||||
|
} else {
|
||||||
|
const { error } = await supabase.from('events').insert(eventData);
|
||||||
|
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||||
|
else { toast({ title: 'Berhasil', description: 'Event dibuat' }); setEventDialogOpen(false); fetchData(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEvent = async (id: string) => {
|
||||||
|
if (!confirm('Hapus event ini?')) return;
|
||||||
|
const { error } = await supabase.from('events').delete().eq('id', id);
|
||||||
|
if (error) toast({ title: 'Error', description: 'Gagal menghapus event', variant: 'destructive' });
|
||||||
|
else { toast({ title: 'Berhasil', description: 'Event dihapus' }); fetchData(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Block handlers
|
||||||
|
const handleNewBlock = () => {
|
||||||
|
setEditingBlock(null);
|
||||||
|
setBlockForm(emptyBlock);
|
||||||
|
setBlockDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditBlock = (block: AvailabilityBlock) => {
|
||||||
|
setEditingBlock(block);
|
||||||
|
setBlockForm({
|
||||||
|
kind: block.kind,
|
||||||
|
starts_at: block.starts_at.slice(0, 16),
|
||||||
|
ends_at: block.ends_at.slice(0, 16),
|
||||||
|
note: block.note || '',
|
||||||
|
});
|
||||||
|
setBlockDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBlock = async () => {
|
||||||
|
if (!blockForm.starts_at || !blockForm.ends_at) {
|
||||||
|
toast({ title: 'Error', description: 'Lengkapi waktu mulai dan selesai', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockData = {
|
||||||
|
kind: blockForm.kind,
|
||||||
|
starts_at: new Date(blockForm.starts_at).toISOString(),
|
||||||
|
ends_at: new Date(blockForm.ends_at).toISOString(),
|
||||||
|
note: blockForm.note || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingBlock) {
|
||||||
|
const { error } = await supabase.from('availability_blocks').update(blockData).eq('id', editingBlock.id);
|
||||||
|
if (error) toast({ title: 'Error', description: 'Gagal mengupdate', variant: 'destructive' });
|
||||||
|
else { toast({ title: 'Berhasil', description: 'Blok diupdate' }); setBlockDialogOpen(false); fetchData(); }
|
||||||
|
} else {
|
||||||
|
const { error } = await supabase.from('availability_blocks').insert(blockData);
|
||||||
|
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||||
|
else { toast({ title: 'Berhasil', description: 'Blok dibuat' }); setBlockDialogOpen(false); fetchData(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteBlock = async (id: string) => {
|
||||||
|
if (!confirm('Hapus blok waktu ini?')) return;
|
||||||
|
const { error } = await supabase.from('availability_blocks').delete().eq('id', id);
|
||||||
|
if (error) toast({ title: 'Error', description: 'Gagal menghapus', variant: 'destructive' });
|
||||||
|
else { toast({ title: 'Berhasil', description: 'Blok dihapus' }); fetchData(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authLoading || loading) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<Skeleton className="h-10 w-1/3 mb-8" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
<Calendar className="w-8 h-8" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold">Kalender & Jadwal</h1>
|
||||||
|
<p className="text-muted-foreground">Kelola event dan blok ketersediaan</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="events" className="space-y-6">
|
||||||
|
<TabsList className="border-2 border-border">
|
||||||
|
<TabsTrigger value="events">Event</TabsTrigger>
|
||||||
|
<TabsTrigger value="availability">Ketersediaan</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="events">
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Daftar Event</CardTitle>
|
||||||
|
<Button onClick={handleNewEvent} className="shadow-sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Tambah Event
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Judul</TableHead>
|
||||||
|
<TableHead>Tipe</TableHead>
|
||||||
|
<TableHead>Mulai</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Aksi</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{events.map((event) => (
|
||||||
|
<TableRow key={event.id}>
|
||||||
|
<TableCell className="font-medium">{event.title}</TableCell>
|
||||||
|
<TableCell className="capitalize">{event.type}</TableCell>
|
||||||
|
<TableCell>{formatDateTime(event.starts_at)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={event.status === 'confirmed' ? 'bg-accent' : 'bg-muted'}>
|
||||||
|
{event.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleEditEvent(event)}>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleDeleteEvent(event.id)}>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{events.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||||
|
Belum ada event
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="availability">
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Blok Ketersediaan</CardTitle>
|
||||||
|
<Button onClick={handleNewBlock} className="shadow-sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Tambah Blok
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Tipe</TableHead>
|
||||||
|
<TableHead>Mulai</TableHead>
|
||||||
|
<TableHead>Selesai</TableHead>
|
||||||
|
<TableHead>Catatan</TableHead>
|
||||||
|
<TableHead className="text-right">Aksi</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{blocks.map((block) => (
|
||||||
|
<TableRow key={block.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={block.kind === 'available' ? 'bg-accent' : 'bg-destructive'}>
|
||||||
|
{block.kind === 'available' ? 'Tersedia' : 'Tidak Tersedia'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDateTime(block.starts_at)}</TableCell>
|
||||||
|
<TableCell>{formatDateTime(block.ends_at)}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{block.note || '-'}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleEditBlock(block)}>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleDeleteBlock(block.id)}>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{blocks.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||||
|
Belum ada blok ketersediaan
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Event Dialog */}
|
||||||
|
<Dialog open={eventDialogOpen} onOpenChange={setEventDialogOpen}>
|
||||||
|
<DialogContent className="max-w-md border-2 border-border">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingEvent ? 'Edit Event' : 'Buat Event Baru'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Judul *</Label>
|
||||||
|
<Input
|
||||||
|
value={eventForm.title}
|
||||||
|
onChange={(e) => setEventForm({ ...eventForm, title: e.target.value })}
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tipe</Label>
|
||||||
|
<Select value={eventForm.type} onValueChange={(v) => setEventForm({ ...eventForm, type: v })}>
|
||||||
|
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="webinar">Webinar</SelectItem>
|
||||||
|
<SelectItem value="bootcamp">Bootcamp</SelectItem>
|
||||||
|
<SelectItem value="consulting">Konsultasi</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Produk Terkait</Label>
|
||||||
|
<Select value={eventForm.product_id} onValueChange={(v) => setEventForm({ ...eventForm, product_id: v })}>
|
||||||
|
<SelectTrigger className="border-2"><SelectValue placeholder="Pilih produk (opsional)" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{products.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>{p.title}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Mulai *</Label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={eventForm.starts_at}
|
||||||
|
onChange={(e) => setEventForm({ ...eventForm, starts_at: e.target.value })}
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Selesai *</Label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={eventForm.ends_at}
|
||||||
|
onChange={(e) => setEventForm({ ...eventForm, ends_at: e.target.value })}
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<Select value={eventForm.status} onValueChange={(v) => setEventForm({ ...eventForm, status: v })}>
|
||||||
|
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="confirmed">Confirmed</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSaveEvent} className="w-full shadow-sm">Simpan</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Block Dialog */}
|
||||||
|
<Dialog open={blockDialogOpen} onOpenChange={setBlockDialogOpen}>
|
||||||
|
<DialogContent className="max-w-md border-2 border-border">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingBlock ? 'Edit Blok' : 'Tambah Blok Ketersediaan'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tipe</Label>
|
||||||
|
<Select value={blockForm.kind} onValueChange={(v) => setBlockForm({ ...blockForm, kind: v })}>
|
||||||
|
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="available">Tersedia</SelectItem>
|
||||||
|
<SelectItem value="blocked">Tidak Tersedia</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Mulai *</Label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={blockForm.starts_at}
|
||||||
|
onChange={(e) => setBlockForm({ ...blockForm, starts_at: e.target.value })}
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Selesai *</Label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={blockForm.ends_at}
|
||||||
|
onChange={(e) => setBlockForm({ ...blockForm, ends_at: e.target.value })}
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Catatan</Label>
|
||||||
|
<Textarea
|
||||||
|
value={blockForm.note}
|
||||||
|
onChange={(e) => setBlockForm({ ...blockForm, note: e.target.value })}
|
||||||
|
placeholder="Contoh: Libur nasional, sudah ada jadwal lain..."
|
||||||
|
className="border-2"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSaveBlock} className="w-full shadow-sm">Simpan</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user