Feat: add create brewery post, brewery image upload

Add address autocomplete, using MapBox
This commit is contained in:
Aaron William Po
2023-06-10 22:09:51 -04:00
parent 140abaa5a1
commit aa994f0067
19 changed files with 855 additions and 39 deletions

192
package-lock.json generated
View File

@@ -13,6 +13,8 @@
"@headlessui/tailwindcss": "^0.1.3",
"@hookform/resolvers": "^3.1.0",
"@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",
"@prisma/client": "^4.15.0",
"@react-email/components": "^0.0.6",
@@ -638,6 +640,19 @@
"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": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-6.0.1.tgz",
@@ -989,6 +1004,74 @@
"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": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz",
@@ -1973,8 +2056,7 @@
"node_modules/@types/prop-types": {
"version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
"integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==",
"dev": true
"integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w=="
},
"node_modules/@types/qs": {
"version": "6.9.7",
@@ -2051,8 +2133,7 @@
"node_modules/@types/scheduler": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
"integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==",
"dev": true
"integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ=="
},
"node_modules/@types/semver": {
"version": "7.3.13",
@@ -5343,6 +5424,14 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"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": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -8021,6 +8110,11 @@
"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": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
@@ -10568,6 +10662,11 @@
"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": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz",
@@ -11899,6 +11998,19 @@
"integrity": "sha512-Uo3pGspElQW91PCvKSIAXoEgAUlRnH29sX2/p89kg7sP1m2PzCufHINd0FhTXQf6DYGiUlVncdSPa2F9wxed2A==",
"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": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-6.0.1.tgz",
@@ -12178,6 +12290,54 @@
"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": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz",
@@ -12943,8 +13103,7 @@
"@types/prop-types": {
"version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
"integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==",
"dev": true
"integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w=="
},
"@types/qs": {
"version": "6.9.7",
@@ -13020,8 +13179,7 @@
"@types/scheduler": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
"integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==",
"dev": true
"integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ=="
},
"@types/semver": {
"version": "7.3.13",
@@ -15433,6 +15591,14 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"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": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -17292,6 +17458,11 @@
"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": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
@@ -19064,6 +19235,11 @@
"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": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz",

View File

@@ -17,6 +17,8 @@
"@headlessui/tailwindcss": "^0.1.3",
"@hookform/resolvers": "^3.1.0",
"@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",
"@prisma/client": "^4.15.0",
"@react-email/components": "^0.0.6",
@@ -76,11 +78,11 @@
"eslint-config-next": "^13.4.4",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-react": "^7.32.2",
"onchange": "^7.1.0",
"postcss": "^8.4.24",
"prettier": "^2.8.8",
"prettier-plugin-jsdoc": "^0.4.2",
"prettier-plugin-tailwindcss": "^0.3.0",
"onchange": "^7.1.0",
"prisma": "^4.15.0",
"tailwindcss": "^3.3.2",
"tailwindcss-animate": "^1.0.5",

View File

@@ -34,15 +34,15 @@ const CreateBeerPostForm: FunctionComponent<BeerFormProps> = ({
types = [],
brewery,
}) => {
const { register, handleSubmit, formState } = useForm<
z.infer<typeof CreateBeerPostWithImagesValidationSchema>
>({
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<z.infer<typeof CreateBeerPostWithImagesValidationSchema>>({
resolver: zodResolver(CreateBeerPostWithImagesValidationSchema),
defaultValues: { breweryId: brewery.id },
});
const { errors, isSubmitting } = formState;
const onSubmit: SubmitHandler<
z.infer<typeof CreateBeerPostWithImagesValidationSchema>
> = async (data) => {
@@ -51,9 +51,9 @@ const CreateBeerPostForm: FunctionComponent<BeerFormProps> = ({
}
try {
const response = await sendCreateBeerPostRequest(data);
await sendUploadBeerImagesRequest({ beerPost: response, images: data.images });
await router.push(`/beers/${response.id}`);
const beerPost = await sendCreateBeerPostRequest(data);
await sendUploadBeerImagesRequest({ beerPost, images: data.images });
await router.push(`/beers/${beerPost.id}`);
toast.success('Created beer post.');
} catch (e) {
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 }) => {
return (
<>
<Toaster position="bottom-right">
<Toaster
position="bottom-right"
toastOptions={{
duration: 2500,
}}
>
{(t) => {
const alertType = toastToClassName(t.type);
return (
<div
className={`alert ${alertType} flex w-full items-center justify-between shadow-lg animate-in fade-in duration-200 lg:w-3/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' && (
<div>
<button

View File

@@ -20,7 +20,7 @@ const FormPageLayout: FC<FormPageLayoutProps> = ({
}) => {
return (
<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}>
<Link href={backLink} className="btn-ghost btn-sm btn p-0">
<BiArrowBack className="text-xl" />

View File

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

View File

@@ -12,7 +12,7 @@ import { NextApiResponse } from 'next';
import { z } from 'zod';
import ServerError from '@/config/util/ServerError';
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';
const { storage } = cloudinaryConfig;
@@ -50,7 +50,7 @@ const processImageData = async (
throw new ServerError('No images uploaded', 400);
}
const beerImages = await processImageDataIntoDB({
const beerImages = await addBeerImageToDB({
alt: body.alt,
caption: body.caption,
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

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

View File

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

View File

@@ -0,0 +1,280 @@
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(
// @ts-expect-error
() => 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

@@ -11,7 +11,7 @@ interface ProcessImageDataArgs {
userId: string;
}
const processImageDataIntoDB = ({
const addBeerImageToDB = ({
alt,
caption,
files,
@@ -36,4 +36,4 @@ const processImageDataIntoDB = ({
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

@@ -37,6 +37,9 @@ export const BaseCreateUserSchema = z.object({
}),
dateOfBirth: z
.string()
.refine((val) => !Number.isNaN(Date.parse(val)), {
message: 'Date is invalid.',
})
.refine((dateOfBirth) => new Date(dateOfBirth) <= MINIMUM_DATE_OF_BIRTH, {
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.
* It defaults to an empty object.
* @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
* PreviewData type.
* `PreviewData` type.
* @param context - The context object containing information about the incoming HTTP
* request.
* @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.
* @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.
*/
export type ExtendedGetServerSideProps<
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
* server-side page component.
*
* @param fn An async function that receives the GetServerSidePropsContext and
* authenticated session as arguments and returns a GetServerSidePropsResult with props
* for the wrapped component.
* @returns A promise that resolves to a GetServerSidePropsResult object with props for
* the wrapped component. If authentication is successful, the GetServerSidePropsResult
* will include props generated by the wrapped component's getServerSideProps method. If
* authentication fails, the GetServerSidePropsResult will include a redirect to the
* login page.
* @param fn An async function that receives the `GetServerSidePropsContext` and
* authenticated session as arguments and returns a `GetServerSidePropsResult` with
* props for the wrapped component.
* @returns A promise that resolves to a `GetServerSidePropsResult` object with props for
* the wrapped component.
*
* If authentication is successful, the `GetServerSidePropsResult` will include props
* generated by the wrapped component's `getServerSideProps` method. If authentication
* fails, the `GetServerSidePropsResult` will include a redirect to the login page.
*/
const withPageAuthRequired =