diff --git a/Dockerfile b/Dockerfile index 9cfdfd1..e68fe41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,10 +3,12 @@ FROM node:20.15.0-alpine AS base ARG NEXT_PUBLIC_LIFF_ID="" ARG NEXT_PUBLIC_TARGET_DATE=01-15-2025 ARG NODE_ENV=production +ARG NEXT_PUBLIC_BASE_URL ENV NEXT_PUBLIC_LIFF_ID=${NEXT_PUBLIC_LIFF_ID} ENV NEXT_PUBLIC_TARGET_DATE=${NEXT_PUBLIC_TARGET_DATE} ENV NODE_ENV=${NODE_ENV} +ENV NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL} FROM base AS deps WORKDIR /app diff --git a/package-lock.json b/package-lock.json index 0d5a95c..c591857 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-slot": "^1.1.1", + "@tanstack/react-query": "^5.64.2", + "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -26,6 +28,7 @@ "react-day-picker": "^8.10.1", "react-dom": "^18.2.0", "react-hook-form": "^7.54.2", + "react-hot-toast": "^2.5.1", "react-qr-code": "^2.0.15", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", @@ -207,14 +210,16 @@ }, "node_modules/@floating-ui/core": { "version": "1.6.9", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "node_modules/@floating-ui/dom": { "version": "1.6.13", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", "dependencies": { "@floating-ui/core": "^1.6.0", "@floating-ui/utils": "^0.2.9" @@ -222,7 +227,8 @@ }, "node_modules/@floating-ui/react-dom": { "version": "2.1.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", "dependencies": { "@floating-ui/dom": "^1.0.0" }, @@ -233,7 +239,8 @@ }, "node_modules/@floating-ui/utils": { "version": "0.2.9", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" }, "node_modules/@hookform/resolvers": { "version": "3.10.0", @@ -1287,7 +1294,8 @@ }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", + "integrity": "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==", "dependencies": { "@radix-ui/react-primitive": "2.0.1" }, @@ -1308,7 +1316,8 @@ }, "node_modules/@radix-ui/react-collection": { "version": "1.1.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz", + "integrity": "sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", @@ -1406,7 +1415,8 @@ }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz", + "integrity": "sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==", "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", @@ -1444,7 +1454,8 @@ }, "node_modules/@radix-ui/react-focus-scope": { "version": "1.1.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.1.tgz", + "integrity": "sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA==", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.1", @@ -1554,7 +1565,8 @@ }, "node_modules/@radix-ui/react-popper": { "version": "1.2.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", + "integrity": "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==", "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.1", @@ -1584,7 +1596,8 @@ }, "node_modules/@radix-ui/react-portal": { "version": "1.1.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz", + "integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==", "dependencies": { "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-layout-effect": "1.1.0" @@ -1606,7 +1619,8 @@ }, "node_modules/@radix-ui/react-presence": { "version": "1.1.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" @@ -1628,7 +1642,8 @@ }, "node_modules/@radix-ui/react-primitive": { "version": "2.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", "dependencies": { "@radix-ui/react-slot": "1.1.1" }, @@ -1735,7 +1750,8 @@ }, "node_modules/@radix-ui/react-use-escape-keydown": { "version": "1.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, @@ -1777,7 +1793,8 @@ }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", "dependencies": { "@radix-ui/rect": "1.1.0" }, @@ -1793,7 +1810,8 @@ }, "node_modules/@radix-ui/react-use-size": { "version": "1.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, @@ -1809,7 +1827,8 @@ }, "node_modules/@radix-ui/react-visually-hidden": { "version": "1.1.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.1.tgz", + "integrity": "sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg==", "dependencies": { "@radix-ui/react-primitive": "2.0.1" }, @@ -1830,7 +1849,8 @@ }, "node_modules/@radix-ui/rect": { "version": "1.1.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -1853,6 +1873,30 @@ "tslib": "^2.8.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.64.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.64.2.tgz", + "integrity": "sha512-hdO8SZpWXoADNTWXV9We8CwTkXU88OVWRBcsiFrk7xJQnhm6WRlweDzMD+uH+GnuieTBVSML6xFa17C2cNV8+g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.64.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.64.2.tgz", + "integrity": "sha512-3pakNscZNm8KJkxmovvtZ4RaXLyiYYobwleTMvpIGUoKRa8j8VlrQKNl5W8VUEfVfZKkikvXVddLuWMbcSCA1Q==", + "dependencies": { + "@tanstack/query-core": "5.64.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "license": "MIT" @@ -1901,24 +1945,25 @@ }, "node_modules/@types/prop-types": { "version": "15.7.14", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", "optional": true, "peer": true }, "node_modules/@types/react": { - "version": "19.0.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz", - "integrity": "sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==", + "version": "19.0.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", + "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", "devOptional": true, - "license": "MIT", "dependencies": { "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "19.0.2", + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.3.tgz", + "integrity": "sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==", "devOptional": true, - "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" } @@ -2418,7 +2463,8 @@ }, "node_modules/axios": { "version": "1.7.9", - "license": "MIT", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -2759,7 +2805,8 @@ }, "node_modules/cmdk/node_modules/@radix-ui/primitive": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", "dependencies": { "@babel/runtime": "^7.13.10" } @@ -2782,7 +2829,8 @@ }, "node_modules/cmdk/node_modules/@radix-ui/react-context": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", "dependencies": { "@babel/runtime": "^7.13.10" }, @@ -2798,7 +2846,8 @@ }, "node_modules/cmdk/node_modules/@radix-ui/react-dialog": { "version": "1.0.5", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", + "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/primitive": "1.0.1", @@ -2833,7 +2882,8 @@ }, "node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": { "version": "1.0.5", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", + "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/primitive": "1.0.1", @@ -2859,7 +2909,8 @@ }, "node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", + "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", "dependencies": { "@babel/runtime": "^7.13.10" }, @@ -2875,7 +2926,8 @@ }, "node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": { "version": "1.0.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", + "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.1", @@ -2899,7 +2951,8 @@ }, "node_modules/cmdk/node_modules/@radix-ui/react-id": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-layout-effect": "1.0.1" @@ -2916,7 +2969,8 @@ }, "node_modules/cmdk/node_modules/@radix-ui/react-portal": { "version": "1.0.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", + "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-primitive": "1.0.3" @@ -2938,7 +2992,8 @@ }, "node_modules/cmdk/node_modules/@radix-ui/react-presence": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.1", @@ -2961,7 +3016,8 @@ }, "node_modules/cmdk/node_modules/@radix-ui/react-primitive": { "version": "1.0.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-slot": "1.0.2" @@ -3000,7 +3056,8 @@ }, "node_modules/cmdk/node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", "dependencies": { "@babel/runtime": "^7.13.10" }, @@ -3016,7 +3073,8 @@ }, "node_modules/cmdk/node_modules/@radix-ui/react-use-controllable-state": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.1" @@ -3033,7 +3091,8 @@ }, "node_modules/cmdk/node_modules/@radix-ui/react-use-escape-keydown": { "version": "1.0.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", + "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.1" @@ -3050,7 +3109,8 @@ }, "node_modules/cmdk/node_modules/@radix-ui/react-use-layout-effect": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", "dependencies": { "@babel/runtime": "^7.13.10" }, @@ -3066,7 +3126,8 @@ }, "node_modules/cmdk/node_modules/@types/react": { "version": "18.3.18", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", + "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", "optional": true, "peer": true, "dependencies": { @@ -3076,7 +3137,8 @@ }, "node_modules/cmdk/node_modules/react-remove-scroll": { "version": "2.5.5", - "license": "MIT", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", + "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", "dependencies": { "react-remove-scroll-bar": "^2.3.3", "react-style-singleton": "^2.2.1", @@ -3207,9 +3269,6 @@ }, "node_modules/csstype": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -4529,6 +4588,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.2.0", "license": "MIT", @@ -6238,8 +6305,9 @@ } }, "node_modules/react": { - "version": "18.3.1", - "license": "MIT", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "dependencies": { "loose-envify": "^1.1.0" }, @@ -6260,14 +6328,15 @@ } }, "node_modules/react-dom": { - "version": "18.3.1", - "license": "MIT", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "dependencies": { "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.23.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^18.2.0" } }, "node_modules/react-hook-form": { @@ -6284,6 +6353,22 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-hot-toast": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.1.tgz", + "integrity": "sha512-54Gq1ZD1JbmAb4psp9bvFHjS7lje+8ubboUmvKZkCsQBLH6AOpZ9JemfRvIdHcfb9AZXRaFLrb3qUobGYDJhFQ==", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-is": { "version": "16.13.1", "license": "MIT" @@ -6709,7 +6794,8 @@ }, "node_modules/scheduler": { "version": "0.23.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "dependencies": { "loose-envify": "^1.1.0" } diff --git a/package.json b/package.json index 95cdf31..c646e7e 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-slot": "^1.1.1", + "@tanstack/react-query": "^5.64.2", + "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -31,6 +33,7 @@ "react-day-picker": "^8.10.1", "react-dom": "^18.2.0", "react-hook-form": "^7.54.2", + "react-hot-toast": "^2.5.1", "react-qr-code": "^2.0.15", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", diff --git a/src/app/(user)/edit/_components/form.tsx b/src/app/(user)/edit/_components/form.tsx index d3a94ca..fdfb29f 100644 --- a/src/app/(user)/edit/_components/form.tsx +++ b/src/app/(user)/edit/_components/form.tsx @@ -3,76 +3,118 @@ import { useEffect } from 'react'; import TextInput from '../../register/_components/textInput'; -import ErrorMsg from '../../register/_components/errorMsg'; import DropdownInput from '../../register/_components/dropdownInput'; import ComboBox from '../../register/_components/comboBox'; import Label from '../../register/_components/label'; -import DateInput from '../../register/_components/dateInput'; import { Button } from '@/components/ui/button'; - -import { SubmitHandler, useForm } from 'react-hook-form'; -import { User, UserSchema } from '../schema/user'; -import { universities } from '../../register/_data/universities'; -import { faculties } from '../../register/_data/faculties'; -import { studies } from '../../register/_data/studies'; -import { sizes } from '../../register/_data/size'; +import { ErrorMsg, ErrorMsgFloat } from '../../register/_components/errorMsg'; import Image from 'next/image'; + import { zodResolver } from '@hookform/resolvers/zod'; +import { sizeJersey } from '@/const/size'; +import { faculties } from '@/const/faculties'; +import { EditForm, EditSchema } from '@/schema/edit'; +import { educationsMap } from '@/const/educations'; +import { universities } from '@/const/universities'; +import { statusMap } from '@/const/status'; + +import { SubmitHandler, useForm } from 'react-hook-form'; +import { useAuth } from '@/contexts/auth'; +import { useLiff } from '@/contexts/liff'; +import toast from 'react-hot-toast'; export default function Form() { + const { editError, edit, isEditing, user: defaultUser } = useAuth(); + const { client } = useLiff(); const { handleSubmit, register, setValue, watch, + reset, formState: { errors }, - } = useForm({ - resolver: zodResolver(UserSchema), + } = useForm({ + resolver: zodResolver(EditSchema), }); + useEffect(() => { + if (defaultUser) { + reset({ + name: defaultUser.name, + email: defaultUser.email, + phone: defaultUser.phone, + education: defaultUser.education, + university: defaultUser.university, + status: defaultUser.status, + graduatedYear: defaultUser.graduatedYear, + faculty: defaultUser.faculty, + age: defaultUser.age, + sizeJersey: defaultUser.sizeJersey, + foodLimitation: defaultUser.foodLimitation, + chronicDisease: defaultUser.chronicDisease, + drugAllergy: defaultUser.drugAllergy, + }); + } + }, [defaultUser, reset]); const user = watch(); - const onSubmit: SubmitHandler = data => { - console.log(data); + const onSubmit: SubmitHandler = async data => { + const context = client?.getContext(); + const userId = context?.userId; + if (!userId) { + const error = new Error('Failed edit: userId is undefined'); + console.error(error); + return; + } + + const toastId = toast.loading('รอสักครู่'); + const resp = await edit({ ...data, id: userId }); + if (resp.success) { + toast.success('บันทึกการแก้ไขสำเร็จ'); + } else { + toast.error('บันทึกการแก้ไขไม่สำเร็จ'); + } + + toast.dismiss(toastId); }; - const updateField = (field: keyof User) => { - return (value: User[keyof User]) => { + const updateField = (field: keyof EditForm) => { + return (value: EditForm[keyof EditForm]) => { setValue(field, value); }; }; useEffect(() => { - let status: User['status']; + let status: EditForm['status']; // set default value if ( - !!user.study && + !!user.education && !!user.university && - (user.study != 'จบการศึกษาแล้ว' || + (user.education != 'graduated' || user.university != 'จุฬาลงกรณ์มหาวิทยาลัย') ) { - setValue('graduateYear', '9999'); - setValue('graduateFaculty', 'ไม่ระบุ'); + setValue('graduatedYear', '9999'); + setValue('faculty', 'ไม่ระบุ'); } // set status - if (user.study == 'กำลังศึกษาอยู่') { + if (user.education == 'studying') { if (user.university == 'จุฬาลงกรณ์มหาวิทยาลัย') { - status = 'นิสิตปัจจุบัน'; + status = 'chula_student'; } else { - status = 'นักศึกษา'; + status = 'general_student'; } } else { if (user.university == 'จุฬาลงกรณ์มหาวิทยาลัย') { - status = 'นิสิตเก่า'; + status = 'alumni'; } else { - status = 'บุคคลทั่วไป'; + status = 'general_public'; } } setValue('status', status); - }, [user.study, user.university, setValue]); + }, [user.education, user.university, setValue]); return (
@@ -80,45 +122,44 @@ export default function Form() { className="w-full space-y-4 px-6 py-8" onSubmit={handleSubmit(onSubmit)} > - {/* fullname */} + {/* name */}
- - + + {errors.name?.message}
{/* email */}
- + {errors.email?.message}
- {/* tel */} + {/* phone */}
- - + + {errors.phone?.message}
- {/* study */} + {/* education */}
setValue('education', educationsMap[val])} placeholder="กำลังศึกษาอยู่" - choices={[...studies]} + choices={Object.keys(educationsMap).map(key => key)} /> - + {errors.education?.message}
{/* university */} - {/* TODO: optimize here, there are 390 universties in list */} - {user.study && ( + {user.education && (
@@ -130,26 +171,26 @@ export default function Form() { searchText="ค้นหามหาวิทยาลัย" emptyText="ไม่มีข้อมูล" /> - + {errors.university?.message}
)} {/* status */}
- - + + {errors.status?.message}
{/* graduate year && graduate faculty*/} - {user.study == 'จบการศึกษาแล้ว' && + {user.education == 'graduated' && user.university == 'จุฬาลงกรณ์มหาวิทยาลัย' && (
(2468 + i).toString(), )} /> - + {errors.graduatedYear?.message}
- + {errors.faculty?.message}
)} {/* birthDate */}
- - - + + + {errors.age?.message}
- {/* size */}
- + {errors.sizeJersey?.message}
{/* foodAllegy */}
- - + + {errors.foodLimitation?.message}
{/* disease */}
- - + + {errors.chronicDisease?.message}
{/* drugAllegy */}
- - + + {errors.drugAllergy?.message}
{/* submit */} -
- + {editError?.message}
diff --git a/src/app/(user)/edit/schema/user.ts b/src/app/(user)/edit/schema/user.ts deleted file mode 100644 index a744d24..0000000 --- a/src/app/(user)/edit/schema/user.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { z } from 'zod'; -import { sizes } from '../../register/_data/size'; -import { studies } from '../../register/_data/studies'; -import { universities } from '../../register/_data/universities'; -import { statuses } from '../../register/_data/statuses'; -import { faculties } from '../../register/_data/faculties'; - -export const UserSchema = z.object({ - fullname: z.string().min(1, 'กรุณากรอกชื่อ-นามสกุล'), - email: z.string().email('กรุณากรอกอีเมล'), - tel: z.string().regex(/^\d+$/, 'กรุณากรอกเลข 0-9 เท่านั้น'), - birthdate: z.date({ - message: 'กรุณากรอกวันเกิด', - }), - size: z.enum(sizes, { - message: 'กรุณาเลือกขนาดเสื้อ', - }), - study: z.enum(studies, { message: 'กรุณาเลือกการศึกษา' }), - foodAllegy: z.string(), - disease: z.string(), - drugAllegy: z.string(), - university: z.enum(universities, { - message: 'กรุณาเลือกมหาวิทยาลัย', - }), - status: z.enum(statuses, { - message: 'กรุณาเลือกสถานะ', - }), - graduateYear: z - .string({ message: 'กรุณากรอกปีที่สำเร็จการศึกษา' }) - .regex(/^\d{4}$/, { message: 'กรุณากรอกปีที่สำเร็จการศึกษา' }), - graduateFaculty: z.enum([...faculties, 'ไม่ระบุ'], { - message: 'กรุณาเลือกคณะที่สำเร็จการศึกษา', - }), -}); - -export type User = z.infer; diff --git a/src/app/(user)/register/_components/errorMsg/index.tsx b/src/app/(user)/register/_components/errorMsg/index.tsx index 414fa5c..bcbeedb 100644 --- a/src/app/(user)/register/_components/errorMsg/index.tsx +++ b/src/app/(user)/register/_components/errorMsg/index.tsx @@ -1,17 +1,29 @@ -import React from 'react'; +import { cn } from '@/lib/utils'; +import React, { ComponentPropsWithoutRef } from 'react'; -interface ErrorMsgProps { - message: string | undefined; +export function ErrorMsgFloat({ + children, + className, + ...props +}: ComponentPropsWithoutRef<'p'>) { + return ( + + {children} + + ); } -export default function ErrorMsg({ message }: ErrorMsgProps) { +export function ErrorMsg({ + children, + className, + ...props +}: ComponentPropsWithoutRef<'p'>) { return ( - <> - {message && ( -

- {message} -

- )} - +

+ {children} +

); } diff --git a/src/app/(user)/register/_components/subpages/one.tsx b/src/app/(user)/register/_components/subpages/one.tsx index ff67f01..bc33678 100644 --- a/src/app/(user)/register/_components/subpages/one.tsx +++ b/src/app/(user)/register/_components/subpages/one.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; -import { PDPA, termAndCondition } from '../../_data/policy'; +import { PDPA, termAndCondition } from '@/const/policy'; import Policy from '../policy'; import { Button } from '@/components/ui/button'; diff --git a/src/app/(user)/register/_components/subpages/two.tsx b/src/app/(user)/register/_components/subpages/two.tsx index 084865f..078e20b 100644 --- a/src/app/(user)/register/_components/subpages/two.tsx +++ b/src/app/(user)/register/_components/subpages/two.tsx @@ -1,29 +1,35 @@ +import { useEffect } from 'react'; + import Label from '../label'; import TextInput from '../textInput'; import DropdownInput from '../dropdownInput'; import ImageInput from '../imageinput'; import CheckBox from '../policy/checkbox'; +import ComboBox from '../comboBox'; +import { ErrorMsg, ErrorMsgFloat } from '../errorMsg'; +import RegisterLayout from '../RegisterLayout'; import { Button } from '@/components/ui/button'; import { SubmitHandler, UseFormReturn } from 'react-hook-form'; -import { User } from '../../schema/user'; -import DateInput from '../dateInput'; -import { useEffect } from 'react'; -import ComboBox from '../comboBox'; -import { studies } from '../../_data/studies'; -import { universities } from '../../_data/universities'; -import { faculties } from '../../_data/faculties'; -import { sizes } from '../../_data/size'; -import ErrorMsg from '../errorMsg'; -import RegisterLayout from '../RegisterLayout'; +import { useLiff } from '@/contexts/liff'; +import { useAuth } from '@/contexts/auth'; +import toast from 'react-hot-toast'; +import { RegisterForm } from '@/schema/register'; +import { educationsMap } from '@/const/educations'; +import { universities } from '@/const/universities'; +import { faculties } from '@/const/faculties'; +import { sizeJersey } from '@/const/size'; +import { statusMap } from '@/const/status'; interface TwoProps { setStep: (value: number) => void; - form: UseFormReturn; + form: UseFormReturn; } export default function Two({ setStep, form }: TwoProps) { + const { client } = useLiff(); + const { register: sendRegister, registerError } = useAuth(); const { handleSubmit, register, @@ -34,48 +40,65 @@ export default function Two({ setStep, form }: TwoProps) { const user = watch(); - const onSubmit: SubmitHandler = data => { - console.log(data); - onNext(); + const onSubmit: SubmitHandler = async data => { + const context = client?.getContext(); + const userId = context?.userId; + if (!userId) { + console.error('Failed submit register form: userId is undefined'); + return; + } + + const toastId = toast.loading('กำลังส่ง'); + const resp = await sendRegister({ + ...data, + id: userId, + }); + if (resp.success) { + onNext(); + toast.success('ลงทะเบียนสำเร็จ'); + } else { + toast.error(resp.error.message); + } + toast.dismiss(toastId); }; - const updateField = (field: keyof User) => { - return (value: User[keyof User]) => { + const updateField = (field: keyof RegisterForm) => { + return (value: RegisterForm[keyof RegisterForm]) => { setValue(field, value); }; }; useEffect(() => { - let status: User['status']; + let status: RegisterForm['status']; // set default value if ( - !!user.study && + !!user.education && !!user.university && - (user.study != 'จบการศึกษาแล้ว' || + (user.education != 'graduated' || user.university != 'จุฬาลงกรณ์มหาวิทยาลัย') ) { - setValue('graduateYear', '9999'); - setValue('graduateFaculty', 'ไม่ระบุ'); + setValue('graduatedYear', '9999'); + setValue('faculty', 'ไม่ระบุ'); } // set status - if (user.study == 'กำลังศึกษาอยู่') { + if (user.education == 'studying') { if (user.university == 'จุฬาลงกรณ์มหาวิทยาลัย') { - status = 'นิสิตปัจจุบัน'; + status = 'chula_student'; } else { - status = 'นักศึกษา'; + status = 'general_student'; } } else { if (user.university == 'จุฬาลงกรณ์มหาวิทยาลัย') { - status = 'นิสิตเก่า'; + status = 'alumni'; } else { - status = 'บุคคลทั่วไป'; + status = 'general_public'; } } setValue('status', status); - }, [user.study, user.university, setValue]); + }, [user.education, user.university, setValue]); function onBack() { setStep(1); @@ -93,45 +116,44 @@ export default function Two({ setStep, form }: TwoProps) { backMsg="กลับ" >
- {/* fullname */} + {/* name */}
- - + + {errors.name?.message}
{/* email */}
- + {errors.email?.message}
- {/* tel */} + {/* phone */}
- - + + {errors.phone?.message}
- {/* study */} + {/* education */}
setValue('education', educationsMap[val])} placeholder="กำลังศึกษาอยู่" - choices={[...studies]} + choices={Object.keys(educationsMap).map(key => key)} /> - + {errors.education?.message}
{/* university */} - {/* TODO: optimize here, there are 390 universties in list */} - {user.study && ( + {user.education && (
@@ -143,26 +165,26 @@ export default function Two({ setStep, form }: TwoProps) { searchText="ค้นหามหาวิทยาลัย" emptyText="ไม่มีข้อมูล" /> - + {errors.university?.message}
)} {/* status */}
- - + + {errors.status?.message}
{/* graduate year && graduate faculty*/} - {user.study == 'จบการศึกษาแล้ว' && + {user.education == 'graduated' && user.university == 'จุฬาลงกรณ์มหาวิทยาลัย' && (
(2468 + i).toString(), )} /> - + {errors.graduatedYear?.message}
- + {errors.faculty?.message}
)} - {/* birthDate */} + {/* age */}
- - - + + + {errors.age?.message}
{/* size */}
- + {errors.sizeJersey?.message}
{/* foodAllegy */}
- - + + {errors.foodLimitation?.message}
{/* disease */}
- - + + {errors.chronicDisease?.message}
{/* drugAllegy */}
- - + + {errors.drugAllergy?.message}
{/* idCardImg */}
- - + + {errors.image?.message}
{/* confirm */} @@ -254,10 +270,11 @@ export default function Two({ setStep, form }: TwoProps) { {/* submit */} -
+
+ {registerError?.message}
diff --git a/src/app/(user)/register/_data/size.ts b/src/app/(user)/register/_data/size.ts deleted file mode 100644 index 9a23e27..0000000 --- a/src/app/(user)/register/_data/size.ts +++ /dev/null @@ -1 +0,0 @@ -export const sizes = ['S', 'M', 'L', 'XL', '2XL'] as const; diff --git a/src/app/(user)/register/_data/statuses.ts b/src/app/(user)/register/_data/statuses.ts deleted file mode 100644 index 75cfd89..0000000 --- a/src/app/(user)/register/_data/statuses.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const statuses = [ - 'นิสิตปัจจุบัน', - 'นิสิตเก่า', - 'บุคคลทั่วไป', - 'นักศึกษา', -] as const; diff --git a/src/app/(user)/register/_data/studies.ts b/src/app/(user)/register/_data/studies.ts deleted file mode 100644 index 83dc319..0000000 --- a/src/app/(user)/register/_data/studies.ts +++ /dev/null @@ -1 +0,0 @@ -export const studies = ['กำลังศึกษาอยู่', 'จบการศึกษาแล้ว'] as const; diff --git a/src/app/(user)/register/schema/user.ts b/src/app/(user)/register/_schema/user.ts similarity index 57% rename from src/app/(user)/register/schema/user.ts rename to src/app/(user)/register/_schema/user.ts index 07e89c0..3c9be08 100644 --- a/src/app/(user)/register/schema/user.ts +++ b/src/app/(user)/register/_schema/user.ts @@ -1,25 +1,25 @@ import { z } from 'zod'; -import { universities } from '../_data/universities'; -import { faculties } from '../_data/faculties'; -import { statuses } from '../_data/statuses'; -import { sizes } from '../_data/size'; -import { studies } from '../_data/studies'; +import { status } from '@/const/status'; +import { educations } from '@/const/educations'; +import { sizeJersey } from '@/const/size'; +import { universities } from '@/const/universities'; +import { faculties } from '@/const/faculties'; -export const UserSchema = z.object({ - fullname: z.string().min(1, 'กรุณากรอกชื่อ-นามสกุล'), +export const RegisterSchema = z.object({ + name: z.string().min(1, 'กรุณากรอกชื่อ-นามสกุล'), email: z.string().email('กรุณากรอกอีเมล'), - tel: z.string().regex(/^\d+$/, 'กรุณากรอกเลข 0-9 เท่านั้น'), + phone: z.string().regex(/^\d+$/, 'กรุณากรอกเลข 0-9 เท่านั้น'), birthdate: z.date({ message: 'กรุณากรอกวันเกิด', }), - size: z.enum(sizes, { + sizeJersey: z.enum(sizeJersey, { message: 'กรุณาเลือกขนาดเสื้อ', }), - study: z.enum(studies, { message: 'กรุณาเลือกการศึกษา' }), - foodAllegy: z.string(), + education: z.enum(educations, { message: 'กรุณาเลือกการศึกษา' }), + foodLimitation: z.string(), disease: z.string(), drugAllegy: z.string(), - idCardImg: z + image: z .instanceof(File, { message: 'กรุณาอัพโหลดรูปบัตรประชาชน', }) @@ -29,13 +29,13 @@ export const UserSchema = z.object({ university: z.enum(universities, { message: 'กรุณาเลือกมหาวิทยาลัย', }), - status: z.enum(statuses, { + status: z.enum(status, { message: 'กรุณาเลือกสถานะ', }), - graduateYear: z + graduatedYear: z .string({ message: 'กรุณากรอกปีที่สำเร็จการศึกษา' }) .regex(/^\d{4}$/, { message: 'กรุณากรอกปีที่สำเร็จการศึกษา' }), - graduateFaculty: z.enum([...faculties, 'ไม่ระบุ'], { + faculty: z.enum([...faculties, 'ไม่ระบุ'], { message: 'กรุณาเลือกคณะที่สำเร็จการศึกษา', }), isConfirm: z @@ -44,4 +44,15 @@ export const UserSchema = z.object({ .nullable(), }); -export type User = z.infer; +export type RegisterForm = z.infer; + +export interface RegisterReq extends RegisterForm { + id: string; +} + +export const RegisterRespSchema = z.object({ + accessToken: z.string(), + userId: z.string(), +}); + +export type RegisterResp = z.infer; diff --git a/src/app/(user)/register/page.tsx b/src/app/(user)/register/page.tsx index 3a49fa4..84a185c 100644 --- a/src/app/(user)/register/page.tsx +++ b/src/app/(user)/register/page.tsx @@ -8,16 +8,21 @@ import Three from './_components/subpages/three'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { User, UserSchema } from './schema/user'; +import { RegisterForm, RegisterSchema } from '@/schema/register'; export default function Page() { - const [step, setStep] = useState(1); + const [step, setStep] = useState(2); const [isTerm, setIsTerm] = useState(false); const [isPDPA, setIsPDPA] = useState(false); - const form = useForm({ - resolver: zodResolver(UserSchema), + const form = useForm({ + resolver: zodResolver(RegisterSchema), + defaultValues: { + drugAllergy: '-', + foodLimitation: '-', + chronicDisease: '-', + }, }); function getPage(): ReactNode { diff --git a/src/app/admin/dashboard/_components/addRole.tsx b/src/app/admin/dashboard/_components/addRole.tsx index e707174..d49b8b9 100644 --- a/src/app/admin/dashboard/_components/addRole.tsx +++ b/src/app/admin/dashboard/_components/addRole.tsx @@ -2,6 +2,7 @@ import TextInput from '@/app/(user)/register/_components/textInput'; import { Button } from '@/components/ui/button'; import { UserPlus } from 'lucide-react'; + export default function AddRole() { return (
@@ -10,10 +11,10 @@ export default function AddRole() { placeholder="add phone number" />
diff --git a/src/app/admin/dashboard/_components/detail.tsx b/src/app/admin/dashboard/_components/detail.tsx index 18ae129..37cd9e0 100644 --- a/src/app/admin/dashboard/_components/detail.tsx +++ b/src/app/admin/dashboard/_components/detail.tsx @@ -1,39 +1,32 @@ 'use client'; -import React, { useState } from 'react'; +import React from 'react'; import AddRole from './addRole'; import SearchStaff from './searchStaff'; import UserList from './userList'; +import { User } from '@/schema/user'; -const roles = ['admin', 'staff'] as const; -const names = [ - 'สมชาย วงศ์สวัสดิ์', - 'สมศักดิ์ ไชยสงคราม', - 'สมพงษ์ ศรีสวัสดิ์', - 'สมพร เจริญสุข', - 'สมบูรณ์ รัตนพงษ์', - 'สมจิต ทองชัย', - 'สมนึก พงษ์เทพ', - 'สมชาย ทองดี', - 'สมศรี ไชยสงคราม', - 'สมยศ วงศ์สวัสดิ์', - 'สมชาย ศรีสวัสดิ์', - 'สมชาย เจริญสุข', -]; - -const users = Array.from({ length: 20 }, () => ({ - UID: `#${Math.floor(Math.random() * 10000)}`, - name: names[Math.floor(Math.random() * names.length)], - role: roles[Math.floor(Math.random() * roles.length)], -})); - -export default function Detail() { - const [name, setName] = useState(''); +interface DetailProps { + users: User[]; + name: string; + setName: (name: string) => void; + handleSearch: () => void; +} +export default function Detail({ + users, + setName, + name, + handleSearch, +}: DetailProps) { return (
- setName(e.target.value)} /> + setName(e.target.value)} + handleSearch={handleSearch} + />
); diff --git a/src/app/admin/dashboard/_components/searchStaff.tsx b/src/app/admin/dashboard/_components/searchStaff.tsx index b6737e0..323e860 100644 --- a/src/app/admin/dashboard/_components/searchStaff.tsx +++ b/src/app/admin/dashboard/_components/searchStaff.tsx @@ -3,10 +3,15 @@ import { cn } from '@/lib/utils'; import { Search } from 'lucide-react'; import React from 'react'; +interface SearchStaffProps extends React.ComponentPropsWithoutRef<'input'> { + handleSearch: () => void; +} + export default function SearchStaff({ className, + handleSearch, ...props -}: React.ComponentPropsWithoutRef<'input'>) { +}: SearchStaffProps) { return (
- +
); } diff --git a/src/app/admin/dashboard/_components/table/action.tsx b/src/app/admin/dashboard/_components/table/action.tsx index c7675ee..9d54840 100644 --- a/src/app/admin/dashboard/_components/table/action.tsx +++ b/src/app/admin/dashboard/_components/table/action.tsx @@ -8,26 +8,33 @@ import { } from '@radix-ui/react-popover'; import ActionList from './actionList'; +import { useDeleteUser } from '../../api/user'; +import { useAuth } from '@/contexts/auth'; -const actions = [ - { - imageURL: '/admin/dashboard/circle-x.svg', - className: 'bg-error', - text: 'Remove', - }, - { - imageURL: '/admin/dashboard/profile.svg', - className: 'bg-dark-pink', - text: 'Change to Staff', - }, - { - imageURL: '/admin/dashboard/crown.svg', - className: 'bg-dark-blue', - text: 'Change to Admin', - }, -]; +export default function Action({ id }: { id: string }) { + const { token } = useAuth(); + const { mutate: deleteUser } = useDeleteUser(id, token?.accessToken || ''); -export default function Action() { + const actions = [ + { + imageURL: '/admin/dashboard/circle-x.svg', + className: 'bg-error', + text: 'Remove', + fn: deleteUser, + }, + { + imageURL: '/admin/dashboard/profile.svg', + className: 'bg-dark-pink', + text: 'Change to Staff', + fn: () => {}, + }, + { + imageURL: '/admin/dashboard/crown.svg', + className: 'bg-dark-blue', + text: 'Change to Admin', + fn: () => {}, + }, + ]; return ( diff --git a/src/app/admin/dashboard/_components/table/actionList.tsx b/src/app/admin/dashboard/_components/table/actionList.tsx index 1365d4b..ff71e94 100644 --- a/src/app/admin/dashboard/_components/table/actionList.tsx +++ b/src/app/admin/dashboard/_components/table/actionList.tsx @@ -5,12 +5,14 @@ import Image from 'next/image'; interface ActionListProps extends React.ComponentPropsWithoutRef<'div'> { imageURL: string; text: string; + fn: () => void; } export default function ActionList({ imageURL, className, text, + fn, ...props }: ActionListProps) { return ( @@ -20,6 +22,7 @@ export default function ActionList({ className, )} {...props} + onClick={fn} > icon {text} diff --git a/src/app/admin/dashboard/_components/table/role.tsx b/src/app/admin/dashboard/_components/table/role.tsx index e990f14..497dd1c 100644 --- a/src/app/admin/dashboard/_components/table/role.tsx +++ b/src/app/admin/dashboard/_components/table/role.tsx @@ -2,7 +2,7 @@ import { cn } from '@/lib/utils'; import React from 'react'; interface RoleProps extends React.ComponentPropsWithoutRef<'div'> { - role: 'admin' | 'staff'; + role: 'admin' | 'staff' | 'member'; } export default function Role({ className, role, ...props }: RoleProps) { diff --git a/src/app/admin/dashboard/_components/table/userRow.tsx b/src/app/admin/dashboard/_components/table/userRow.tsx index 49ebf3f..7172703 100644 --- a/src/app/admin/dashboard/_components/table/userRow.tsx +++ b/src/app/admin/dashboard/_components/table/userRow.tsx @@ -4,23 +4,20 @@ import { Row } from './row'; import { Col } from './col'; import Role from './role'; import Action from './action'; +import { User } from '@/schema/user'; -export interface UserRowProps { - UID: string; - name: string; - role: 'admin' | 'staff'; -} - -export default function UserRow({ UID, name, role }: UserRowProps) { +export default function UserRow({ user }: { user: User }) { return ( - {UID} - {name} + + {user.id} + + {user.name} - + - + ); diff --git a/src/app/admin/dashboard/_components/userList.tsx b/src/app/admin/dashboard/_components/userList.tsx index 4aaf033..8e7a95c 100644 --- a/src/app/admin/dashboard/_components/userList.tsx +++ b/src/app/admin/dashboard/_components/userList.tsx @@ -1,9 +1,10 @@ import React from 'react'; import Header from './table/header'; -import UserRow, { UserRowProps } from './table/userRow'; +import UserRow from './table/userRow'; +import { User } from '@/schema/user'; interface UserListProps { - users: UserRowProps[]; + users: User[]; } export default function UserList({ users }: UserListProps) { @@ -11,7 +12,7 @@ export default function UserList({ users }: UserListProps) {
{users.map(user => ( - + ))}
); diff --git a/src/app/admin/dashboard/api/query.ts b/src/app/admin/dashboard/api/query.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app/admin/dashboard/api/user.ts b/src/app/admin/dashboard/api/user.ts new file mode 100644 index 0000000..2c42c43 --- /dev/null +++ b/src/app/admin/dashboard/api/user.ts @@ -0,0 +1,37 @@ +import { apiClient } from '@/utils/axios'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +export async function getUsers(accessToken: string, name?: string) { + const query = name ? `?name=${name}` : ''; + const resp = await apiClient.get(`/users${query}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return resp.data; +} + +export function useGetUsers(accessToken: string, name?: string) { + return useQuery({ + queryKey: ['users'], + queryFn: () => getUsers(accessToken, name), + }); +} + +export async function deleteUser(id: string, accessToken: string) { + return await apiClient.delete(`/users/${id}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); +} + +export function useDeleteUser(id: string, accessToken: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => deleteUser(id, accessToken), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + }, + }); +} diff --git a/src/app/admin/dashboard/layout.tsx b/src/app/admin/dashboard/layout.tsx new file mode 100644 index 0000000..ee05e05 --- /dev/null +++ b/src/app/admin/dashboard/layout.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx index 26a5d30..35aa51d 100644 --- a/src/app/admin/dashboard/page.tsx +++ b/src/app/admin/dashboard/page.tsx @@ -1,14 +1,43 @@ +'use client'; + import Background from '@/components/staff/qr/background'; import Top from './_components/top'; import Detail from './_components/detail'; +import Load from '@/components/loading/loading'; +import { useGetUsers } from './api/user'; +import { useState } from 'react'; +import { useAuth } from '@/contexts/auth'; + +export default function Page() { + const [name, setName] = useState(''); + const { token } = useAuth(); + const { + isLoading, + refetch, + data: users, + } = useGetUsers(token?.accessToken || '', name); + + function handleSearch() { + refetch(); + } -export default function page() { return (
- - -
- + {isLoading ? ( + + ) : ( + <> + + +
+ + + )}
); } diff --git a/src/app/config.ts b/src/app/config.ts index ec85205..c273ce4 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -1,3 +1,4 @@ export const config = { liffId: process.env.NEXT_PUBLIC_LIFF_ID || '', + baseURL: process.env.NEXT_PUBLIC_BASE_URL || '', }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a8f4331..68b7225 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,11 @@ import type { Metadata } from 'next'; import { Anuphan } from 'next/font/google'; import './globals.css'; + +import { Toaster } from 'react-hot-toast'; + import LineProvider from '../contexts/liff'; +import AuthProvider from '@/contexts/auth'; const anuphan = Anuphan({ variable: '--font-anuphan', @@ -31,8 +35,11 @@ export default async function RootLayout({ +
- {children} + + {children} +
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 2edf28c..36e0e73 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -33,17 +33,34 @@ export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; + inClassName?: string; } const Button = React.forwardRef( - ({ className, variant, size, children, asChild = false, ...props }, ref) => { + ( + { + className, + variant, + size, + children, + asChild = false, + inClassName, + ...props + }, + ref, + ) => { const Comp = asChild ? Slot : 'button'; return variant == 'outline' ? ( diff --git a/src/const/educations.ts b/src/const/educations.ts new file mode 100644 index 0000000..0213e02 --- /dev/null +++ b/src/const/educations.ts @@ -0,0 +1,7 @@ +export const educations = ['studying', 'graduated'] as const; +export type Education = (typeof educations)[number]; + +export const educationsMap: { [key: string]: Education } = { + กำลังศึกษา: 'studying', + จบการศึกษา: 'graduated', +}; diff --git a/src/app/(user)/register/_data/faculties.ts b/src/const/faculties.ts similarity index 98% rename from src/app/(user)/register/_data/faculties.ts rename to src/const/faculties.ts index ced7655..433fd60 100644 --- a/src/app/(user)/register/_data/faculties.ts +++ b/src/const/faculties.ts @@ -39,3 +39,5 @@ export const faculties = [ 'สถาบันวิจัยสังคม', 'สถาบันเอเชียศึกษา', ] as const; + +export type Faculty = (typeof faculties)[number]; \ No newline at end of file diff --git a/src/app/(user)/register/_data/policy.ts b/src/const/policy.ts similarity index 100% rename from src/app/(user)/register/_data/policy.ts rename to src/const/policy.ts diff --git a/src/const/role.ts b/src/const/role.ts new file mode 100644 index 0000000..a923725 --- /dev/null +++ b/src/const/role.ts @@ -0,0 +1 @@ +export const roles = ['member', 'staff', 'admin'] as const; diff --git a/src/const/size.ts b/src/const/size.ts new file mode 100644 index 0000000..7f6a784 --- /dev/null +++ b/src/const/size.ts @@ -0,0 +1,3 @@ +export const sizeJersey = ['S', 'M', 'L', 'XL', '2XL'] as const; + +export type SizeJersey = (typeof sizeJersey)[number]; \ No newline at end of file diff --git a/src/const/status.ts b/src/const/status.ts new file mode 100644 index 0000000..350013b --- /dev/null +++ b/src/const/status.ts @@ -0,0 +1,15 @@ +export const status = [ + 'chula_student', + 'general_student', + 'alumni', + 'general_public', +] as const; + +export const statusMap = { + chula_student: 'นิสิตปัจจุบัน', + alumni: 'นิสิตเก่า', + general_student: 'นักศึกษา', + general_public: 'บุคคลทั่วไป', +}; + +export type Status = (typeof status)[number]; \ No newline at end of file diff --git a/src/app/(user)/register/_data/universities.ts b/src/const/universities.ts similarity index 99% rename from src/app/(user)/register/_data/universities.ts rename to src/const/universities.ts index 5efb013..2ebe045 100644 --- a/src/app/(user)/register/_data/universities.ts +++ b/src/const/universities.ts @@ -169,7 +169,7 @@ export const universities = [ 'มหาวิทยาลัยราชภัฏสวนสุนันทา', 'มหาวิทยาลัยราชภัฏสุรินทร์', 'มหาวิทยาลัยราชภัฏหมู่บ้านจอมบึง', - 'มหาวิทยาลัยราชภัฏอุดรธานี\xa0', + 'มหาวิทยาลัยราชภัฏอุดรธานี', 'มหาวิทยาลัยราชภัฏอุตรดิตถ์', 'มหาวิทยาลัยราชภัฏอุบลราชธานี', 'มหาวิทยาลัยราชภัฏนครสวรรค์', @@ -390,3 +390,5 @@ export const universities = [ 'โรงเรียนเสนาธิการทหารบก', 'วิทยาลัยนานาชาติราฟเฟิลส์', ] as const; + +export type University = (typeof universities)[number]; diff --git a/src/contexts/auth.tsx b/src/contexts/auth.tsx new file mode 100644 index 0000000..62759b5 --- /dev/null +++ b/src/contexts/auth.tsx @@ -0,0 +1,154 @@ +'use client'; + +import React, { + createContext, + ReactNode, + useContext, + useEffect, + useState, +} from 'react'; + +import { + getUser, + login as sendLogin, + register as sendRegister, + edit as sendEdit, + setAuthData, +} from '@/utils/auth'; + +import { useLiff } from './liff'; +import { Result } from '@/utils/error'; +import { AuthToken } from '@/schema/auth'; +import { User } from '@/schema/user'; +import { LoginResp } from '@/schema/login'; +import { RegisterReq, RegisterResp } from '@/schema/register'; +import { EditReq } from '@/schema/edit'; + +interface AuthState { + user: User | undefined; + token: AuthToken | undefined; + isLoggedIn: boolean; + loginError: Error | undefined; + registerError: Error | undefined; + editError: Error | undefined; + isLoggingIn: boolean; + isRegistering: boolean; + isEditing: boolean; + login(): Promise>; + edit(req: EditReq): Promise>; + register(req: RegisterReq): Promise>; +} + +const AuthContext = createContext({} as AuthState); + +export const useAuth = () => useContext(AuthContext); + +export default function AuthProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ + user: undefined as User | undefined, + token: undefined as AuthToken | undefined, + isLoggedIn: false, + loginError: undefined as Error | undefined, + registerError: undefined as Error | undefined, + editError: undefined as Error | undefined, + isLoggingIn: false, + isRegistering: false, + isEditing: false, + }); + const { client } = useLiff(); + + type State = typeof state; + function set(key: keyof State, val: State[keyof State]) { + setState(prev => ({ ...prev, [key]: val })); + } + + async function login(): Promise> { + const context = client?.getContext(); + const userId = context?.userId; + if (!userId) { + const error = new Error('login error: user id is undefined'); + console.error(error.message); + return { success: false, error: error }; + } + + set('isLoggingIn', true); + const loginResp = await sendLogin(userId); + + if (loginResp.success) { + set('token', loginResp.result); + set('isLoggedIn', true); + } else { + console.error('login failed:', loginResp.error); + set('loginError', loginResp.error); + return loginResp; + } + + const UserResp = await getUser(userId); + if (UserResp.success) { + set('user', UserResp.result); + } else { + console.error('get user failed:', UserResp.error); + set('loginError', UserResp.error); + return UserResp; + } + + set('isLoggedIn', false); + + return { + success: true, + result: { ...UserResp.result, ...loginResp.result }, + }; + } + + async function register(data: RegisterReq) { + set('isRegistering', true); + const resp = await sendRegister(data); + + if (resp.success) { + setAuthData(resp.result); + } else { + console.error('register faild:', resp.error); + set('registerError', resp.error); + return resp; + } + + set('isRegistering', false); + return await login(); + } + + async function edit(data: RegisterReq) { + if (!state.token?.accessToken) { + const error = new Error(state.token?.accessToken); + console.error('Failed edit:', error); + return { success: false, error } as Result; + } + set('isEditing', true); + const resp = await sendEdit(data, state.token.accessToken); + + if (!resp.success) { + console.error('edit faild:', resp.error); + set('editError', resp.error); + } + + set('isEditing', false); + return resp; + } + + useEffect(() => { + login(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {children} + + ); +} diff --git a/src/contexts/liff.tsx b/src/contexts/liff.tsx index e3e58f5..bf105a4 100644 --- a/src/contexts/liff.tsx +++ b/src/contexts/liff.tsx @@ -30,25 +30,27 @@ export default function LineProvider({ async function init() { try { await liff.init({ liffId: config.liffId }); + setState({ client: liff, error: null, isInitializing: false, }); - if (!liff.isLoggedIn()) { setState(prev => ({ ...prev, isInitializing: true })); liff.login(); } - } catch (error) { + } catch (raw: unknown) { + const error = + raw instanceof Error ? raw : new Error('Failed to initialize LIFF'); + setState({ client: null, - error: - error instanceof Error - ? error - : new Error('Failed to initialize LIFF'), + error: error, isInitializing: false, }); + + console.error('error init liff: ', error); } } diff --git a/src/schema/auth.ts b/src/schema/auth.ts new file mode 100644 index 0000000..d52c482 --- /dev/null +++ b/src/schema/auth.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const AuthTokenSchema = z.object({ + accessToken: z.string(), + userId: z.string(), +}); + +export type AuthToken = z.infer; diff --git a/src/schema/edit.ts b/src/schema/edit.ts new file mode 100644 index 0000000..0f919a5 --- /dev/null +++ b/src/schema/edit.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +import { status } from '@/const/status'; +import { educations } from '@/const/educations'; +import { sizeJersey } from '@/const/size'; +import { universities } from '@/const/universities'; +import { faculties } from '@/const/faculties'; + +export const EditSchema = z.object({ + name: z.string().min(1, 'กรุณากรอกชื่อ-นามสกุล'), + email: z.string().email('กรุณากรอกอีเมล'), + phone: z.string().regex(/^\d+$/, 'กรุณากรอกเลข 0-9 เท่านั้น'), + age: z + .string({ + message: 'กรุณากรอกอายุ', + }) + .regex(/^\d+$/, 'กรุณากรอกเลข 0-9 เท่านั้น'), + sizeJersey: z.enum(sizeJersey, { + message: 'กรุณาเลือกขนาดเสื้อ', + }), + education: z.enum(educations, { message: 'กรุณาเลือกการศึกษา' }), + foodLimitation: z.string(), + chronicDisease: z.string(), + drugAllergy: z.string(), + university: z.enum(universities, { + message: 'กรุณาเลือกมหาวิทยาลัย', + }), + status: z.enum(status, { + message: 'กรุณาเลือกสถานะ', + }), + graduatedYear: z + .string({ message: 'กรุณากรอกปีที่สำเร็จการศึกษา' }) + .regex(/^\d{4}$/, { message: 'กรุณากรอกปีที่สำเร็จการศึกษา' }), + faculty: z.enum([...faculties, 'ไม่ระบุ'], { + message: 'กรุณาเลือกคณะที่สำเร็จการศึกษา', + }), +}); + +export type EditForm = z.infer; + +export interface EditReq extends EditForm { + id: string; +} diff --git a/src/schema/error.ts b/src/schema/error.ts new file mode 100644 index 0000000..b70af28 --- /dev/null +++ b/src/schema/error.ts @@ -0,0 +1,3 @@ +export type ErrorDTO = { + error: string; +}; diff --git a/src/schema/login.ts b/src/schema/login.ts new file mode 100644 index 0000000..9921c37 --- /dev/null +++ b/src/schema/login.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const LoginRespSchema = z.object({ + accessToken: z.string(), + userId: z.string(), +}); + +export type LoginResp = z.infer; diff --git a/src/schema/register.ts b/src/schema/register.ts new file mode 100644 index 0000000..357df9b --- /dev/null +++ b/src/schema/register.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; +import { status } from '@/const/status'; +import { educations } from '@/const/educations'; +import { sizeJersey } from '@/const/size'; +import { universities } from '@/const/universities'; +import { faculties } from '@/const/faculties'; + +export const RegisterSchema = z.object({ + name: z.string().min(1, 'กรุณากรอกชื่อ-นามสกุล'), + email: z.string().email('กรุณากรอกอีเมล'), + phone: z.string().regex(/^\d+$/, 'กรุณากรอกเลข 0-9 เท่านั้น'), + age: z + .string({ + message: 'กรุณากรอกอายุ', + }) + .regex(/^\d+$/, 'กรุณากรอกเลข 0-9 เท่านั้น'), + sizeJersey: z.enum(sizeJersey, { + message: 'กรุณาเลือกขนาดเสื้อ', + }), + education: z.enum(educations, { message: 'กรุณาเลือกการศึกษา' }), + foodLimitation: z.string(), + chronicDisease: z.string(), + drugAllergy: z.string(), + image: z + .instanceof(File, { + message: 'กรุณาอัพโหลดรูปบัตรประชาชน', + }) + .refine(file => { + return ['image/jpeg', 'image/png', 'image/webp'].includes(file.type); + }, 'กรุณาอัพโหลดเฉพาะ .jpg, .png, .webp'), + university: z.enum(universities, { + message: 'กรุณาเลือกมหาวิทยาลัย', + }), + status: z.enum(status, { + message: 'กรุณาเลือกสถานะ', + }), + graduatedYear: z + .string({ message: 'กรุณากรอกปีที่สำเร็จการศึกษา' }) + .regex(/^\d{4}$/, { message: 'กรุณากรอกปีที่สำเร็จการศึกษา' }), + faculty: z.enum([...faculties, 'ไม่ระบุ'], { + message: 'กรุณาเลือกคณะที่สำเร็จการศึกษา', + }), + isConfirm: z + .boolean({ message: 'กรุณากดยืนยันข้อมูล' }) + .refine(value => value, { message: 'กรุณากดยืนยันข้อมูล' }) + .nullable(), +}); + +export type RegisterForm = z.infer; + +export interface RegisterReq extends RegisterForm { + id: string; +} + +export const RegisterRespSchema = z.object({ + accessToken: z.string(), + userId: z.string(), +}); + +export type RegisterResp = z.infer; diff --git a/src/schema/user.ts b/src/schema/user.ts new file mode 100644 index 0000000..04d6cb4 --- /dev/null +++ b/src/schema/user.ts @@ -0,0 +1,34 @@ +import { educations } from '@/const/educations'; +import { faculties } from '@/const/faculties'; +import { roles } from '@/const/role'; +import { sizeJersey } from '@/const/size'; +import { status } from '@/const/status'; +import { universities } from '@/const/universities'; +import { z } from 'zod'; + +export const UserSchema = z.object({ + id: z.string(), + name: z.string(), + education: z.enum(educations), + email: z.string(), + faculty: z.enum(faculties), + foodLimitation: z.string(), + graduatedYear: z.string(), + imageURL: z.string(), + invitationCode: z.string(), + lastEntered: z.string(), + phone: z.string(), + role: z.enum(roles), + sizeJersey: z.enum(sizeJersey), + status: z.enum(status), + university: z.enum(universities), + age: z + .string({ + message: 'กรุณากรอกอายุ', + }) + .regex(/^\d+$/, 'กรุณากรอกเลข 0-9 เท่านั้น'), + drugAllergy: z.string(), + chronicDisease: z.string(), +}); + +export type User = z.infer; diff --git a/src/types/user.ts b/src/types/user.ts deleted file mode 100644 index 9c9b496..0000000 --- a/src/types/user.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type User = { - fullname: string; - email: string; - tel: string; - birthdate: Date; - size: string; - foodAllegy: string; - disease: string; - drugAllegy: string; - idCardImg: File | null; - isConfirmCorrectInfo: boolean; - isAcceptTermAndCondition: boolean; - isAcceptPDPA: boolean; - study: string; - university: string; - status: string; - graduatedYear: number; - graduatedFaculty: string; -}; diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..c985fdf --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,107 @@ +import { + getLocalStorageObj, + LocalStorageKey, + setLocalStorageObj, +} from './localstorage'; + +import { AuthToken, AuthTokenSchema } from '@/schema/auth'; +import { RegisterReq, RegisterResp } from '@/schema/register'; +import { User } from '@/schema/user'; +import { LoginResp } from '@/schema/login'; +import { EditReq } from '@/schema/edit'; +import { Result } from '@/utils/error'; + +import { AxiosError } from 'axios'; +import { apiClient } from './axios'; +import { ErrorDTO } from '@/schema/error'; + +export const authKey: LocalStorageKey = 'auth-data'; + +export function getAuthData(): AuthToken | null { + const { success, data } = AuthTokenSchema.safeParse( + getLocalStorageObj(authKey), + ); + return success ? data : null; +} + +export function setAuthData(data: AuthToken) { + setLocalStorageObj(authKey, data); +} + +export async function register( + req: RegisterReq, +): Promise> { + try { + const res = await apiClient.postForm('/users/register', req); + return { success: true, result: res.data }; + } catch (raw: unknown) { + const error = raw as AxiosError; + return { + success: false, + error: new Error(error.response?.data.error || error.message), + }; + } +} + +export const login = async (id: string): Promise> => { + try { + const resp = await apiClient.post(`/users/signin`, `"${id}"`, { + headers: { + 'Content-Type': 'apllcation/json', + }, + }); + setAuthData(resp.data); + return { success: true, result: resp.data }; + } catch (raw: unknown) { + const error = raw as AxiosError; + return { + success: false, + error: new Error( + error.response ? error.response.data.error : error.message, + ), + }; + } +}; + +export async function getUser(id: string): Promise> { + try { + const resp = await apiClient.get(`/users/${id}`, { + headers: { + Authorization: `Bearer ${getAuthData()?.accessToken}`, + }, + }); + return { success: true, result: resp.data }; + } catch (raw: unknown) { + const error = raw as AxiosError; + return { + success: false, + error: new Error( + error.response ? error.response.data.error : error.message, + ), + }; + } +} + +export async function edit( + req: EditReq, + accessToken: string, +): Promise> { + try { + await apiClient.patch(`/users`, req, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return { success: true, result: null }; + } catch (raw: unknown) { + const error = raw as AxiosError; + if (error.response) { + return { + success: false, + error: new Error(error.response.data.error), + }; + } else { + return { success: false, error: new Error(error.message) }; + } + } +} diff --git a/src/utils/axios.ts b/src/utils/axios.ts new file mode 100644 index 0000000..00d2705 --- /dev/null +++ b/src/utils/axios.ts @@ -0,0 +1,6 @@ +import { config } from '@/app/config'; +import axios from 'axios'; + +export const apiClient = axios.create({ + baseURL: config.baseURL, +}); diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000..ded5c07 --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,17 @@ +export function ensureError(value: unknown): Error { + if (value instanceof Error) return value; + + let stringified = '[Unable to stringify the thrown value]'; + try { + stringified = JSON.stringify(value); + } catch {} + + const error = new Error( + `This value was thrown as is, not through an Error: ${stringified}`, + ); + return error; +} + +export type Result = + | { success: true; result: T } + | { success: false; error: E }; diff --git a/src/utils/localstorage.ts b/src/utils/localstorage.ts new file mode 100644 index 0000000..2c0ca7e --- /dev/null +++ b/src/utils/localstorage.ts @@ -0,0 +1,10 @@ +export type LocalStorageKey = string; + +export function setLocalStorageObj(key: LocalStorageKey, data: T) { + localStorage.setItem(key, JSON.stringify(data)); +} + +export function getLocalStorageObj(key: string): unknown | null { + const raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : null; +}