Merge pull request #46 from aaronpo97/create-brewery

Create brewery page
This commit is contained in:
Aaron Po
2023-06-10 22:24:06 -04:00
committed by GitHub
30 changed files with 991 additions and 106 deletions

208
package-lock.json generated
View File

@@ -13,6 +13,8 @@
"@headlessui/tailwindcss": "^0.1.3", "@headlessui/tailwindcss": "^0.1.3",
"@hookform/resolvers": "^3.1.0", "@hookform/resolvers": "^3.1.0",
"@mapbox/mapbox-sdk": "^0.15.1", "@mapbox/mapbox-sdk": "^0.15.1",
"@mapbox/search-js-core": "^1.0.0-beta.16",
"@mapbox/search-js-react": "^1.0.0-beta.16",
"@next/bundle-analyzer": "^13.4.4", "@next/bundle-analyzer": "^13.4.4",
"@prisma/client": "^4.15.0", "@prisma/client": "^4.15.0",
"@react-email/components": "^0.0.6", "@react-email/components": "^0.0.6",
@@ -81,7 +83,7 @@
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"tailwindcss-animate": "^1.0.5", "tailwindcss-animate": "^1.0.5",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.1.3" "typescript": "^4.9.0"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@@ -638,6 +640,19 @@
"npm": ">=6.14.13" "npm": ">=6.14.13"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.7.3.tgz",
"integrity": "sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg=="
},
"node_modules/@floating-ui/dom": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.5.4.tgz",
"integrity": "sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg==",
"dependencies": {
"@floating-ui/core": "^0.7.3"
}
},
"node_modules/@hapi/b64": { "node_modules/@hapi/b64": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-6.0.1.tgz", "resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-6.0.1.tgz",
@@ -989,6 +1004,74 @@
"polyline": "bin/polyline.bin.js" "polyline": "bin/polyline.bin.js"
} }
}, },
"node_modules/@mapbox/search-js-core": {
"version": "1.0.0-beta.16",
"resolved": "https://registry.npmjs.org/@mapbox/search-js-core/-/search-js-core-1.0.0-beta.16.tgz",
"integrity": "sha512-8Y+9HivlN/yQyEm2AqA3C9WRNiE9j8JVFoG1ZZnt5Rczm5Y+7BubdVH66buUvZxvrhzdyiyQgejGoNLxtrkhfw==",
"dependencies": {
"@types/geojson": "^7946.0.8"
},
"engines": {
"node": ">=12.20.1"
}
},
"node_modules/@mapbox/search-js-react": {
"version": "1.0.0-beta.16",
"resolved": "https://registry.npmjs.org/@mapbox/search-js-react/-/search-js-react-1.0.0-beta.16.tgz",
"integrity": "sha512-PsGU0D0VuoK9YUd70YGoWhaaEdZ851sBn/TLm3Rfu9HTF1ZHE7GemVS9YjmlNSf491VrIuYX3cDVElkq+J04gg==",
"dependencies": {
"@mapbox/search-js-core": "^1.0.0-beta.16",
"@mapbox/search-js-web": "^1.0.0-beta.16",
"@types/geojson": "^7946.0.8",
"@types/react": "^17.0.43"
},
"engines": {
"node": ">=12.20.1"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@mapbox/search-js-react/node_modules/@types/react": {
"version": "17.0.60",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.60.tgz",
"integrity": "sha512-pCH7bqWIfzHs3D+PDs3O/COCQJka+Kcw3RnO9rFA2zalqoXg7cNjJDh6mZ7oRtY1wmY4LVwDdAbA1F7Z8tv3BQ==",
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@mapbox/search-js-web": {
"version": "1.0.0-beta.16",
"resolved": "https://registry.npmjs.org/@mapbox/search-js-web/-/search-js-web-1.0.0-beta.16.tgz",
"integrity": "sha512-jCofyJZPnLUZjdEFX6cAS2V4q5RJHPe+M7JSdmnA3bfMEYZwrQdT920vy2Psm9Ep/h7sHwMzGGEtu58DpdmA7w==",
"dependencies": {
"@floating-ui/dom": "^0.5.2",
"@mapbox/search-js-core": "^1.0.0-beta.16",
"@mapbox/sphericalmercator": "^1.2.0",
"focus-trap": "^6.7.3",
"no-scroll": "^2.1.1"
},
"engines": {
"node": ">=12.20.1"
},
"peerDependencies": {
"mapbox-gl": ">=2.7.0"
}
},
"node_modules/@mapbox/sphericalmercator": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/sphericalmercator/-/sphericalmercator-1.2.0.tgz",
"integrity": "sha512-ZTOuuwGuMOJN+HEmG/68bSEw15HHaMWmQ5gdTsWdWsjDe56K1kGvLOK6bOSC8gWgIvEO0w6un/2Gvv1q5hJSkQ==",
"bin": {
"bbox": "bin/bbox.js",
"to4326": "bin/to4326.js",
"to900913": "bin/to900913.js",
"xyz": "bin/xyz.js"
}
},
"node_modules/@mapbox/tiny-sdf": { "node_modules/@mapbox/tiny-sdf": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz", "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz",
@@ -1973,8 +2056,7 @@
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.5", "version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
"integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w=="
"dev": true
}, },
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.9.7", "version": "6.9.7",
@@ -2051,8 +2133,7 @@
"node_modules/@types/scheduler": { "node_modules/@types/scheduler": {
"version": "0.16.3", "version": "0.16.3",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
"integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ=="
"dev": true
}, },
"node_modules/@types/semver": { "node_modules/@types/semver": {
"version": "7.3.13", "version": "7.3.13",
@@ -5343,6 +5424,14 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"dev": true "dev": true
}, },
"node_modules/focus-trap": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-6.9.4.tgz",
"integrity": "sha512-v2NTsZe2FF59Y+sDykKY+XjqZ0cPfhq/hikWVL88BqLivnNiEffAsac6rP6H45ff9wG9LL5ToiDqrLEP9GX9mw==",
"dependencies": {
"tabbable": "^5.3.3"
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.3", "version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -8021,6 +8110,11 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/no-scroll": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/no-scroll/-/no-scroll-2.1.1.tgz",
"integrity": "sha512-YTzGAJOo/B6hkodeT5SKKHpOhAzjMfkUCCXjLJwjWk2F4/InIg+HbdH9kmT7bKpleDuqLZDTRy2OdNtAj0IVyQ=="
},
"node_modules/node-addon-api": { "node_modules/node-addon-api": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
@@ -10568,6 +10662,11 @@
"url": "https://opencollective.com/unts" "url": "https://opencollective.com/unts"
} }
}, },
"node_modules/tabbable": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-5.3.3.tgz",
"integrity": "sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA=="
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.3.2", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz",
@@ -11101,16 +11200,16 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.1.3", "version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"devOptional": true, "devOptional": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
}, },
"engines": { "engines": {
"node": ">=14.17" "node": ">=4.2.0"
} }
}, },
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
@@ -11899,6 +11998,19 @@
"integrity": "sha512-Uo3pGspElQW91PCvKSIAXoEgAUlRnH29sX2/p89kg7sP1m2PzCufHINd0FhTXQf6DYGiUlVncdSPa2F9wxed2A==", "integrity": "sha512-Uo3pGspElQW91PCvKSIAXoEgAUlRnH29sX2/p89kg7sP1m2PzCufHINd0FhTXQf6DYGiUlVncdSPa2F9wxed2A==",
"dev": true "dev": true
}, },
"@floating-ui/core": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.7.3.tgz",
"integrity": "sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg=="
},
"@floating-ui/dom": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.5.4.tgz",
"integrity": "sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg==",
"requires": {
"@floating-ui/core": "^0.7.3"
}
},
"@hapi/b64": { "@hapi/b64": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-6.0.1.tgz", "resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-6.0.1.tgz",
@@ -12178,6 +12290,54 @@
"meow": "^6.1.1" "meow": "^6.1.1"
} }
}, },
"@mapbox/search-js-core": {
"version": "1.0.0-beta.16",
"resolved": "https://registry.npmjs.org/@mapbox/search-js-core/-/search-js-core-1.0.0-beta.16.tgz",
"integrity": "sha512-8Y+9HivlN/yQyEm2AqA3C9WRNiE9j8JVFoG1ZZnt5Rczm5Y+7BubdVH66buUvZxvrhzdyiyQgejGoNLxtrkhfw==",
"requires": {
"@types/geojson": "^7946.0.8"
}
},
"@mapbox/search-js-react": {
"version": "1.0.0-beta.16",
"resolved": "https://registry.npmjs.org/@mapbox/search-js-react/-/search-js-react-1.0.0-beta.16.tgz",
"integrity": "sha512-PsGU0D0VuoK9YUd70YGoWhaaEdZ851sBn/TLm3Rfu9HTF1ZHE7GemVS9YjmlNSf491VrIuYX3cDVElkq+J04gg==",
"requires": {
"@mapbox/search-js-core": "^1.0.0-beta.16",
"@mapbox/search-js-web": "^1.0.0-beta.16",
"@types/geojson": "^7946.0.8",
"@types/react": "^17.0.43"
},
"dependencies": {
"@types/react": {
"version": "17.0.60",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.60.tgz",
"integrity": "sha512-pCH7bqWIfzHs3D+PDs3O/COCQJka+Kcw3RnO9rFA2zalqoXg7cNjJDh6mZ7oRtY1wmY4LVwDdAbA1F7Z8tv3BQ==",
"requires": {
"@types/prop-types": "*",
"@types/scheduler": "*",
"csstype": "^3.0.2"
}
}
}
},
"@mapbox/search-js-web": {
"version": "1.0.0-beta.16",
"resolved": "https://registry.npmjs.org/@mapbox/search-js-web/-/search-js-web-1.0.0-beta.16.tgz",
"integrity": "sha512-jCofyJZPnLUZjdEFX6cAS2V4q5RJHPe+M7JSdmnA3bfMEYZwrQdT920vy2Psm9Ep/h7sHwMzGGEtu58DpdmA7w==",
"requires": {
"@floating-ui/dom": "^0.5.2",
"@mapbox/search-js-core": "^1.0.0-beta.16",
"@mapbox/sphericalmercator": "^1.2.0",
"focus-trap": "^6.7.3",
"no-scroll": "^2.1.1"
}
},
"@mapbox/sphericalmercator": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/sphericalmercator/-/sphericalmercator-1.2.0.tgz",
"integrity": "sha512-ZTOuuwGuMOJN+HEmG/68bSEw15HHaMWmQ5gdTsWdWsjDe56K1kGvLOK6bOSC8gWgIvEO0w6un/2Gvv1q5hJSkQ=="
},
"@mapbox/tiny-sdf": { "@mapbox/tiny-sdf": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz", "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz",
@@ -12943,8 +13103,7 @@
"@types/prop-types": { "@types/prop-types": {
"version": "15.7.5", "version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
"integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w=="
"dev": true
}, },
"@types/qs": { "@types/qs": {
"version": "6.9.7", "version": "6.9.7",
@@ -13020,8 +13179,7 @@
"@types/scheduler": { "@types/scheduler": {
"version": "0.16.3", "version": "0.16.3",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
"integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ=="
"dev": true
}, },
"@types/semver": { "@types/semver": {
"version": "7.3.13", "version": "7.3.13",
@@ -15433,6 +15591,14 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"dev": true "dev": true
}, },
"focus-trap": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-6.9.4.tgz",
"integrity": "sha512-v2NTsZe2FF59Y+sDykKY+XjqZ0cPfhq/hikWVL88BqLivnNiEffAsac6rP6H45ff9wG9LL5ToiDqrLEP9GX9mw==",
"requires": {
"tabbable": "^5.3.3"
}
},
"for-each": { "for-each": {
"version": "0.3.3", "version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -17292,6 +17458,11 @@
"regexparam": "^2.0.1" "regexparam": "^2.0.1"
} }
}, },
"no-scroll": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/no-scroll/-/no-scroll-2.1.1.tgz",
"integrity": "sha512-YTzGAJOo/B6hkodeT5SKKHpOhAzjMfkUCCXjLJwjWk2F4/InIg+HbdH9kmT7bKpleDuqLZDTRy2OdNtAj0IVyQ=="
},
"node-addon-api": { "node-addon-api": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
@@ -19064,6 +19235,11 @@
"tslib": "^2.5.0" "tslib": "^2.5.0"
} }
}, },
"tabbable": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-5.3.3.tgz",
"integrity": "sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA=="
},
"tailwindcss": { "tailwindcss": {
"version": "3.3.2", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz",
@@ -19445,9 +19621,9 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
}, },
"typescript": { "typescript": {
"version": "5.1.3", "version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"devOptional": true "devOptional": true
}, },
"unbox-primitive": { "unbox-primitive": {

View File

@@ -17,6 +17,8 @@
"@headlessui/tailwindcss": "^0.1.3", "@headlessui/tailwindcss": "^0.1.3",
"@hookform/resolvers": "^3.1.0", "@hookform/resolvers": "^3.1.0",
"@mapbox/mapbox-sdk": "^0.15.1", "@mapbox/mapbox-sdk": "^0.15.1",
"@mapbox/search-js-core": "^1.0.0-beta.16",
"@mapbox/search-js-react": "^1.0.0-beta.16",
"@next/bundle-analyzer": "^13.4.4", "@next/bundle-analyzer": "^13.4.4",
"@prisma/client": "^4.15.0", "@prisma/client": "^4.15.0",
"@react-email/components": "^0.0.6", "@react-email/components": "^0.0.6",
@@ -76,16 +78,16 @@
"eslint-config-next": "^13.4.4", "eslint-config-next": "^13.4.4",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-react": "^7.32.2", "eslint-plugin-react": "^7.32.2",
"onchange": "^7.1.0",
"postcss": "^8.4.24", "postcss": "^8.4.24",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"prettier-plugin-jsdoc": "^0.4.2", "prettier-plugin-jsdoc": "^0.4.2",
"prettier-plugin-tailwindcss": "^0.3.0", "prettier-plugin-tailwindcss": "^0.3.0",
"onchange": "^7.1.0",
"prisma": "^4.15.0", "prisma": "^4.15.0",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"tailwindcss-animate": "^1.0.5", "tailwindcss-animate": "^1.0.5",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.1.3" "typescript": "^4.9.0"
}, },
"prisma": { "prisma": {
"schema": "./src/prisma/schema.prisma", "schema": "./src/prisma/schema.prisma",

View File

@@ -1,7 +1,10 @@
import UserContext from '@/contexts/UserContext';
import { AccountPageState, AccountPageAction } from '@/reducers/accountPageReducer'; import { AccountPageState, AccountPageAction } from '@/reducers/accountPageReducer';
import { Switch } from '@headlessui/react'; import { Switch } from '@headlessui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { Dispatch, FunctionComponent, useRef } from 'react'; import { Dispatch, FunctionComponent, useContext, useRef } from 'react';
import { toast } from 'react-hot-toast';
interface DeleteAccountProps { interface DeleteAccountProps {
pageState: AccountPageState; pageState: AccountPageState;
@@ -13,6 +16,26 @@ const DeleteAccount: FunctionComponent<DeleteAccountProps> = ({
}) => { }) => {
const deleteRef = useRef<null | HTMLDialogElement>(null); const deleteRef = useRef<null | HTMLDialogElement>(null);
const router = useRouter(); const router = useRouter();
const { user, mutate } = useContext(UserContext);
const onDeleteSubmit = async () => {
deleteRef.current!.close();
const loadingToast = toast.loading(
'Deleting your account. We are sad to see you go. 😭',
);
const request = await fetch(`/api/users/${user?.id}`, {
method: 'DELETE',
});
if (!request.ok) {
throw new Error('Could not delete that user.');
}
toast.remove(loadingToast);
toast.success('Deleted your account. Goodbye. 😓');
await mutate!();
router.push('/');
};
return ( return (
<div className="card w-full space-y-4"> <div className="card w-full space-y-4">
@@ -49,10 +72,7 @@ const DeleteAccount: FunctionComponent<DeleteAccountProps> = ({
<div className="modal-action flex-col space-x-0 space-y-3"> <div className="modal-action flex-col space-x-0 space-y-3">
<button <button
className="btn-error btn-sm btn w-full" className="btn-error btn-sm btn w-full"
onClick={async () => { onClick={onDeleteSubmit}
deleteRef.current!.close();
await router.replace('/api/users/logout');
}}
> >
Okay, delete my account Okay, delete my account
</button> </button>

View File

@@ -72,14 +72,14 @@ const Security: FunctionComponent<SecurityProps> = ({ dispatch, pageState }) =>
formValidationSchema={register('password')} formValidationSchema={register('password')}
/> />
<FormInfo> <FormInfo>
<FormLabel htmlFor="password">Confirm Password</FormLabel> <FormLabel htmlFor="confirm-password">Confirm Password</FormLabel>
<FormError>{formState.errors.confirmPassword?.message}</FormError> <FormError>{formState.errors.confirmPassword?.message}</FormError>
</FormInfo> </FormInfo>
<FormTextInput <FormTextInput
type="password" type="password"
disabled={!pageState.securityOpen || formState.isSubmitting} disabled={!pageState.securityOpen || formState.isSubmitting}
error={!!formState.errors.confirmPassword} error={!!formState.errors.confirmPassword}
id="password" id="confirm-password"
formValidationSchema={register('confirmPassword')} formValidationSchema={register('confirmPassword')}
/> />

View File

@@ -34,15 +34,15 @@ const CreateBeerPostForm: FunctionComponent<BeerFormProps> = ({
types = [], types = [],
brewery, brewery,
}) => { }) => {
const { register, handleSubmit, formState } = useForm< const {
z.infer<typeof CreateBeerPostWithImagesValidationSchema> register,
>({ handleSubmit,
formState: { errors, isSubmitting },
} = useForm<z.infer<typeof CreateBeerPostWithImagesValidationSchema>>({
resolver: zodResolver(CreateBeerPostWithImagesValidationSchema), resolver: zodResolver(CreateBeerPostWithImagesValidationSchema),
defaultValues: { breweryId: brewery.id }, defaultValues: { breweryId: brewery.id },
}); });
const { errors, isSubmitting } = formState;
const onSubmit: SubmitHandler< const onSubmit: SubmitHandler<
z.infer<typeof CreateBeerPostWithImagesValidationSchema> z.infer<typeof CreateBeerPostWithImagesValidationSchema>
> = async (data) => { > = async (data) => {
@@ -51,9 +51,9 @@ const CreateBeerPostForm: FunctionComponent<BeerFormProps> = ({
} }
try { try {
const response = await sendCreateBeerPostRequest(data); const beerPost = await sendCreateBeerPostRequest(data);
await sendUploadBeerImagesRequest({ beerPost: response, images: data.images }); await sendUploadBeerImagesRequest({ beerPost, images: data.images });
await router.push(`/beers/${response.id}`); await router.push(`/beers/${beerPost.id}`);
toast.success('Created beer post.'); toast.success('Created beer post.');
} catch (e) { } catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Something went wrong.'; const errorMessage = e instanceof Error ? e.message : 'Something went wrong.';

View File

@@ -22,14 +22,19 @@ const toastToClassName = (toastType: Toast['type']) => {
const CustomToast: FC<{ children: ReactNode }> = ({ children }) => { const CustomToast: FC<{ children: ReactNode }> = ({ children }) => {
return ( return (
<> <>
<Toaster position="bottom-right"> <Toaster
position="bottom-right"
toastOptions={{
duration: 2500,
}}
>
{(t) => { {(t) => {
const alertType = toastToClassName(t.type); const alertType = toastToClassName(t.type);
return ( return (
<div <div
className={`alert ${alertType} flex w-11/12 items-center justify-between shadow-lg animate-in fade-in duration-200 lg:w-4/12`} className={`alert ${alertType} flex w-full items-start justify-between shadow-lg animate-in fade-in duration-200 lg:w-3/12`}
> >
<p className="w-full">{resolveValue(t.message, t)}</p> <p className="w-full text-left">{resolveValue(t.message, t)}</p>
{t.type !== 'loading' && ( {t.type !== 'loading' && (
<div> <div>
<button <button

View File

@@ -20,7 +20,7 @@ const FormPageLayout: FC<FormPageLayoutProps> = ({
}) => { }) => {
return ( return (
<div className="my-20 flex flex-col items-center justify-center"> <div className="my-20 flex flex-col items-center justify-center">
<div className="w-10/12 lg:w-8/12 2xl:w-6/12"> <div className="w-11/12 lg:w-9/12 2xl:w-7/12">
<div className="tooltip tooltip-right" data-tip={backLinkText}> <div className="tooltip tooltip-right" data-tip={backLinkText}>
<Link href={backLink} className="btn-ghost btn-sm btn p-0"> <Link href={backLink} className="btn-ghost btn-sm btn p-0">
<BiArrowBack className="text-xl" /> <BiArrowBack className="text-xl" />

View File

@@ -11,6 +11,7 @@ interface FormInputProps {
id: string; id: string;
height?: string; height?: string;
disabled?: boolean; disabled?: boolean;
autoComplete?: string;
} }
/** /**
@@ -33,6 +34,7 @@ interface FormInputProps {
* @param param0.id The id of the input. * @param param0.id The id of the input.
* @param param0.height The height of the input. * @param param0.height The height of the input.
* @param param0.disabled Whether or not the input is disabled. * @param param0.disabled Whether or not the input is disabled.
* @param param0.autoComplete The autocomplete value for the input.
*/ */
const FormTextInput: FunctionComponent<FormInputProps> = ({ const FormTextInput: FunctionComponent<FormInputProps> = ({
placeholder = '', placeholder = '',

View File

@@ -1,13 +0,0 @@
import useUser from '@/hooks/auth/useUser';
import GetUserSchema from '@/services/User/schema/GetUserSchema';
import { createContext } from 'react';
import { z } from 'zod';
const UserContext = createContext<{
user?: z.infer<typeof GetUserSchema>;
error?: unknown;
isLoading: boolean;
mutate?: ReturnType<typeof useUser>['mutate'];
}>({ isLoading: true });
export default UserContext;

View File

@@ -0,0 +1,24 @@
import useUser from '@/hooks/auth/useUser';
import GetUserSchema from '@/services/User/schema/GetUserSchema';
import { ReactNode, createContext } from 'react';
import { z } from 'zod';
const UserContext = createContext<{
user?: z.infer<typeof GetUserSchema>;
error?: unknown;
isLoading: boolean;
mutate?: ReturnType<typeof useUser>['mutate'];
}>({ isLoading: true });
export default UserContext;
type AuthProviderComponent = (props: { children: ReactNode }) => JSX.Element;
export const AuthProvider: AuthProviderComponent = ({ children }) => {
const { error, isLoading, mutate, user } = useUser();
return (
<UserContext.Provider value={{ isLoading, error, mutate, user }}>
{children}
</UserContext.Provider>
);
};

View File

@@ -46,7 +46,12 @@ const useUser = () => {
return parsedPayload.data; return parsedPayload.data;
}); });
return { user, isLoading, error: error as unknown, mutate }; return {
mutate,
isLoading,
user: error ? undefined : user,
error: error as unknown,
};
}; };
export default useUser; export default useUser;

View File

@@ -1,4 +1,4 @@
import UserContext from '@/contexts/UserContext'; import UserContext, { AuthProvider } from '@/contexts/UserContext';
import '@/styles/globals.css'; import '@/styles/globals.css';
import type { AppProps } from 'next/app'; import type { AppProps } from 'next/app';
@@ -13,15 +13,12 @@ import Layout from '@/components/ui/Layout';
import useUser from '@/hooks/auth/useUser'; import useUser from '@/hooks/auth/useUser';
import CustomToast from '@/components/ui/CustomToast'; import CustomToast from '@/components/ui/CustomToast';
const spaceGrotesk = Space_Grotesk({ const spaceGrotesk = Space_Grotesk({ subsets: ['latin'] });
subsets: ['latin'],
});
export default function App({ Component, pageProps }: AppProps) { const App = ({ Component, pageProps }: AppProps) => {
useEffect(() => { useEffect(() => {
themeChange(false); themeChange(false);
}, []); }, []);
const { user, isLoading, error, mutate } = useUser();
return ( return (
<> <>
@@ -38,15 +35,17 @@ export default function App({ Component, pageProps }: AppProps) {
content="width=device-width, initial-scale=1.0, maximum-scale=1.0" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"
/> />
</Head> </Head>
<UserContext.Provider value={{ user, isLoading, error, mutate }}> <AuthProvider>
<Layout> <Layout>
<CustomToast> <CustomToast>
<Component {...pageProps} /> <Component {...pageProps} />
</CustomToast> </CustomToast>
</Layout> </Layout>
</UserContext.Provider> </AuthProvider>
<Analytics /> <Analytics />
</> </>
); );
} };
export default App;

View File

@@ -12,7 +12,7 @@ import { NextApiResponse } from 'next';
import { z } from 'zod'; import { z } from 'zod';
import ServerError from '@/config/util/ServerError'; import ServerError from '@/config/util/ServerError';
import validateRequest from '@/config/nextConnect/middleware/validateRequest'; import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import processImageDataIntoDB from '@/services/BeerImage/processImageDataIntoDB'; import addBeerImageToDB from '@/services/BeerImage/addBeerImageToDB';
import ImageMetadataValidationSchema from '@/services/types/ImageSchema/ImageMetadataValidationSchema'; import ImageMetadataValidationSchema from '@/services/types/ImageSchema/ImageMetadataValidationSchema';
const { storage } = cloudinaryConfig; const { storage } = cloudinaryConfig;
@@ -50,7 +50,7 @@ const processImageData = async (
throw new ServerError('No images uploaded', 400); throw new ServerError('No images uploaded', 400);
} }
const beerImages = await processImageDataIntoDB({ const beerImages = await addBeerImageToDB({
alt: body.alt, alt: body.alt,
caption: body.caption, caption: body.caption,
beerPostId: req.query.id, beerPostId: req.query.id,

View File

@@ -0,0 +1,86 @@
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import { createRouter, expressWrapper } from 'next-connect';
import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import multer from 'multer';
import cloudinaryConfig from '@/config/cloudinary';
import { NextApiResponse } from 'next';
import { z } from 'zod';
import ServerError from '@/config/util/ServerError';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import ImageMetadataValidationSchema from '@/services/types/ImageSchema/ImageMetadataValidationSchema';
import addBreweryImageToDB from '@/services/BreweryImage/addBreweryImageToDB';
const { storage } = cloudinaryConfig;
const fileFilter: multer.Options['fileFilter'] = (req, file, cb) => {
const { mimetype } = file;
const isImage = mimetype.startsWith('image/');
if (!isImage) {
cb(null, false);
}
cb(null, true);
};
const uploadMiddleware = expressWrapper(
multer({ storage, fileFilter, limits: { files: 5, fileSize: 15 * 1024 * 1024 } }).array(
'images',
),
);
interface UploadBreweryPostImagesRequest extends UserExtendedNextApiRequest {
files?: Express.Multer.File[];
query: { id: string };
body: z.infer<typeof ImageMetadataValidationSchema>;
}
const processImageData = async (
req: UploadBreweryPostImagesRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { files, user, body } = req;
if (!files || !files.length) {
throw new ServerError('No images uploaded', 400);
}
const breweryImages = await addBreweryImageToDB({
alt: body.alt,
caption: body.caption,
breweryPostId: req.query.id,
userId: user!.id,
files,
});
res.status(200).json({
success: true,
message: `Successfully uploaded ${breweryImages.length} image${
breweryImages.length > 1 ? 's' : ''
}`,
statusCode: 200,
});
};
const router = createRouter<
UploadBreweryPostImagesRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.post(
getCurrentUser,
// @ts-expect-error
uploadMiddleware,
validateRequest({ bodySchema: ImageMetadataValidationSchema }),
processImageData,
);
const handler = router.handler(NextConnectOptions);
export default handler;
export const config = { api: { bodyParser: false } };

View File

@@ -0,0 +1,76 @@
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import { createRouter } from 'next-connect';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { z } from 'zod';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import CreateBreweryPostSchema from '@/services/BreweryPost/types/CreateBreweryPostSchema';
import createNewBreweryPost from '@/services/BreweryPost/createNewBreweryPost';
import geocode from '@/config/mapbox/geocoder';
import ServerError from '@/config/util/ServerError';
import DBClient from '@/prisma/DBClient';
interface CreateBreweryPostRequest extends UserExtendedNextApiRequest {
body: z.infer<typeof CreateBreweryPostSchema>;
}
const createBreweryPost = async (
req: CreateBreweryPostRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { name, description, dateEstablished, address, city, country, region } = req.body;
const userId = req.user!.id;
const fullAddress = `${address}, ${city}, ${region}, ${country}`;
const geocoded = await geocode(fullAddress);
if (!geocoded) {
throw new ServerError('Address is not valid', 400);
}
const [latitude, longitude] = geocoded.center;
const location = await DBClient.instance.location.create({
data: {
address,
city,
country,
stateOrProvince: region,
coordinates: [latitude, longitude],
postedBy: { connect: { id: userId } },
},
select: { id: true },
});
const newBreweryPost = await createNewBreweryPost({
name,
description,
locationId: location.id,
dateEstablished,
userId,
});
res.status(201).json({
message: 'Brewery post created successfully',
statusCode: 201,
payload: newBreweryPost,
success: true,
});
};
const router = createRouter<
CreateBreweryPostRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.post(
validateRequest({ bodySchema: CreateBreweryPostSchema }),
getCurrentUser,
createBreweryPost,
);
const handler = router.handler(NextConnectOptions);
export default handler;

View File

@@ -4,6 +4,7 @@ import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import validateRequest from '@/config/nextConnect/middleware/validateRequest'; import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import ServerError from '@/config/util/ServerError'; import ServerError from '@/config/util/ServerError';
import DBClient from '@/prisma/DBClient'; import DBClient from '@/prisma/DBClient';
import deleteUserById from '@/services/User/deleteUserById';
import findUserByEmail from '@/services/User/findUserByEmail'; import findUserByEmail from '@/services/User/findUserByEmail';
import findUserById from '@/services/User/findUserById'; import findUserById from '@/services/User/findUserById';
import findUserByUsername from '@/services/User/findUserByUsername'; import findUserByUsername from '@/services/User/findUserByUsername';
@@ -21,11 +22,12 @@ const EditUserSchema = BaseCreateUserSchema.pick({
lastName: true, lastName: true,
}); });
interface EditUserRequest extends UserExtendedNextApiRequest { interface UserRouteRequest extends UserExtendedNextApiRequest {
query: { id: string };
}
interface EditUserRequest extends UserRouteRequest {
body: z.infer<typeof EditUserSchema>; body: z.infer<typeof EditUserSchema>;
query: {
id: string;
};
} }
const checkIfUserCanEditUser = async ( const checkIfUserCanEditUser = async (
@@ -41,7 +43,7 @@ const checkIfUserCanEditUser = async (
} }
if (authenticatedUser.id !== userToUpdate.id) { if (authenticatedUser.id !== userToUpdate.id) {
throw new ServerError('You are not permitted to edit this user', 403); throw new ServerError('You are not permitted to modify this user', 403);
} }
await next(); await next();
@@ -88,6 +90,24 @@ const editUser = async (
}); });
}; };
const deleteUser = async (
req: UserRouteRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { id } = req.query;
const deletedUser = await deleteUserById(id);
if (!deletedUser) {
throw new ServerError('Could not find a user with that id.', 400);
}
res.send({
message: 'Successfully deleted user.',
statusCode: 200,
success: true,
});
};
const router = createRouter< const router = createRouter<
EditUserRequest, EditUserRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>> NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
@@ -103,6 +123,15 @@ router.put(
editUser, editUser,
); );
router.delete(
getCurrentUser,
validateRequest({
querySchema: z.object({ id: z.string().cuid() }),
}),
checkIfUserCanEditUser,
deleteUser,
);
const handler = router.handler(NextConnectOptions); const handler = router.handler(NextConnectOptions);
export default handler; export default handler;

View File

@@ -5,7 +5,6 @@ import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import validateRequest from '@/config/nextConnect/middleware/validateRequest'; import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import DBClient from '@/prisma/DBClient'; import DBClient from '@/prisma/DBClient';
import { UpdatePasswordSchema } from '@/services/User/schema/CreateUserValidationSchemas'; import { UpdatePasswordSchema } from '@/services/User/schema/CreateUserValidationSchemas';
import GetUserSchema from '@/services/User/schema/GetUserSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
import { createRouter } from 'next-connect'; import { createRouter } from 'next-connect';
@@ -23,26 +22,15 @@ const updatePassword = async (
const hash = await hashPassword(password); const hash = await hashPassword(password);
const user = req.user!; const user = req.user!;
const updatedUser: z.infer<typeof GetUserSchema> = await DBClient.instance.user.update({ await DBClient.instance.user.update({
data: { hash }, data: { hash },
where: { id: user.id }, where: { id: user.id },
select: {
id: true,
username: true,
createdAt: true,
updatedAt: true,
email: true,
firstName: true,
lastName: true,
dateOfBirth: true,
accountIsVerified: true,
},
}); });
res.json({ res.json({
message: 'Updated user password.', message: 'Updated user password.',
statusCode: 200, statusCode: 200,
success: true, success: true,
payload: updatedUser,
}); });
}; };
const router = createRouter< const router = createRouter<

View File

@@ -11,6 +11,7 @@ import findUserByEmail from '@/services/User/findUserByEmail';
import validateRequest from '@/config/nextConnect/middleware/validateRequest'; import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NODE_ENV } from '@/config/env';
import sendConfirmationEmail from '@/services/User/sendConfirmationEmail'; import sendConfirmationEmail from '@/services/User/sendConfirmationEmail';
interface RegisterUserRequest extends NextApiRequest { interface RegisterUserRequest extends NextApiRequest {
@@ -44,7 +45,9 @@ const registerUser = async (req: RegisterUserRequest, res: NextApiResponse) => {
username: user.username, username: user.username,
}); });
if (NODE_ENV === 'production') {
await sendConfirmationEmail(user); await sendConfirmationEmail(user);
}
res.status(201).json({ res.status(201).json({
success: true, success: true,

View File

@@ -33,7 +33,6 @@ export const getServerSideProps = withPageAuthRequired<CreateBeerPageProps>(
const id = context.params?.id as string; const id = context.params?.id as string;
const breweryPost = await getBreweryPostById(id); const breweryPost = await getBreweryPostById(id);
const beerTypes = await DBClient.instance.beerType.findMany(); const beerTypes = await DBClient.instance.beerType.findMany();
return { return {

View File

@@ -0,0 +1,279 @@
import Button from '@/components/ui/forms/Button';
import FormError from '@/components/ui/forms/FormError';
import FormInfo from '@/components/ui/forms/FormInfo';
import FormLabel from '@/components/ui/forms/FormLabel';
import FormPageLayout from '@/components/ui/forms/FormPageLayout';
import FormSegment from '@/components/ui/forms/FormSegment';
import FormTextArea from '@/components/ui/forms/FormTextArea';
import FormTextInput from '@/components/ui/forms/FormTextInput';
import createErrorToast from '@/util/createErrorToast';
import withPageAuthRequired from '@/util/withPageAuthRequired';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { zodResolver } from '@hookform/resolvers/zod';
import type { AddressAutofillRetrieveResponse } from '@mapbox/search-js-core';
import { GetServerSideProps, NextPage } from 'next';
import Head from 'next/head';
import { FieldError, SubmitHandler, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { FaBeer } from 'react-icons/fa';
import { z } from 'zod';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import CreateBreweryPostSchema from '@/services/BreweryPost/types/CreateBreweryPostSchema';
import UploadImageValidationSchema from '@/services/types/ImageSchema/UploadImageValidationSchema';
import sendUploadBreweryImagesRequest from '@/requests/BreweryImage/sendUploadBreweryImageRequest';
const AddressAutofill = dynamic(
() => import('@mapbox/search-js-react').then((mod) => mod.AddressAutofill),
{ ssr: false },
);
const CreateBreweryPostWithImagesSchema = CreateBreweryPostSchema.merge(
UploadImageValidationSchema,
);
const sendCreateBreweryPostRequest = async (
data: z.infer<typeof CreateBreweryPostSchema>,
) => {
const response = await fetch('/api/breweries/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(response.statusText);
}
const json = await response.json();
const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) {
throw new Error('API response parsing failed');
}
const parsedPayload = BreweryPostQueryResult.safeParse(parsed.data.payload);
if (!parsedPayload.success) {
throw new Error('API response payload parsing failed');
}
return parsedPayload.data;
};
const CreateBreweryPage: NextPage = () => {
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors, isSubmitting },
} = useForm<z.infer<typeof CreateBreweryPostWithImagesSchema>>({
resolver: zodResolver(CreateBreweryPostWithImagesSchema),
});
const router = useRouter();
const onSubmit: SubmitHandler<
z.infer<typeof CreateBreweryPostWithImagesSchema>
> = async (data) => {
const loadingToast = toast.loading('Creating brewery...');
try {
if (!(data.images instanceof FileList)) {
return;
}
const breweryPost = await sendCreateBreweryPostRequest(data);
await sendUploadBreweryImagesRequest({ breweryPost, images: data.images });
await router.push(`/breweries/${breweryPost.id}`);
toast.remove(loadingToast);
toast.success('Created brewery.');
} catch (error) {
toast.remove(loadingToast);
reset();
createErrorToast(error);
}
};
const onAutoCompleteChange = (address: string) => {
setValue('address', address);
};
const onAutoCompleteRetrieve = (address: AddressAutofillRetrieveResponse) => {
const { country, region, place } = address.features[0].properties as unknown as {
country?: string;
region?: string;
place?: string;
};
setValue('country', country);
setValue('region', region);
setValue('city', place!);
};
return (
<>
<Head>
<title>Create Brewery</title>
</Head>
<div className="flex w-full flex-col items-center justify-center">
<div className="w-full">
<FormPageLayout
backLink="/breweries"
backLinkText="Back to Breweries"
headingText="Create Brewery"
headingIcon={FaBeer}
>
<form
onSubmit={handleSubmit(onSubmit)}
className="form-control"
autoComplete="off"
>
<div>
<FormInfo>
<FormLabel htmlFor="name">Name</FormLabel>
<FormError>{errors.name?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="Lorem Ipsum Brewing Company"
formValidationSchema={register('name')}
error={!!errors.name}
type="text"
id="name"
disabled={isSubmitting}
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="description">Description</FormLabel>
<FormError>{errors.description?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextArea
placeholder="We make beer, and we make it good."
formValidationSchema={register('description')}
error={!!errors.description}
rows={4}
id="description"
disabled={isSubmitting}
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="dateEstablished">Date Established</FormLabel>
<FormError>{errors.dateEstablished?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="2021-01-01"
formValidationSchema={register('dateEstablished')}
error={!!errors.dateEstablished}
type="date"
id="dateEstablished"
disabled={isSubmitting}
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="images">Images</FormLabel>
<FormError>
{(errors.images as FieldError | undefined)?.message}
</FormError>
</FormInfo>
<FormSegment>
<input
type="file"
{...register('images')}
multiple
className="file-input-bordered file-input w-full"
disabled={isSubmitting}
/>
</FormSegment>
</div>
<div>
<FormInfo>
<FormLabel htmlFor="address">Address</FormLabel>
<FormError>{errors.address?.message}</FormError>
</FormInfo>
<FormSegment>
<AddressAutofill
accessToken={process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN!}
onRetrieve={onAutoCompleteRetrieve}
onChange={onAutoCompleteChange}
>
<input
id="address"
type="text"
placeholder="1234 Main St"
className={`input-bordered input w-full appearance-none rounded-lg transition ease-in-out ${
errors.address?.message ? 'input-error' : ''
}`}
{...register('address')}
disabled={isSubmitting}
/>
</AddressAutofill>
</FormSegment>
<div className="flex space-x-3">
<div className="w-1/2">
<FormInfo>
<FormLabel htmlFor="city">City</FormLabel>
<FormError>{errors.city?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="Toronto"
formValidationSchema={register('city')}
error={!!errors.city}
type="text"
id="city"
disabled={isSubmitting}
/>
</FormSegment>
</div>
<div className="w-1/2">
<FormInfo>
<FormLabel htmlFor="region">Region</FormLabel>
<FormError>{errors.region?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="Ontario"
formValidationSchema={register('region')}
error={!!errors.region}
type="text"
id="region"
disabled={isSubmitting}
/>
</FormSegment>
</div>
</div>
<FormInfo>
<FormLabel htmlFor="country">Country</FormLabel>
<FormError>{errors.country?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="Canada"
formValidationSchema={register('country')}
error={!!errors.country}
type="text"
id="country"
disabled={isSubmitting}
/>
</FormSegment>
</div>
<div className="mt-4">
<Button type="submit" isSubmitting={isSubmitting}>
Create Brewery Post
</Button>
</div>
</form>
</FormPageLayout>
</div>
</div>
</>
);
};
export default CreateBreweryPage;
export const getServerSideProps: GetServerSideProps = withPageAuthRequired();

View File

@@ -0,0 +1,34 @@
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import { z } from 'zod';
interface SendUploadBeerImagesRequestArgs {
breweryPost: z.infer<typeof BreweryPostQueryResult>;
images: FileList;
}
const sendUploadBreweryImagesRequest = async ({
breweryPost,
images,
}: SendUploadBeerImagesRequestArgs) => {
const formData = new FormData();
[...images].forEach((file) => {
formData.append('images', file);
});
formData.append('caption', `Image of ${breweryPost.name}`);
formData.append('alt', breweryPost.name);
const uploadResponse = await fetch(`/api/breweries/${breweryPost.id}/images`, {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
throw new Error('Failed to upload images');
}
return uploadResponse.json();
};
export default sendUploadBreweryImagesRequest;

View File

@@ -13,7 +13,7 @@ interface SendEditUserRequestArgs {
} }
const sendEditUserRequest = async ({ user, data }: SendEditUserRequestArgs) => { const sendEditUserRequest = async ({ user, data }: SendEditUserRequestArgs) => {
const response = await fetch(`/api/users/${user!.id}/edit`, { const response = await fetch(`/api/users/${user!.id}`, {
body: JSON.stringify(data), body: JSON.stringify(data),
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -1,5 +1,4 @@
import { UpdatePasswordSchema } from '@/services/User/schema/CreateUserValidationSchemas'; import { UpdatePasswordSchema } from '@/services/User/schema/CreateUserValidationSchemas';
import GetUserSchema from '@/services/User/schema/GetUserSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { z } from 'zod'; import { z } from 'zod';
@@ -21,13 +20,7 @@ const sendUpdatePasswordRequest = async (data: z.infer<typeof UpdatePasswordSche
throw new Error('API response validation failed.'); throw new Error('API response validation failed.');
} }
const parsedPayload = GetUserSchema.safeParse(parsed.data.payload); return parsed.data;
if (!parsedPayload.success) {
throw new Error('API payload validation failed.');
}
return parsedPayload.data;
}; };
export default sendUpdatePasswordRequest; export default sendUpdatePasswordRequest;

View File

@@ -11,7 +11,7 @@ interface ProcessImageDataArgs {
userId: string; userId: string;
} }
const processImageDataIntoDB = ({ const addBeerImageToDB = ({
alt, alt,
caption, caption,
files, files,
@@ -36,4 +36,4 @@ const processImageDataIntoDB = ({
return Promise.all(beerImagePromises); return Promise.all(beerImagePromises);
}; };
export default processImageDataIntoDB; export default addBeerImageToDB;

View File

@@ -0,0 +1,39 @@
import DBClient from '@/prisma/DBClient';
import { BreweryImage } from '@prisma/client';
import { z } from 'zod';
import ImageMetadataValidationSchema from '../types/ImageSchema/ImageMetadataValidationSchema';
interface ProcessImageDataArgs {
files: Express.Multer.File[];
alt: z.infer<typeof ImageMetadataValidationSchema>['alt'];
caption: z.infer<typeof ImageMetadataValidationSchema>['caption'];
breweryPostId: string;
userId: string;
}
const addBreweryImageToDB = ({
alt,
caption,
files,
breweryPostId,
userId,
}: ProcessImageDataArgs) => {
const breweryImagePromises: Promise<BreweryImage>[] = [];
files.forEach((file) => {
breweryImagePromises.push(
DBClient.instance.breweryImage.create({
data: {
alt,
caption,
postedBy: { connect: { id: userId } },
breweryPost: { connect: { id: breweryPostId } },
path: file.path,
},
}),
);
});
return Promise.all(breweryImagePromises);
};
export default addBreweryImageToDB;

View File

@@ -0,0 +1,55 @@
import DBClient from '@/prisma/DBClient';
import { z } from 'zod';
import CreateBreweryPostSchema from './types/CreateBreweryPostSchema';
import BreweryPostQueryResult from './types/BreweryPostQueryResult';
const CreateNewBreweryPostWithUserAndLocationSchema = CreateBreweryPostSchema.omit({
address: true,
city: true,
country: true,
stateOrProvince: true,
}).extend({
userId: z.string().cuid(),
locationId: z.string().cuid(),
});
const createNewBreweryPost = async ({
dateEstablished,
description,
locationId,
name,
userId,
}: z.infer<typeof CreateNewBreweryPostWithUserAndLocationSchema>) => {
const breweryPost: z.infer<typeof BreweryPostQueryResult> =
await DBClient.instance.breweryPost.create({
data: {
name,
description,
dateEstablished,
location: { connect: { id: locationId } },
postedBy: { connect: { id: userId } },
},
select: {
id: true,
name: true,
description: true,
createdAt: true,
dateEstablished: true,
postedBy: { select: { id: true, username: true } },
breweryImages: { select: { path: true, caption: true, id: true, alt: true } },
location: {
select: {
city: true,
address: true,
coordinates: true,
country: true,
stateOrProvince: true,
},
},
},
});
return breweryPost;
};
export default createNewBreweryPost;

View File

@@ -0,0 +1,55 @@
import { isPast } from 'date-fns';
import { z } from 'zod';
const CreateBreweryPostSchema = z.object({
name: z
.string({
required_error: 'Brewery name is required.',
invalid_type_error: 'Brewery name must be a string.',
})
.min(1, { message: 'Brewery name is required.' })
.max(100, { message: 'Brewery name is too long.' }),
description: z
.string({
required_error: 'Description is required.',
invalid_type_error: 'Description must be a string.',
})
.min(1, { message: 'Description is required.' })
.max(500, { message: 'Description is too long.' }),
address: z
.string({
required_error: 'Address is required.',
invalid_type_error: 'Address must be a string.',
})
.min(1, { message: 'Address is required.' })
.max(100, { message: 'Address is too long.' }),
city: z
.string({
required_error: 'City is required.',
invalid_type_error: 'City must be a string.',
})
.min(1, { message: 'City is required.' })
.max(100, { message: 'City is too long.' }),
region: z
.string({ invalid_type_error: 'region must be a string.' })
.min(1, { message: 'region is required.' })
.max(100, { message: 'region is too long.' })
.optional(),
country: z
.string({ invalid_type_error: 'Country must be a string.' })
.max(100, { message: 'Country is too long.' })
.optional(),
dateEstablished: z.coerce
.date({
required_error: 'Date established is required.',
invalid_type_error: 'Date established must be a date string.',
})
.refine((val) => !Number.isNaN(val.toString()), { message: 'Date is invalid.' })
.refine((val) => isPast(new Date(val)), { message: 'Date must be in the past.' }),
});
export default CreateBreweryPostSchema;

View File

@@ -0,0 +1,25 @@
import DBClient from '@/prisma/DBClient';
import { z } from 'zod';
import GetUserSchema from './schema/GetUserSchema';
const deleteUserById = async (id: string) => {
const deletedUser: z.infer<typeof GetUserSchema> | null =
await DBClient.instance.user.delete({
where: { id },
select: {
id: true,
username: true,
email: true,
firstName: true,
lastName: true,
dateOfBirth: true,
createdAt: true,
accountIsVerified: true,
updatedAt: true,
},
});
return deletedUser;
};
export default deleteUserById;

View File

@@ -37,6 +37,9 @@ export const BaseCreateUserSchema = z.object({
}), }),
dateOfBirth: z dateOfBirth: z
.string() .string()
.refine((val) => !Number.isNaN(Date.parse(val)), {
message: 'Date is invalid.',
})
.refine((dateOfBirth) => new Date(dateOfBirth) <= MINIMUM_DATE_OF_BIRTH, { .refine((dateOfBirth) => new Date(dateOfBirth) <= MINIMUM_DATE_OF_BIRTH, {
message: 'You must be at least 19 years old to register.', message: 'You must be at least 19 years old to register.',
}), }),

View File

@@ -9,13 +9,13 @@ import { getLoginSession } from '../config/auth/session';
* @template P - A generic type that represents an object with string keys and any values. * @template P - A generic type that represents an object with string keys and any values.
* It defaults to an empty object. * It defaults to an empty object.
* @template Q - A generic type that represents a parsed URL query object. It defaults to * @template Q - A generic type that represents a parsed URL query object. It defaults to
* the ParsedUrlQuery type. * the `ParsedUrlQuery` type.
* @template D - A generic type that represents preview data. It defaults to the * @template D - A generic type that represents preview data. It defaults to the
* PreviewData type. * `PreviewData` type.
* @param context - The context object containing information about the incoming HTTP * @param context - The context object containing information about the incoming HTTP
* request. * request.
* @param session - An awaited value of the return type of the getLoginSession function. * @param session - An awaited value of the return type of the `getLoginSession` function.
* @returns - A promise that resolves to the result of the server-side rendering process. * @returns A promise that resolves to the result of the server-side rendering process.
*/ */
export type ExtendedGetServerSideProps< export type ExtendedGetServerSideProps<
P extends { [key: string]: any } = { [key: string]: any }, P extends { [key: string]: any } = { [key: string]: any },
@@ -30,14 +30,15 @@ export type ExtendedGetServerSideProps<
* A Higher Order Function that adds an authentication requirement to a Next.js * A Higher Order Function that adds an authentication requirement to a Next.js
* server-side page component. * server-side page component.
* *
* @param fn An async function that receives the GetServerSidePropsContext and * @param fn An async function that receives the `GetServerSidePropsContext` and
* authenticated session as arguments and returns a GetServerSidePropsResult with props * authenticated session as arguments and returns a `GetServerSidePropsResult` with
* for the wrapped component. * props for the wrapped component.
* @returns A promise that resolves to a GetServerSidePropsResult object with props for * @returns A promise that resolves to a `GetServerSidePropsResult` object with props for
* the wrapped component. If authentication is successful, the GetServerSidePropsResult * the wrapped component.
* will include props generated by the wrapped component's getServerSideProps method. If *
* authentication fails, the GetServerSidePropsResult will include a redirect to the * If authentication is successful, the `GetServerSidePropsResult` will include props
* login page. * generated by the wrapped component's `getServerSideProps` method. If authentication
* fails, the `GetServerSidePropsResult` will include a redirect to the login page.
*/ */
const withPageAuthRequired = const withPageAuthRequired =