Website updates: add new app scaffold, archive legacy site, and refresh docs/tooling (#173)
12
src/Website-v1/.eslintrc.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "airbnb-base", "airbnb-typescript", "prettier"],
|
||||
"rules": {
|
||||
"arrow-body-style": "off",
|
||||
"import/extensions": "warn",
|
||||
"import/order": "warn",
|
||||
"import/no-extraneous-dependencies": ["error", { "devDependencies": true }]
|
||||
},
|
||||
"parserOptions": {
|
||||
"project": ["./tsconfig.json"]
|
||||
}
|
||||
}
|
||||
2
src/Website-v1/.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.next
|
||||
14
src/Website-v1/.prettierrc
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true,
|
||||
"jsxSingleQuote": false,
|
||||
"printWidth": 90,
|
||||
"proseWrap": "always",
|
||||
"quoteProps": "as-needed",
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"useTabs": false,
|
||||
"plugins": ["prettier-plugin-jsdoc", "prettier-plugin-tailwindcss"]
|
||||
}
|
||||
219
src/Website-v1/README.old.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# The Biergarten App
|
||||
|
||||
## About
|
||||
|
||||
The Biergarten App is a web application designed for beer lovers to share their favorite
|
||||
brews and breweries with like-minded people online.
|
||||
|
||||
This application's stack consists of Next.js, Prisma and Neon Postgres. I'm motivated to
|
||||
learn more about these technologies while exploring my passion for beer.
|
||||
|
||||
I've also incorporated different APIs into the application, such as the Cloudinary API for
|
||||
image uploading, the SparkPost API for email services as well as Mapbox for geolocation
|
||||
and map data.
|
||||
|
||||
To handle serverless functions (API routes), I use the next-connect package.
|
||||
|
||||
On the client-side, I use Tailwind CSS, Headless UI and Daisy UI for styling to create a
|
||||
visually appealing and user-friendly interface.
|
||||
|
||||
I'm sharing my code publicly so that others can learn from it and use it as a reference
|
||||
for their own projects.
|
||||
|
||||
### Some beer terminology
|
||||
|
||||
In this app you will encounter various beer related terms. Here is a list of terms used in
|
||||
this app and their definitions.
|
||||
|
||||
#### ABV
|
||||
|
||||
[Alcohol by volume](https://en.wikipedia.org/wiki/Alcohol_by_volume) (abbreviated as ABV)
|
||||
is a standard measure of how much alcohol (ethanol) is contained in a given volume of an
|
||||
alcoholic beverage (expressed as a volume percent).
|
||||
|
||||
#### IBU
|
||||
|
||||
The
|
||||
[International Bitterness Units](https://en.wikipedia.org/wiki/Beer_measurement#Bitterness)
|
||||
scale, or IBU, is used to approximately quantify the bitterness of beer. This scale is not
|
||||
measured on the perceived bitterness of the beer, but rather the amount of a component of
|
||||
beer known as iso-alpha acids.
|
||||
|
||||
## Database Schema
|
||||
|
||||

|
||||
|
||||
## Technologies
|
||||
|
||||
### General
|
||||
|
||||
- [Next.js](https://nextjs.org/)
|
||||
- A React based framework for building web applications offering several features such
|
||||
as server side rendering, static site generation and API routes.
|
||||
|
||||
### Client
|
||||
|
||||
- [SWR](https://swr.vercel.app/)
|
||||
- A React Hooks library for fetching data with support for caching, revalidation and
|
||||
error handling.
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- A popular open-source utility-first CSS framework that provides pre-defined classes to
|
||||
style HTML elements.
|
||||
- [Headless UI](https://headlessui.dev/)
|
||||
- A set of completely unstyled, fully accessible UI components, designed to integrate
|
||||
beautifully with Tailwind CSS.
|
||||
- [Daisy UI](https://daisyui.com/)
|
||||
- A component library for Tailwind CSS that provides ready-to-use components for
|
||||
building user interfaces.
|
||||
|
||||
### Server
|
||||
|
||||
- [Prisma](https://www.prisma.io/)
|
||||
- An open-source ORM for Node.js and TypeScript applications.
|
||||
- [Neon Postgres](https://neon.tech/)
|
||||
- A managed PostgreSQL database service powered by Neon.
|
||||
- [Cloudinary](https://cloudinary.com/)
|
||||
- A cloud-based image and video management service that provides developers with an easy
|
||||
way to upload, store, and manipulate media assets.
|
||||
- [SparkPost](https://www.sparkpost.com/)
|
||||
- A cloud-based email delivery service that provides developers with an easy way to send
|
||||
transactional and marketing emails.
|
||||
- [Mapbox](https://www.mapbox.com/)
|
||||
- A suite of open-source mapping tools that allows developers to add custom maps,
|
||||
search, and navigation into their applications.
|
||||
- [next-connect](https://github.com/hoangvvo/next-connect#readme)
|
||||
- A promise-based method routing and middleware layer for Next.js.
|
||||
|
||||
## How to run locally
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before you can run this application locally, you will need to have the following installed
|
||||
on your machine:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/)
|
||||
- [npm (version 8 or higher)](https://www.npmjs.com/get-npm)
|
||||
|
||||
You will also need to create a free account with the following services:
|
||||
|
||||
- [Cloudinary](https://cloudinary.com/users/register/free)
|
||||
- [SparkPost](https://www.sparkpost.com/)
|
||||
- [Neon Postgres](https://neon.tech/)
|
||||
- [Mapbox](https://account.mapbox.com/auth/signup/)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Clone this repository and navigate to the project directory.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/aaronpo97/the-biergarten-app
|
||||
cd the-biergarten-app
|
||||
```
|
||||
|
||||
2. Run the following command to install the dependencies.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Run the following script to create a `.env` file in the root directory of the project
|
||||
and add the following environment variables. Update these variables with your own
|
||||
values.
|
||||
|
||||
```bash
|
||||
echo "BASE_URL=
|
||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=
|
||||
CLOUDINARY_KEY=
|
||||
CLOUDINARY_SECRET=
|
||||
CONFIRMATION_TOKEN_SECRET=
|
||||
RESET_PASSWORD_TOKEN_SECRET=
|
||||
SESSION_SECRET=
|
||||
SESSION_TOKEN_NAME=
|
||||
SESSION_MAX_AGE=
|
||||
NODE_ENV=
|
||||
|
||||
POSTGRES_PRISMA_URL=
|
||||
POSTGRES_URL_NON_POOLING=
|
||||
SHADOW_DATABASE_URL=
|
||||
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
MAPBOX_ACCESS_TOKEN=
|
||||
|
||||
SPARKPOST_API_KEY=
|
||||
SPARKPOST_SENDER_ADDRESS=" > .env
|
||||
```
|
||||
|
||||
### Explanation of environment variables
|
||||
|
||||
- `BASE_URL` is the base URL of the application.
|
||||
- For example, if you are running the application locally, you can set this to
|
||||
`http://localhost:3000`.
|
||||
- `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_KEY`, and `CLOUDINARY_SECRET` are the
|
||||
credentials for your Cloudinary account.
|
||||
- You can create a free account [here](https://cloudinary.com/users/register/free).
|
||||
- `CONFIRMATION_TOKEN_SECRET` is the secret used to sign the confirmation token used for
|
||||
email confirmation.
|
||||
- You can generate a random string using the`openssl rand -base64 127` command.
|
||||
- `RESET_PASSWORD_TOKEN_SECRET` is the secret used to sign the reset password token.
|
||||
- You can generate a random string using the `openssl rand -base64 127` command.
|
||||
- `SESSION_SECRET` is the secret used to sign the session cookie.
|
||||
- Use the same command as above to generate a random string.
|
||||
- `SESSION_TOKEN_NAME` is the name of the session cookie.
|
||||
- You can set this to `biergarten`.
|
||||
- `SESSION_MAX_AGE` is the maximum age of the session cookie in seconds.
|
||||
- You can set this to `604800` (1 week).
|
||||
- `POSTGRES_PRISMA_URL`is a pooled connection string for your Neon Postgres database.
|
||||
- `POSTGRES_URL_NON_POOLING` is a non-pooled connection string for your Neon Postgres
|
||||
database used for migrations.
|
||||
- `SHADOW_DATABASE_URL` is a connection string for a secondary database used for
|
||||
migrations to detect schema drift.
|
||||
- You can create a free account [here](https://neon.tech).
|
||||
- Consult the [docs](https://neon.tech/docs/guides/prisma) for more information.
|
||||
- `MAPBOX_ACCESS_TOKEN` is the access token for your Mapbox account.
|
||||
- You can create a free account [here](https://account.mapbox.com/auth/signup/).
|
||||
- `NODE_ENV` is the environment in which the application is running.
|
||||
- You can set this to `development` or `production`.
|
||||
- `SPARKPOST_API_KEY` is the API key for your SparkPost account.
|
||||
- You can create a free account [here](https://www.sparkpost.com/).
|
||||
- `SPARKPOST_SENDER_ADDRESS` is the email address that will be used to send emails.
|
||||
- `ADMIN_PASSWORD` is the password for the admin account created when seeding the
|
||||
database.
|
||||
|
||||
1. Initialize the database and run the migrations.
|
||||
|
||||
```bash
|
||||
npx prisma generate
|
||||
npx prisma migrate dev
|
||||
```
|
||||
|
||||
5. Seed the database with some initial data.
|
||||
|
||||
```bash
|
||||
npm run seed
|
||||
```
|
||||
|
||||
6. Start the application.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
The Biergarten App is licensed under the GNU General Public License v3.0. This means that
|
||||
anyone is free to use, modify, and distribute the code as long as they also distribute
|
||||
their modifications under the same license.
|
||||
|
||||
I encourage anyone who uses this code for educational purposes to attribute me as the
|
||||
original author, and to provide a link to this repository.
|
||||
|
||||
By contributing to this repository, you agree to license your contributions under the same
|
||||
license as the project.
|
||||
|
||||
If you have any questions or concerns about the license, please feel free to submit an
|
||||
issue to this repository.
|
||||
|
||||
I hope that this project will be useful to other developers and beer enthusiasts who are
|
||||
interested in learning about web development with Next.js, Prisma, Postgres, and other
|
||||
technologies.
|
||||
15
src/Website-v1/next.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ hostname: 'picsum.photos', protocol: 'https', pathname: '**' },
|
||||
{ hostname: 'res.cloudinary.com', protocol: 'https', pathname: '**' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
});
|
||||
|
||||
module.exports = withBundleAnalyzer(nextConfig);
|
||||
10881
src/Website-v1/package-lock.json
generated
Normal file
98
src/Website-v1/package.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"name": "biergarten",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"prestart": "npm run build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"clear-db": "npx ts-node ./src/prisma/seed/clear/index.ts",
|
||||
"format": "npx prettier . --write; npx prisma format;",
|
||||
"format-watch": "npx onchange \"**/*\" -- prettier --write --ignore-unknown {{changed}}",
|
||||
"seed": "npx --max-old-space-size=4096 ts-node ./src/prisma/seed/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/iron": "^7.0.1",
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@headlessui/tailwindcss": "^0.2.0",
|
||||
"@hookform/resolvers": "^3.3.1",
|
||||
"@mapbox/mapbox-sdk": "^0.15.2",
|
||||
"@mapbox/search-js-core": "^1.0.0-beta.17",
|
||||
"@mapbox/search-js-react": "^1.0.0-beta.17",
|
||||
"@next/bundle-analyzer": "^14.0.3",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"@react-email/components": "^0.0.11",
|
||||
"@react-email/render": "^0.0.9",
|
||||
"@react-email/tailwind": "^0.0.12",
|
||||
"@vercel/analytics": "^1.1.0",
|
||||
"argon2": "^0.31.1",
|
||||
"classnames": "^2.5.1",
|
||||
"cloudinary": "^1.41.0",
|
||||
"cookie": "^0.7.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mapbox-gl": "^3.4.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"next": "^14.2.22",
|
||||
"next-cloudinary": "^5.10.0",
|
||||
"next-connect": "^1.0.0-next.3",
|
||||
"passport": "^0.6.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"pino": "^10.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-daisyui": "^5.0.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-email": "^1.9.5",
|
||||
"react-hook-form": "^7.45.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^4.10.1",
|
||||
"react-intersection-observer": "^9.5.2",
|
||||
"react-map-gl": "^7.1.7",
|
||||
"react-responsive-carousel": "^3.2.23",
|
||||
"swr": "^2.2.0",
|
||||
"theme-change": "^2.5.0",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.3.1",
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/lodash": "^4.14.195",
|
||||
"@types/mapbox__mapbox-sdk": "^0.13.4",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^20.4.2",
|
||||
"@types/passport-local": "^1.0.35",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@vercel/fetch": "^7.0.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"daisyui": "^4.7.2",
|
||||
"dotenv-cli": "^7.2.1",
|
||||
"eslint": "^8.51.0",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-airbnb-typescript": "17.1.0",
|
||||
"eslint-config-next": "^13.5.4",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"generate-password": "^1.7.1",
|
||||
"onchange": "^7.1.0",
|
||||
"postcss": "^8.4.26",
|
||||
"prettier": "^3.0.0",
|
||||
"prettier-plugin-jsdoc": "^1.0.2",
|
||||
"prettier-plugin-tailwindcss": "^0.5.7",
|
||||
"prisma": "^5.7.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss-animated": "^1.0.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.3.2"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "./src/prisma/schema.prisma",
|
||||
"seed": "npm run seed"
|
||||
}
|
||||
}
|
||||
6
src/Website-v1/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
src/Website-v1/public/background.jpg
Normal file
|
After Width: | Height: | Size: 203 KiB |
6
src/Website-v1/public/favicon/about.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
This favicon was generated using the following graphics from Twitter Twemoji:
|
||||
|
||||
- Graphics Title: 1f37a.svg
|
||||
- Graphics Author: Copyright 2020 Twitter, Inc and other contributors (https://github.com/twitter/twemoji)
|
||||
- Graphics Source: https://github.com/twitter/twemoji/blob/master/assets/svg/1f37a.svg
|
||||
- Graphics License: CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/)
|
||||
BIN
src/Website-v1/public/favicon/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
src/Website-v1/public/favicon/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src/Website-v1/public/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src/Website-v1/public/favicon/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 515 B |
BIN
src/Website-v1/public/favicon/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 961 B |
BIN
src/Website-v1/public/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
11
src/Website-v1/public/favicon/site.webmanifest
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
5
src/Website-v1/public/robots.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
User-agent: *
|
||||
Disallow: /api/
|
||||
Disallow: /login/
|
||||
Disallow: /register/
|
||||
Disallow: /users/
|
||||
2697
src/Website-v1/schema.svg
Normal file
|
After Width: | Height: | Size: 256 KiB |
176
src/Website-v1/src/components/Account/AccountInfo.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import validateUsernameRequest from '@/requests/users/profile/validateUsernameRequest';
|
||||
import { BaseCreateUserSchema } from '@/services/users/auth/schema/CreateUserValidationSchemas';
|
||||
import { Switch } from '@headlessui/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Dispatch, FC, useContext } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
|
||||
import createErrorToast from '@/util/createErrorToast';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { AccountPageAction, AccountPageState } from '@/reducers/accountPageReducer';
|
||||
import FormError from '../ui/forms/FormError';
|
||||
import FormInfo from '../ui/forms/FormInfo';
|
||||
import FormLabel from '../ui/forms/FormLabel';
|
||||
import FormTextInput from '../ui/forms/FormTextInput';
|
||||
import { sendEditUserRequest, validateEmailRequest } from '@/requests/users/auth';
|
||||
|
||||
interface AccountInfoProps {
|
||||
pageState: AccountPageState;
|
||||
dispatch: Dispatch<AccountPageAction>;
|
||||
}
|
||||
|
||||
const AccountInfo: FC<AccountInfoProps> = ({ pageState, dispatch }) => {
|
||||
const { user, mutate } = useContext(UserContext);
|
||||
|
||||
const EditUserSchema = BaseCreateUserSchema.pick({
|
||||
username: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
}).extend({
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: 'Email must be a valid email address.' })
|
||||
.refine(
|
||||
async (email) => {
|
||||
if (user!.email === email) return true;
|
||||
return validateEmailRequest({ email });
|
||||
},
|
||||
{ message: 'Email is already taken.' },
|
||||
),
|
||||
username: z
|
||||
.string()
|
||||
.min(1, { message: 'Username must not be empty.' })
|
||||
.max(20, { message: 'Username must be less than 20 characters.' })
|
||||
.refine(
|
||||
async (username) => {
|
||||
if (user!.username === username) return true;
|
||||
return validateUsernameRequest(username);
|
||||
},
|
||||
{ message: 'Username is already taken.' },
|
||||
),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof EditUserSchema>) => {
|
||||
const loadingToast = toast.loading('Submitting edits...');
|
||||
try {
|
||||
await sendEditUserRequest({ user: user!, data });
|
||||
toast.remove(loadingToast);
|
||||
toast.success('Edits submitted successfully.');
|
||||
dispatch({ type: 'CLOSE_ALL' });
|
||||
await mutate!();
|
||||
} catch (error) {
|
||||
dispatch({ type: 'CLOSE_ALL' });
|
||||
toast.remove(loadingToast);
|
||||
createErrorToast(error);
|
||||
await mutate!();
|
||||
}
|
||||
};
|
||||
const { register, handleSubmit, formState, reset } = useForm<
|
||||
z.infer<typeof EditUserSchema>
|
||||
>({
|
||||
resolver: zodResolver(EditUserSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card mt-8">
|
||||
<div className="card-body flex flex-col space-y-3">
|
||||
<div className="flex w-full items-center justify-between space-x-5">
|
||||
<div className="">
|
||||
<h1 className="text-lg font-bold">Edit Your Account Info</h1>
|
||||
<p>Update your personal account information.</p>
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
className="toggle"
|
||||
id="edit-toggle"
|
||||
checked={pageState.accountInfoOpen}
|
||||
onClick={async () => {
|
||||
dispatch({ type: 'TOGGLE_ACCOUNT_INFO_VISIBILITY' });
|
||||
await mutate!();
|
||||
reset({
|
||||
username: user!.username,
|
||||
email: user!.email,
|
||||
firstName: user!.firstName,
|
||||
lastName: user!.lastName,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{pageState.accountInfoOpen && (
|
||||
<form
|
||||
className="form-control space-y-5"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
noValidate
|
||||
>
|
||||
<div>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="username">Username</FormLabel>
|
||||
<FormError>{formState.errors.username?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormTextInput
|
||||
type="text"
|
||||
disabled={!pageState.accountInfoOpen || formState.isSubmitting}
|
||||
error={!!formState.errors.username}
|
||||
id="username"
|
||||
formValidationSchema={register('username')}
|
||||
/>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="email">Email</FormLabel>
|
||||
<FormError>{formState.errors.email?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormTextInput
|
||||
type="email"
|
||||
disabled={!pageState.accountInfoOpen || formState.isSubmitting}
|
||||
error={!!formState.errors.email}
|
||||
id="email"
|
||||
formValidationSchema={register('email')}
|
||||
/>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<div className="w-1/2">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="firstName">First Name</FormLabel>
|
||||
<FormError>{formState.errors.firstName?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormTextInput
|
||||
type="text"
|
||||
disabled={!pageState.accountInfoOpen || formState.isSubmitting}
|
||||
error={!!formState.errors.firstName}
|
||||
id="firstName"
|
||||
formValidationSchema={register('firstName')}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="lastName">Last Name</FormLabel>
|
||||
<FormError>{formState.errors.lastName?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormTextInput
|
||||
type="text"
|
||||
disabled={!pageState.accountInfoOpen || formState.isSubmitting}
|
||||
error={!!formState.errors.lastName}
|
||||
id="lastName"
|
||||
formValidationSchema={register('lastName')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary my-5 w-full"
|
||||
type="submit"
|
||||
disabled={!pageState.accountInfoOpen || formState.isSubmitting}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountInfo;
|
||||
82
src/Website-v1/src/components/Account/BeerPostsByUser.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
import useBeerPostsByUser from '@/hooks/data-fetching/beer-posts/useBeerPostsByUser';
|
||||
import { FC, useContext, MutableRefObject, useRef } from 'react';
|
||||
import { FaArrowUp } from 'react-icons/fa';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import BeerCard from '../BeerIndex/BeerCard';
|
||||
import LoadingCard from '../ui/LoadingCard';
|
||||
import Spinner from '../ui/Spinner';
|
||||
|
||||
const BeerPostsByUser: FC = () => {
|
||||
const { user } = useContext(UserContext);
|
||||
const pageRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
|
||||
const PAGE_SIZE = 2;
|
||||
const { beerPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } =
|
||||
useBeerPostsByUser({ pageSize: PAGE_SIZE, userId: user!.id });
|
||||
const { ref: lastBeerPostRef } = useInView({
|
||||
onChange: (visible) => {
|
||||
if (!visible || isAtEnd) return;
|
||||
setSize(size + 1);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div className="mt-4" ref={pageRef}>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
{!!beerPosts.length && !isLoading && (
|
||||
<>
|
||||
{beerPosts.map((beerPost, i) => {
|
||||
return (
|
||||
<div
|
||||
key={beerPost.id}
|
||||
ref={beerPosts.length === i + 1 ? lastBeerPostRef : undefined}
|
||||
>
|
||||
<BeerCard post={beerPost} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{isLoadingMore && (
|
||||
<>
|
||||
{Array.from({ length: PAGE_SIZE }, (_, i) => (
|
||||
<LoadingCard key={i} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(isLoading || isLoadingMore) && (
|
||||
<div className="flex h-32 w-full items-center justify-center">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!beerPosts.length && isAtEnd && !isLoading && (
|
||||
<div className="flex h-20 items-center justify-center text-center">
|
||||
<div className="tooltip tooltip-bottom" data-tip="Scroll back to top of page.">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
aria-label="Scroll back to top of page."
|
||||
onClick={() => {
|
||||
pageRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FaArrowUp />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!beerPosts.length && !isLoading && (
|
||||
<div className="flex h-24 w-full items-center justify-center">
|
||||
<p className="text-lg font-bold">No posts yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerPostsByUser;
|
||||
84
src/Website-v1/src/components/Account/BreweryPostsByUser.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
import { FC, useContext, MutableRefObject, useRef } from 'react';
|
||||
import { FaArrowUp } from 'react-icons/fa';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import useBreweryPostsByUser from '@/hooks/data-fetching/brewery-posts/useBreweryPostsByUser';
|
||||
import LoadingCard from '../ui/LoadingCard';
|
||||
import Spinner from '../ui/Spinner';
|
||||
import BreweryCard from '../BreweryIndex/BreweryCard';
|
||||
|
||||
const BreweryPostsByUser: FC = () => {
|
||||
const { user } = useContext(UserContext);
|
||||
const pageRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
|
||||
const PAGE_SIZE = 2;
|
||||
const { breweryPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } =
|
||||
useBreweryPostsByUser({ pageSize: PAGE_SIZE, userId: user!.id });
|
||||
|
||||
const { ref: lastBreweryPostRef } = useInView({
|
||||
onChange: (visible) => {
|
||||
if (!visible || isAtEnd) return;
|
||||
setSize(size + 1);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-4" ref={pageRef}>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
{!!breweryPosts.length && !isLoading && (
|
||||
<>
|
||||
{breweryPosts.map((breweryPost, i) => {
|
||||
return (
|
||||
<div
|
||||
key={breweryPost.id}
|
||||
ref={breweryPosts.length === i + 1 ? lastBreweryPostRef : undefined}
|
||||
>
|
||||
<BreweryCard brewery={breweryPost} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{isLoadingMore && (
|
||||
<>
|
||||
{Array.from({ length: PAGE_SIZE }, (_, i) => (
|
||||
<LoadingCard key={i} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(isLoading || isLoadingMore) && (
|
||||
<div className="flex h-32 w-full items-center justify-center">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!breweryPosts.length && isAtEnd && !isLoading && (
|
||||
<div className="flex h-20 items-center justify-center text-center">
|
||||
<div className="tooltip tooltip-bottom" data-tip="Scroll back to top of page.">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
aria-label="Scroll back to top of page."
|
||||
onClick={() => {
|
||||
pageRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FaArrowUp />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!breweryPosts.length && !isLoading && (
|
||||
<div className="flex h-24 w-full items-center justify-center">
|
||||
<p className="text-lg font-bold">No posts yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BreweryPostsByUser;
|
||||
96
src/Website-v1/src/components/Account/DeleteAccount.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
import { AccountPageState, AccountPageAction } from '@/reducers/accountPageReducer';
|
||||
|
||||
import { Switch } from '@headlessui/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Dispatch, FunctionComponent, useContext, useRef } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface DeleteAccountProps {
|
||||
pageState: AccountPageState;
|
||||
dispatch: Dispatch<AccountPageAction>;
|
||||
}
|
||||
const DeleteAccount: FunctionComponent<DeleteAccountProps> = ({
|
||||
dispatch,
|
||||
pageState,
|
||||
}) => {
|
||||
const deleteRef = useRef<null | HTMLDialogElement>(null);
|
||||
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 (
|
||||
<div className="card w-full space-y-4">
|
||||
<div className="card-body">
|
||||
<div className="flex w-full items-center justify-between space-x-5">
|
||||
<div className="">
|
||||
<h1 className="text-lg font-bold">Delete Your Account</h1>
|
||||
<p>Want to leave? Delete your account here.</p>
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
className="toggle"
|
||||
id="edit-toggle"
|
||||
checked={pageState.deleteAccountOpen}
|
||||
onClick={() => {
|
||||
dispatch({ type: 'TOGGLE_DELETE_ACCOUNT_VISIBILITY' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{pageState.deleteAccountOpen && (
|
||||
<>
|
||||
<div className="mt-3">
|
||||
<button
|
||||
className="btn btn-primary w-full"
|
||||
onClick={() => deleteRef.current!.showModal()}
|
||||
>
|
||||
Delete my account
|
||||
</button>
|
||||
<dialog id="delete-modal" className="modal" ref={deleteRef}>
|
||||
<div className="modal-box text-center">
|
||||
<h3 className="text-lg font-bold">{`You're about to delete your account.`}</h3>
|
||||
<p className="">This action is permanent and cannot be reversed.</p>
|
||||
<div className="modal-action flex-col space-x-0 space-y-3">
|
||||
<button
|
||||
className="btn btn-error btn-sm w-full"
|
||||
onClick={onDeleteSubmit}
|
||||
>
|
||||
Okay, delete my account
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-success btn-sm w-full"
|
||||
onClick={() => deleteRef.current!.close()}
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteAccount;
|
||||
101
src/Website-v1/src/components/Account/Security.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Switch } from '@headlessui/react';
|
||||
import { Dispatch, FunctionComponent } from 'react';
|
||||
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { UpdatePasswordSchema } from '@/services/users/auth/schema/CreateUserValidationSchemas';
|
||||
|
||||
import { AccountPageState, AccountPageAction } from '@/reducers/accountPageReducer';
|
||||
import toast from 'react-hot-toast';
|
||||
import createErrorToast from '@/util/createErrorToast';
|
||||
import { sendUpdatePasswordRequest } from '@/requests/users/auth';
|
||||
import FormError from '../ui/forms/FormError';
|
||||
import FormInfo from '../ui/forms/FormInfo';
|
||||
import FormLabel from '../ui/forms/FormLabel';
|
||||
import FormTextInput from '../ui/forms/FormTextInput';
|
||||
|
||||
interface SecurityProps {
|
||||
pageState: AccountPageState;
|
||||
dispatch: Dispatch<AccountPageAction>;
|
||||
}
|
||||
|
||||
const Security: FunctionComponent<SecurityProps> = ({ dispatch, pageState }) => {
|
||||
const { register, handleSubmit, formState, reset } = useForm<
|
||||
z.infer<typeof UpdatePasswordSchema>
|
||||
>({
|
||||
resolver: zodResolver(UpdatePasswordSchema),
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof UpdatePasswordSchema>> = async (data) => {
|
||||
const loadingToast = toast.loading('Changing password.');
|
||||
try {
|
||||
await sendUpdatePasswordRequest(data);
|
||||
toast.remove(loadingToast);
|
||||
toast.success('Password changed successfully.');
|
||||
dispatch({ type: 'CLOSE_ALL' });
|
||||
} catch (error) {
|
||||
dispatch({ type: 'CLOSE_ALL' });
|
||||
createErrorToast(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card w-full space-y-4">
|
||||
<div className="card-body">
|
||||
<div className="flex w-full items-center justify-between space-x-5">
|
||||
<div className="">
|
||||
<h1 className="text-lg font-bold">Change Your Password</h1>
|
||||
<p>Update your password to maintain the safety of your account.</p>
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
className="toggle"
|
||||
id="edit-toggle"
|
||||
checked={pageState.securityOpen}
|
||||
onClick={() => {
|
||||
dispatch({ type: 'TOGGLE_SECURITY_VISIBILITY' });
|
||||
reset();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{pageState.securityOpen && (
|
||||
<form className="form-control" noValidate onSubmit={handleSubmit(onSubmit)}>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="password">New Password</FormLabel>
|
||||
<FormError>{formState.errors.password?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormTextInput
|
||||
type="password"
|
||||
disabled={!pageState.securityOpen || formState.isSubmitting}
|
||||
error={!!formState.errors.password}
|
||||
id="password"
|
||||
formValidationSchema={register('password')}
|
||||
/>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="confirm-password">Confirm Password</FormLabel>
|
||||
<FormError>{formState.errors.confirmPassword?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormTextInput
|
||||
type="password"
|
||||
disabled={!pageState.securityOpen || formState.isSubmitting}
|
||||
error={!!formState.errors.confirmPassword}
|
||||
id="confirm-password"
|
||||
formValidationSchema={register('confirmPassword')}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="btn btn-primary mt-5"
|
||||
disabled={!pageState.securityOpen || formState.isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Security;
|
||||
93
src/Website-v1/src/components/Account/UpdateProfileForm.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import FormError from '@/components/ui/forms/FormError';
|
||||
import FormInfo from '@/components/ui/forms/FormInfo';
|
||||
import FormLabel from '@/components/ui/forms/FormLabel';
|
||||
import FormSegment from '@/components/ui/forms/FormSegment';
|
||||
import Link from 'next/link';
|
||||
import FormTextArea from '@/components/ui/forms/FormTextArea';
|
||||
import { FC } from 'react';
|
||||
import GetUserSchema from '@/services/users/auth/schema/GetUserSchema';
|
||||
import type {
|
||||
UseFormHandleSubmit,
|
||||
SubmitHandler,
|
||||
FieldErrors,
|
||||
UseFormRegister,
|
||||
} from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import UpdateProfileSchema from '@/services/users/auth/schema/UpdateProfileSchema';
|
||||
|
||||
type UpdateProfileSchemaT = z.infer<typeof UpdateProfileSchema>;
|
||||
|
||||
interface UpdateProfileFormProps {
|
||||
handleSubmit: UseFormHandleSubmit<UpdateProfileSchemaT>;
|
||||
onSubmit: SubmitHandler<UpdateProfileSchemaT>;
|
||||
errors: FieldErrors<UpdateProfileSchemaT>;
|
||||
isSubmitting: boolean;
|
||||
register: UseFormRegister<UpdateProfileSchemaT>;
|
||||
user: z.infer<typeof GetUserSchema>;
|
||||
}
|
||||
|
||||
const UpdateProfileForm: FC<UpdateProfileFormProps> = ({
|
||||
handleSubmit,
|
||||
onSubmit,
|
||||
errors,
|
||||
isSubmitting,
|
||||
register,
|
||||
user,
|
||||
}) => {
|
||||
return (
|
||||
<form className="form-control space-y-1" noValidate onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="userAvatar">Avatar</FormLabel>
|
||||
<FormError>{errors.userAvatar?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<input
|
||||
disabled={isSubmitting}
|
||||
type="file"
|
||||
id="userAvatar"
|
||||
className="file-input file-input-bordered w-full"
|
||||
{...register('userAvatar')}
|
||||
multiple={false}
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
<div>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="bio">Bio</FormLabel>
|
||||
<FormError>{errors.bio?.message}</FormError>
|
||||
</FormInfo>
|
||||
|
||||
<FormSegment>
|
||||
<FormTextArea
|
||||
disabled={isSubmitting}
|
||||
id="bio"
|
||||
{...register('bio')}
|
||||
rows={5}
|
||||
formValidationSchema={register('bio')}
|
||||
error={!!errors.bio}
|
||||
placeholder="Bio"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
<div className="mt-6 flex w-full flex-col justify-center space-y-3">
|
||||
<Link
|
||||
className={`btn btn-secondary rounded-xl ${isSubmitting ? 'btn-disabled' : ''}`}
|
||||
href={`/users/${user?.id}`}
|
||||
>
|
||||
Cancel Changes
|
||||
</Link>
|
||||
|
||||
<button
|
||||
className="btn btn-primary w-full rounded-xl"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateProfileForm;
|
||||
29
src/Website-v1/src/components/Account/UpdateProfileLink.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
import { FaArrowRight } from 'react-icons/fa';
|
||||
|
||||
const UpdateProfileLink: React.FC = () => {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body flex flex-col space-y-3">
|
||||
<div className="flex w-full items-center justify-between space-x-5">
|
||||
<div className="">
|
||||
<h1 className="text-lg font-bold">Update Your Profile</h1>
|
||||
<p className="text-sm">You can update your profile information here.</p>
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
href="/users/account/edit-profile"
|
||||
className="btn-sk btn btn-circle btn-ghost btn-sm"
|
||||
>
|
||||
<FaArrowRight className="text-xl" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateProfileLink;
|
||||
39
src/Website-v1/src/components/Account/UserAvatar.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { FC } from 'react';
|
||||
import { CldImage } from 'next-cloudinary';
|
||||
import { z } from 'zod';
|
||||
import GetUserSchema from '@/services/users/auth/schema/GetUserSchema';
|
||||
import { FaUser } from 'react-icons/fa';
|
||||
|
||||
interface UserAvatarProps {
|
||||
user: {
|
||||
username: z.infer<typeof GetUserSchema>['username'];
|
||||
userAvatar: z.infer<typeof GetUserSchema>['userAvatar'];
|
||||
id: z.infer<typeof GetUserSchema>['id'];
|
||||
};
|
||||
}
|
||||
|
||||
const UserAvatar: FC<UserAvatarProps> = ({ user }) => {
|
||||
const { userAvatar } = user;
|
||||
return !userAvatar ? (
|
||||
<div
|
||||
className="mask mask-circle flex h-32 w-full items-center justify-center bg-primary"
|
||||
aria-label="Default user avatar"
|
||||
role="img"
|
||||
>
|
||||
<span className="h-full text-2xl font-bold text-base-content">
|
||||
<FaUser className="h-full" />
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<CldImage
|
||||
src={userAvatar.path}
|
||||
alt="user avatar"
|
||||
width={1000}
|
||||
height={1000}
|
||||
crop="fill"
|
||||
className="mask mask-circle h-full w-full object-cover"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAvatar;
|
||||
29
src/Website-v1/src/components/Account/UserPosts.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Tab } from '@headlessui/react';
|
||||
import { FC } from 'react';
|
||||
import BeerPostsByUser from './BeerPostsByUser';
|
||||
import BreweryPostsByUser from './BreweryPostsByUser';
|
||||
|
||||
const UserPosts: FC = () => {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
<Tab.Group>
|
||||
<Tab.List className="tabs-boxed tabs grid grid-cols-2">
|
||||
<Tab className="tab uppercase ui-selected:tab-active">Beers</Tab>
|
||||
<Tab className="tab uppercase ui-selected:tab-active">Breweries</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
<BeerPostsByUser />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<BreweryPostsByUser />
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserPosts;
|
||||
61
src/Website-v1/src/components/BeerById/BeerCommentForm.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import BeerPostQueryResult from '@/services/posts/beer-post/schema/BeerPostQueryResult';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
|
||||
import CreateCommentValidationSchema from '@/services/schema/CommentSchema/CreateCommentValidationSchema';
|
||||
import toast from 'react-hot-toast';
|
||||
import createErrorToast from '@/util/createErrorToast';
|
||||
import { sendCreateBeerCommentRequest } from '@/requests/comments/beer-comment';
|
||||
import CommentForm from '../Comments/CommentForm';
|
||||
|
||||
interface BeerCommentFormProps {
|
||||
beerPost: z.infer<typeof BeerPostQueryResult>;
|
||||
mutate: ReturnType<typeof useBeerPostComments>['mutate'];
|
||||
}
|
||||
|
||||
const BeerCommentForm: FunctionComponent<BeerCommentFormProps> = ({
|
||||
beerPost,
|
||||
mutate,
|
||||
}) => {
|
||||
const { register, handleSubmit, formState, watch, reset, setValue } = useForm<
|
||||
z.infer<typeof CreateCommentValidationSchema>
|
||||
>({
|
||||
defaultValues: { rating: 0 },
|
||||
resolver: zodResolver(CreateCommentValidationSchema),
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof CreateCommentValidationSchema>> = async (
|
||||
data,
|
||||
) => {
|
||||
const loadingToast = toast.loading('Posting a new comment...');
|
||||
try {
|
||||
await sendCreateBeerCommentRequest({ body: data, beerPostId: beerPost.id });
|
||||
reset();
|
||||
toast.remove(loadingToast);
|
||||
toast.success('Comment posted successfully.');
|
||||
await mutate();
|
||||
} catch (error) {
|
||||
await mutate();
|
||||
toast.remove(loadingToast);
|
||||
createErrorToast(error);
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CommentForm
|
||||
handleSubmit={handleSubmit}
|
||||
onSubmit={onSubmit}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
formState={formState}
|
||||
register={register}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerCommentForm;
|
||||
109
src/Website-v1/src/components/BeerById/BeerInfoHeader.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import Link from 'next/link';
|
||||
import format from 'date-fns/format';
|
||||
import { FC, useContext } from 'react';
|
||||
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
import { FaRegEdit } from 'react-icons/fa';
|
||||
import BeerPostQueryResult from '@/services/posts/beer-post/schema/BeerPostQueryResult';
|
||||
import { z } from 'zod';
|
||||
import useGetBeerPostLikeCount from '@/hooks/data-fetching/beer-likes/useBeerPostLikeCount';
|
||||
import useTimeDistance from '@/hooks/utilities/useTimeDistance';
|
||||
import BeerPostLikeButton from './BeerPostLikeButton';
|
||||
|
||||
interface BeerInfoHeaderProps {
|
||||
beerPost: z.infer<typeof BeerPostQueryResult>;
|
||||
}
|
||||
|
||||
const BeerInfoHeader: FC<BeerInfoHeaderProps> = ({ beerPost }) => {
|
||||
const createdAt = new Date(beerPost.createdAt);
|
||||
const timeDistance = useTimeDistance(createdAt);
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
const idMatches = user && beerPost.postedBy.id === user.id;
|
||||
const isPostOwner = !!(user && idMatches);
|
||||
|
||||
const { likeCount, mutate } = useGetBeerPostLikeCount(beerPost.id);
|
||||
|
||||
return (
|
||||
<article className="card flex flex-col justify-center bg-base-300">
|
||||
<div className="card-body">
|
||||
<header className="flex justify-between">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold lg:text-4xl">{beerPost.name}</h1>
|
||||
<h2 className="text-lg font-semibold lg:text-2xl">
|
||||
by{' '}
|
||||
<Link
|
||||
href={`/breweries/${beerPost.brewery.id}`}
|
||||
className="link-hover link font-semibold"
|
||||
>
|
||||
{beerPost.brewery.name}
|
||||
</Link>
|
||||
</h2>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="italic">
|
||||
{' posted by '}
|
||||
<Link href={`/users/${beerPost.postedBy.id}`} className="link-hover link">
|
||||
{`${beerPost.postedBy.username} `}
|
||||
</Link>
|
||||
{timeDistance && (
|
||||
<span
|
||||
className="tooltip tooltip-bottom"
|
||||
data-tip={format(createdAt, 'MM/dd/yyyy')}
|
||||
>
|
||||
{`${timeDistance} ago`}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPostOwner && (
|
||||
<div className="tooltip tooltip-left" data-tip={`Edit '${beerPost.name}'`}>
|
||||
<Link href={`/beers/${beerPost.id}/edit`} className="btn btn-ghost btn-xs">
|
||||
<FaRegEdit className="text-xl" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div className="space-y-2">
|
||||
<p>{beerPost.description}</p>
|
||||
<div className="flex justify-between">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<Link
|
||||
className="link-hover link text-lg font-bold"
|
||||
href={`/beers/styles/${beerPost.style.id}`}
|
||||
>
|
||||
{beerPost.style.name}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mr-4 text-lg font-medium">
|
||||
{beerPost.abv.toFixed(1)}% ABV
|
||||
</span>
|
||||
<span className="text-lg font-medium">{beerPost.ibu.toFixed(1)} IBU</span>
|
||||
</div>
|
||||
<div>
|
||||
{(!!likeCount || likeCount === 0) && (
|
||||
<span>
|
||||
Liked by {likeCount}
|
||||
{likeCount !== 1 ? ' users' : ' user'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-actions items-end">
|
||||
{user && (
|
||||
<BeerPostLikeButton beerPostId={beerPost.id} mutateCount={mutate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerInfoHeader;
|
||||
@@ -0,0 +1,88 @@
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
|
||||
import BeerPostQueryResult from '@/services/posts/beer-post/schema/BeerPostQueryResult';
|
||||
|
||||
import { FC, MutableRefObject, useContext, useRef } from 'react';
|
||||
import { z } from 'zod';
|
||||
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import {
|
||||
deleteBeerPostCommentRequest,
|
||||
editBeerPostCommentRequest,
|
||||
} from '@/requests/comments/beer-comment';
|
||||
import BeerCommentForm from './BeerCommentForm';
|
||||
|
||||
import CommentLoadingComponent from '../Comments/CommentLoadingComponent';
|
||||
import CommentsComponent from '../Comments/CommentsComponent';
|
||||
|
||||
interface BeerPostCommentsSectionProps {
|
||||
beerPost: z.infer<typeof BeerPostQueryResult>;
|
||||
}
|
||||
|
||||
const BeerPostCommentsSection: FC<BeerPostCommentsSectionProps> = ({ beerPost }) => {
|
||||
const { user } = useContext(UserContext);
|
||||
const router = useRouter();
|
||||
|
||||
const pageNum = parseInt(router.query.comments_page as string, 10) || 1;
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const { comments, isLoading, mutate, setSize, size, isLoadingMore, isAtEnd } =
|
||||
useBeerPostComments({ id: beerPost.id, pageNum, pageSize: PAGE_SIZE });
|
||||
|
||||
const commentSectionRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3" ref={commentSectionRef}>
|
||||
<div className="card bg-base-300">
|
||||
<div className="card-body h-full">
|
||||
{user ? (
|
||||
<BeerCommentForm beerPost={beerPost} mutate={mutate} />
|
||||
) : (
|
||||
<div className="flex h-52 flex-col items-center justify-center">
|
||||
<span className="text-lg font-bold">Log in to leave a comment.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
/**
|
||||
* If the comments are loading, show a loading component. Otherwise, show the
|
||||
* comments.
|
||||
*/
|
||||
isLoading ? (
|
||||
<div className="card bg-base-300 pb-6">
|
||||
<CommentLoadingComponent length={PAGE_SIZE} />
|
||||
</div>
|
||||
) : (
|
||||
<CommentsComponent
|
||||
commentSectionRef={commentSectionRef}
|
||||
comments={comments}
|
||||
isLoadingMore={isLoadingMore}
|
||||
isAtEnd={isAtEnd}
|
||||
pageSize={PAGE_SIZE}
|
||||
setSize={setSize}
|
||||
size={size}
|
||||
mutate={mutate}
|
||||
handleDeleteCommentRequest={(id) => {
|
||||
return deleteBeerPostCommentRequest({
|
||||
commentId: id,
|
||||
beerPostId: beerPost.id,
|
||||
});
|
||||
}}
|
||||
handleEditCommentRequest={(id, data) => {
|
||||
return editBeerPostCommentRequest({
|
||||
body: data,
|
||||
commentId: id,
|
||||
beerPostId: beerPost.id,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerPostCommentsSection;
|
||||
@@ -0,0 +1,35 @@
|
||||
import useCheckIfUserLikesBeerPost from '@/hooks/data-fetching/beer-likes/useCheckIfUserLikesBeerPost';
|
||||
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
|
||||
import useGetBeerPostLikeCount from '@/hooks/data-fetching/beer-likes/useBeerPostLikeCount';
|
||||
import sendBeerPostLikeRequest from '@/requests/likes/beer-post-like/sendBeerPostLikeRequest';
|
||||
import LikeButton from '../ui/LikeButton';
|
||||
|
||||
const BeerPostLikeButton: FC<{
|
||||
beerPostId: string;
|
||||
mutateCount: ReturnType<typeof useGetBeerPostLikeCount>['mutate'];
|
||||
}> = ({ beerPostId, mutateCount }) => {
|
||||
const { isLiked, mutate: mutateLikeStatus } = useCheckIfUserLikesBeerPost(beerPostId);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [isLiked]);
|
||||
|
||||
const handleLike = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await sendBeerPostLikeRequest(beerPostId);
|
||||
|
||||
await Promise.all([mutateCount(), mutateLikeStatus()]);
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <LikeButton isLiked={!!isLiked} handleLike={handleLike} loading={loading} />;
|
||||
};
|
||||
|
||||
export default BeerPostLikeButton;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { FC } from 'react';
|
||||
import Spinner from '../ui/Spinner';
|
||||
|
||||
interface BeerRecommendationLoadingComponentProps {
|
||||
length: number;
|
||||
}
|
||||
|
||||
const BeerRecommendationLoadingComponent: FC<BeerRecommendationLoadingComponentProps> = ({
|
||||
length,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length }).map((_, i) => (
|
||||
<div className="animate my-3 fade-in-10" key={i}>
|
||||
<div className="flex animate-pulse space-x-4">
|
||||
<div className="flex-1 space-y-4 py-1">
|
||||
<div className="h-4 w-3/4 rounded bg-base-100" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 rounded bg-base-100" />
|
||||
<div className="h-4 w-11/12 rounded bg-base-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="p-1">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerRecommendationLoadingComponent;
|
||||
106
src/Website-v1/src/components/BeerById/BeerRecommendations.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import Link from 'next/link';
|
||||
import { FC } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { z } from 'zod';
|
||||
import useBeerRecommendations from '@/hooks/data-fetching/beer-posts/useBeerRecommendations';
|
||||
import BeerPostQueryResult from '@/services/posts/beer-post/schema/BeerPostQueryResult';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
import BeerRecommendationLoadingComponent from './BeerRecommendationLoadingComponent';
|
||||
|
||||
const BeerRecommendationsSection: FC<{
|
||||
beerPost: z.infer<typeof BeerPostQueryResult>;
|
||||
}> = ({ beerPost }) => {
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const { beerPosts, isAtEnd, isLoadingMore, setSize, size } = useBeerRecommendations({
|
||||
beerPost,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const { ref: penultimateBeerPostRef } = useInView({
|
||||
/**
|
||||
* When the last beer post comes into view, call setSize from useBeerPostsByBrewery to
|
||||
* load more beer posts.
|
||||
*/
|
||||
onChange: (visible) => {
|
||||
if (!visible || isAtEnd) return;
|
||||
debounce(() => setSize(size + 1), 200)();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card h-full">
|
||||
<div className="card-body">
|
||||
<>
|
||||
<div className="my-2 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-3xl font-bold">Also check out</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!!beerPosts.length && (
|
||||
<div className="space-y-5">
|
||||
{beerPosts.map((post, index) => {
|
||||
const isPenultimateBeerPost = index === beerPosts.length - 2;
|
||||
|
||||
/**
|
||||
* Attach a ref to the second last beer post in the list. When it comes
|
||||
* into view, the component will call setSize to load more beer posts.
|
||||
*/
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={isPenultimateBeerPost ? penultimateBeerPostRef : undefined}
|
||||
key={post.id}
|
||||
className="animate-fade"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<Link className="link-hover link" href={`/beers/${post.id}`}>
|
||||
<span className="text-xl font-bold">{post.name}</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className="link-hover link"
|
||||
href={`/breweries/${post.brewery.id}`}
|
||||
>
|
||||
<span className="text-lg font-semibold">{post.brewery.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<Link
|
||||
className="link-hover link"
|
||||
href={`/beers/styles/${post.style.id}`}
|
||||
>
|
||||
<span className="font-medium">{post.style.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<span>{post.abv.toFixed(1)}% ABV</span>
|
||||
<span>{post.ibu.toFixed(1)} IBU</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{
|
||||
/**
|
||||
* If there are more beer posts to load, show a loading component with a
|
||||
* skeleton loader and a loading spinner.
|
||||
*/
|
||||
!!isLoadingMore && !isAtEnd && (
|
||||
<BeerRecommendationLoadingComponent length={PAGE_SIZE} />
|
||||
)
|
||||
}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerRecommendationsSection;
|
||||
73
src/Website-v1/src/components/BeerIndex/BeerCard.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import Link from 'next/link';
|
||||
import { FC, useContext } from 'react';
|
||||
|
||||
import BeerPostQueryResult from '@/services/posts/beer-post/schema/BeerPostQueryResult';
|
||||
import { z } from 'zod';
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
import useGetBeerPostLikeCount from '@/hooks/data-fetching/beer-likes/useBeerPostLikeCount';
|
||||
import { CldImage } from 'next-cloudinary';
|
||||
import BeerPostLikeButton from '../BeerById/BeerPostLikeButton';
|
||||
|
||||
const BeerCard: FC<{ post: z.infer<typeof BeerPostQueryResult> }> = ({ post }) => {
|
||||
const { user } = useContext(UserContext);
|
||||
const { mutate, likeCount, isLoading } = useGetBeerPostLikeCount(post.id);
|
||||
|
||||
return (
|
||||
<div className="card card-compact bg-base-300" key={post.id}>
|
||||
<figure className="h-96">
|
||||
<Link href={`/beers/${post.id}`} className="h-full object-cover">
|
||||
{post.beerImages.length > 0 && (
|
||||
<CldImage
|
||||
src={post.beerImages[0].path}
|
||||
alt={post.name}
|
||||
crop="fill"
|
||||
width="3000"
|
||||
height="3000"
|
||||
className="h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</figure>
|
||||
<div className="card-body justify-between">
|
||||
<div className="space-y-1">
|
||||
<Link href={`/beers/${post.id}`}>
|
||||
<h3 className="link-hover link overflow-hidden whitespace-normal text-2xl font-bold lg:truncate lg:text-3xl">
|
||||
{post.name}
|
||||
</h3>
|
||||
</Link>
|
||||
<Link href={`/breweries/${post.brewery.id}`}>
|
||||
<h4 className="text-md link-hover link whitespace-normal lg:truncate lg:text-xl">
|
||||
{post.brewery.name}
|
||||
</h4>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<Link
|
||||
className="text-md hover:underline lg:text-xl"
|
||||
href={`/beers/styles/${post.style.id}`}
|
||||
>
|
||||
{post.style.name}
|
||||
</Link>
|
||||
<div className="space-x-3">
|
||||
<span className="text-sm lg:text-lg">{post.abv.toFixed(1)}% ABV</span>
|
||||
<span className="text-sm lg:text-lg">{post.ibu.toFixed(1)} IBU</span>
|
||||
</div>
|
||||
{!isLoading && (
|
||||
<span>
|
||||
liked by {likeCount} user{likeCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{!!user && !isLoading && (
|
||||
<BeerPostLikeButton beerPostId={post.id} mutateCount={mutate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerCard;
|
||||
@@ -0,0 +1,100 @@
|
||||
import Link from 'next/link';
|
||||
import { FC, MutableRefObject, useRef } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { z } from 'zod';
|
||||
|
||||
import BeerStyleQueryResult from '@/services/posts/beer-style-post/schema/BeerStyleQueryResult';
|
||||
import useBeerPostsByBeerStyle from '@/hooks/data-fetching/beer-posts/useBeerPostsByBeerStyles';
|
||||
import BeerRecommendationLoadingComponent from '../BeerById/BeerRecommendationLoadingComponent';
|
||||
|
||||
interface BeerStyleBeerSectionProps {
|
||||
beerStyle: z.infer<typeof BeerStyleQueryResult>;
|
||||
}
|
||||
|
||||
const BeerStyleBeerSection: FC<BeerStyleBeerSectionProps> = ({ beerStyle }) => {
|
||||
const PAGE_SIZE = 2;
|
||||
|
||||
const { beerPosts, isAtEnd, isLoadingMore, setSize, size } = useBeerPostsByBeerStyle({
|
||||
beerStyleId: beerStyle.id,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const { ref: penultimateBeerPostRef } = useInView({
|
||||
/**
|
||||
* When the last beer post comes into view, call setSize from useBeerPostsByBeerStyle
|
||||
* to load more beer posts.
|
||||
*/
|
||||
onChange: (visible) => {
|
||||
if (!visible || isAtEnd) return;
|
||||
setSize(size + 1);
|
||||
},
|
||||
});
|
||||
|
||||
const beerRecommendationsRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
|
||||
|
||||
return (
|
||||
<div className="card h-full" ref={beerRecommendationsRef}>
|
||||
<div className="card-body">
|
||||
<>
|
||||
<div className="my-2 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-3xl font-bold">Brews</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!!beerPosts.length && (
|
||||
<div className="space-y-5">
|
||||
{beerPosts.map((beerPost, index) => {
|
||||
const isPenultimateBeerPost = index === beerPosts.length - 2;
|
||||
|
||||
/**
|
||||
* Attach a ref to the second last beer post in the list. When it comes
|
||||
* into view, the component will call setSize to load more beer posts.
|
||||
*/
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={isPenultimateBeerPost ? penultimateBeerPostRef : undefined}
|
||||
key={beerPost.id}
|
||||
>
|
||||
<div>
|
||||
<Link className="link-hover link" href={`/beers/${beerPost.id}`}>
|
||||
<span className="text-xl font-semibold">{beerPost.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
className="link-hover link"
|
||||
href={`/breweries/${beerPost.brewery.id}`}
|
||||
>
|
||||
<span className="text-xl font-semibold">
|
||||
{beerPost.brewery.name}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-x-2">
|
||||
<span>{beerPost.abv}% ABV</span>
|
||||
<span>{beerPost.ibu} IBU</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{
|
||||
/**
|
||||
* If there are more beer posts to load, show a loading component with a
|
||||
* skeleton loader and a loading spinner.
|
||||
*/
|
||||
!!isLoadingMore && !isAtEnd && (
|
||||
<BeerRecommendationLoadingComponent length={PAGE_SIZE} />
|
||||
)
|
||||
}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerStyleBeerSection;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import CreateCommentValidationSchema from '@/services/schema/CommentSchema/CreateCommentValidationSchema';
|
||||
import toast from 'react-hot-toast';
|
||||
import createErrorToast from '@/util/createErrorToast';
|
||||
|
||||
import BeerStyleQueryResult from '@/services/posts/beer-style-post/schema/BeerStyleQueryResult';
|
||||
import useBeerStyleComments from '@/hooks/data-fetching/beer-style-comments/useBeerStyleComments';
|
||||
import { sendCreateBeerStyleCommentRequest } from '@/requests/comments/beer-style-comment';
|
||||
import CommentForm from '../Comments/CommentForm';
|
||||
|
||||
interface BeerCommentFormProps {
|
||||
beerStyle: z.infer<typeof BeerStyleQueryResult>;
|
||||
mutate: ReturnType<typeof useBeerStyleComments>['mutate'];
|
||||
}
|
||||
|
||||
const BeerStyleCommentForm: FunctionComponent<BeerCommentFormProps> = ({
|
||||
beerStyle,
|
||||
mutate,
|
||||
}) => {
|
||||
const { register, handleSubmit, formState, watch, reset, setValue } = useForm<
|
||||
z.infer<typeof CreateCommentValidationSchema>
|
||||
>({
|
||||
defaultValues: { rating: 0 },
|
||||
resolver: zodResolver(CreateCommentValidationSchema),
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof CreateCommentValidationSchema>> = async (
|
||||
data,
|
||||
) => {
|
||||
const loadingToast = toast.loading('Posting a new comment...');
|
||||
try {
|
||||
await sendCreateBeerStyleCommentRequest({
|
||||
body: { content: data.content, rating: data.rating },
|
||||
beerStyleId: beerStyle.id,
|
||||
});
|
||||
reset();
|
||||
toast.remove(loadingToast);
|
||||
toast.success('Comment posted successfully.');
|
||||
await mutate();
|
||||
} catch (error) {
|
||||
await mutate();
|
||||
toast.remove(loadingToast);
|
||||
createErrorToast(error);
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CommentForm
|
||||
handleSubmit={handleSubmit}
|
||||
onSubmit={onSubmit}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
formState={formState}
|
||||
register={register}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerStyleCommentForm;
|
||||
@@ -0,0 +1,86 @@
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
|
||||
import { FC, MutableRefObject, useContext, useRef } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import BeerStyleQueryResult from '@/services/posts/beer-style-post/schema/BeerStyleQueryResult';
|
||||
import useBeerStyleComments from '@/hooks/data-fetching/beer-style-comments/useBeerStyleComments';
|
||||
import {
|
||||
sendDeleteBeerStyleCommentRequest,
|
||||
sendEditBeerStyleCommentRequest,
|
||||
} from '@/requests/comments/beer-style-comment';
|
||||
import CommentLoadingComponent from '../Comments/CommentLoadingComponent';
|
||||
import CommentsComponent from '../Comments/CommentsComponent';
|
||||
import BeerStyleCommentForm from './BeerStyleCommentForm';
|
||||
|
||||
interface BeerStyleCommentsSectionProps {
|
||||
beerStyle: z.infer<typeof BeerStyleQueryResult>;
|
||||
}
|
||||
|
||||
const BeerStyleCommentsSection: FC<BeerStyleCommentsSectionProps> = ({ beerStyle }) => {
|
||||
const { user } = useContext(UserContext);
|
||||
const router = useRouter();
|
||||
|
||||
const pageNum = parseInt(router.query.comments_page as string, 10) || 1;
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const { comments, isLoading, mutate, setSize, size, isLoadingMore, isAtEnd } =
|
||||
useBeerStyleComments({ id: beerStyle.id, pageNum, pageSize: PAGE_SIZE });
|
||||
|
||||
const commentSectionRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3" ref={commentSectionRef}>
|
||||
<div className="card bg-base-300">
|
||||
<div className="card-body h-full">
|
||||
{user ? (
|
||||
<BeerStyleCommentForm beerStyle={beerStyle} mutate={mutate} />
|
||||
) : (
|
||||
<div className="flex h-52 flex-col items-center justify-center">
|
||||
<span className="text-lg font-bold">Log in to leave a comment.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
/**
|
||||
* If the comments are loading, show a loading component. Otherwise, show the
|
||||
* comments.
|
||||
*/
|
||||
isLoading ? (
|
||||
<div className="card bg-base-300 pb-6">
|
||||
<CommentLoadingComponent length={PAGE_SIZE} />
|
||||
</div>
|
||||
) : (
|
||||
<CommentsComponent
|
||||
commentSectionRef={commentSectionRef}
|
||||
comments={comments}
|
||||
isLoadingMore={isLoadingMore}
|
||||
isAtEnd={isAtEnd}
|
||||
pageSize={PAGE_SIZE}
|
||||
setSize={setSize}
|
||||
size={size}
|
||||
mutate={mutate}
|
||||
handleDeleteCommentRequest={(id) => {
|
||||
return sendDeleteBeerStyleCommentRequest({
|
||||
beerStyleId: beerStyle.id,
|
||||
commentId: id,
|
||||
});
|
||||
}}
|
||||
handleEditCommentRequest={(id, data) => {
|
||||
return sendEditBeerStyleCommentRequest({
|
||||
beerStyleId: beerStyle.id,
|
||||
commentId: id,
|
||||
body: data,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerStyleCommentsSection;
|
||||
112
src/Website-v1/src/components/BeerStyleById/BeerStyleHeader.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import Link from 'next/link';
|
||||
import format from 'date-fns/format';
|
||||
import { FC, useContext } from 'react';
|
||||
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
import { FaRegEdit } from 'react-icons/fa';
|
||||
|
||||
import { z } from 'zod';
|
||||
import useTimeDistance from '@/hooks/utilities/useTimeDistance';
|
||||
import BeerStyleQueryResult from '@/services/posts/beer-style-post/schema/BeerStyleQueryResult';
|
||||
import useBeerStyleLikeCount from '@/hooks/data-fetching/beer-style-likes/useBeerStyleLikeCount';
|
||||
import BeerStyleLikeButton from './BeerStyleLikeButton';
|
||||
|
||||
interface BeerInfoHeaderProps {
|
||||
beerStyle: z.infer<typeof BeerStyleQueryResult>;
|
||||
}
|
||||
|
||||
const BeerStyleHeader: FC<BeerInfoHeaderProps> = ({ beerStyle }) => {
|
||||
const createdAt = new Date(beerStyle.createdAt);
|
||||
const timeDistance = useTimeDistance(createdAt);
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
const idMatches = user && beerStyle.postedBy.id === user.id;
|
||||
const isPostOwner = !!(user && idMatches);
|
||||
|
||||
const { likeCount, mutate } = useBeerStyleLikeCount(beerStyle.id);
|
||||
|
||||
return (
|
||||
<article className="card flex flex-col justify-center bg-base-300">
|
||||
<div className="card-body">
|
||||
<header className="flex justify-between">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold lg:text-4xl">{beerStyle.name}</h1>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="italic">
|
||||
{' posted by '}
|
||||
<Link
|
||||
href={`/users/${beerStyle.postedBy.id}`}
|
||||
className="link-hover link"
|
||||
>
|
||||
{`${beerStyle.postedBy.username} `}
|
||||
</Link>
|
||||
{timeDistance && (
|
||||
<span
|
||||
className="tooltip tooltip-bottom"
|
||||
data-tip={format(createdAt, 'MM/dd/yyyy')}
|
||||
>
|
||||
{`${timeDistance} ago`}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPostOwner && (
|
||||
<div className="tooltip tooltip-left" data-tip={`Edit '${beerStyle.name}'`}>
|
||||
<Link href={`/beers/${beerStyle.id}/edit`} className="btn btn-ghost btn-xs">
|
||||
<FaRegEdit className="text-xl" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div>
|
||||
<p>{beerStyle.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="w-25 flex flex-row space-x-3">
|
||||
<div className="text-sm font-bold">
|
||||
ABV Range:{' '}
|
||||
<span>
|
||||
{beerStyle.abvRange[0].toFixed(1)}% - {beerStyle.abvRange[0].toFixed(1)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold">
|
||||
IBU Range:{' '}
|
||||
<span>
|
||||
{beerStyle.ibuRange[0].toFixed(1)} - {beerStyle.ibuRange[1].toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-semibold">
|
||||
Recommended Glassware:{' '}
|
||||
<span className="text-sm font-bold italic">{beerStyle.glassware.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
{(!!likeCount || likeCount === 0) && (
|
||||
<span>
|
||||
Liked by {likeCount}
|
||||
{likeCount !== 1 ? ' users' : ' user'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-actions items-end">
|
||||
{user && (
|
||||
<BeerStyleLikeButton beerStyleId={beerStyle.id} mutateCount={mutate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerStyleHeader;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
|
||||
import useGetBeerPostLikeCount from '@/hooks/data-fetching/beer-likes/useBeerPostLikeCount';
|
||||
import useCheckIfUserLikesBeerStyle from '@/hooks/data-fetching/beer-style-likes/useCheckIfUserLikesBeerPost';
|
||||
import sendBeerStyleLikeRequest from '@/requests/likes/beer-style-like/sendBeerStyleLikeRequest';
|
||||
import LikeButton from '../ui/LikeButton';
|
||||
|
||||
const BeerStyleLikeButton: FC<{
|
||||
beerStyleId: string;
|
||||
mutateCount: ReturnType<typeof useGetBeerPostLikeCount>['mutate'];
|
||||
}> = ({ beerStyleId, mutateCount }) => {
|
||||
const { isLiked, mutate: mutateLikeStatus } = useCheckIfUserLikesBeerStyle(beerStyleId);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [isLiked]);
|
||||
|
||||
const handleLike = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await sendBeerStyleLikeRequest(beerStyleId);
|
||||
|
||||
await Promise.all([mutateCount(), mutateLikeStatus()]);
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <LikeButton isLiked={!!isLiked} handleLike={handleLike} loading={loading} />;
|
||||
};
|
||||
|
||||
export default BeerStyleLikeButton;
|
||||
@@ -0,0 +1,48 @@
|
||||
import BeerStyleQueryResult from '@/services/posts/beer-style-post/schema/BeerStyleQueryResult';
|
||||
import Link from 'next/link';
|
||||
import { FC } from 'react';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const BeerStyleCard: FC<{ beerStyle: z.infer<typeof BeerStyleQueryResult> }> = ({
|
||||
beerStyle,
|
||||
}) => {
|
||||
return (
|
||||
<div className="card card-compact bg-base-300">
|
||||
<div className="card-body justify-between">
|
||||
<div className="space-y-1">
|
||||
<Link href={`/beers/styles/${beerStyle.id}`}>
|
||||
<h3 className="link-hover link overflow-hidden whitespace-normal text-2xl font-bold lg:truncate lg:text-3xl">
|
||||
{beerStyle.name}
|
||||
</h3>
|
||||
</Link>
|
||||
<div className="w-25 flex flex-row space-x-3">
|
||||
<div className="text-sm font-bold">
|
||||
ABV Range:{' '}
|
||||
<span>
|
||||
{beerStyle.abvRange[0].toFixed(1)}% - {beerStyle.abvRange[0].toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold">
|
||||
IBU Range:{' '}
|
||||
<span>
|
||||
{beerStyle.ibuRange[0].toFixed(1)} - {beerStyle.ibuRange[1].toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-20">
|
||||
<p className="line-clamp-3 overflow-ellipsis">{beerStyle.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="font-semibold">
|
||||
Recommended Glassware:{' '}
|
||||
<span className="text-sm font-bold italic">{beerStyle.glassware.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerStyleCard;
|
||||
111
src/Website-v1/src/components/BreweryById/BreweryBeerSection.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import UseBeerPostsByBrewery from '@/hooks/data-fetching/beer-posts/useBeerPostsByBrewery';
|
||||
import BreweryPostQueryResult from '@/services/posts/brewery-post/schema/BreweryPostQueryResult';
|
||||
import Link from 'next/link';
|
||||
import { FC, MutableRefObject, useContext, useRef } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { z } from 'zod';
|
||||
import { FaPlus } from 'react-icons/fa';
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
import BeerRecommendationLoadingComponent from '../BeerById/BeerRecommendationLoadingComponent';
|
||||
|
||||
interface BreweryCommentsSectionProps {
|
||||
breweryPost: z.infer<typeof BreweryPostQueryResult>;
|
||||
}
|
||||
|
||||
const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) => {
|
||||
const PAGE_SIZE = 2;
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const { beerPosts, isAtEnd, isLoadingMore, setSize, size } = UseBeerPostsByBrewery({
|
||||
breweryId: breweryPost.id,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const { ref: penultimateBeerPostRef } = useInView({
|
||||
/**
|
||||
* When the last beer post comes into view, call setSize from useBeerPostsByBrewery to
|
||||
* load more beer posts.
|
||||
*/
|
||||
onChange: (visible) => {
|
||||
if (!visible || isAtEnd) return;
|
||||
setSize(size + 1);
|
||||
},
|
||||
});
|
||||
|
||||
const beerRecommendationsRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
|
||||
|
||||
return (
|
||||
<div className="card h-full" ref={beerRecommendationsRef}>
|
||||
<div className="card-body">
|
||||
<>
|
||||
<div className="my-2 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-3xl font-bold">Brews</h3>
|
||||
</div>
|
||||
<div>
|
||||
{user && (
|
||||
<Link
|
||||
className={`btn btn-ghost btn-sm gap-2 rounded-2xl outline`}
|
||||
href={`/breweries/${breweryPost.id}/beers/create`}
|
||||
>
|
||||
<FaPlus className="text-xl" />
|
||||
Add Beer
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!!beerPosts.length && (
|
||||
<div className="space-y-5">
|
||||
{beerPosts.map((beerPost, index) => {
|
||||
const isPenultimateBeerPost = index === beerPosts.length - 2;
|
||||
|
||||
/**
|
||||
* Attach a ref to the second last beer post in the list. When it comes
|
||||
* into view, the component will call setSize to load more beer posts.
|
||||
*/
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={isPenultimateBeerPost ? penultimateBeerPostRef : undefined}
|
||||
key={beerPost.id}
|
||||
>
|
||||
<div>
|
||||
<Link className="link-hover link" href={`/beers/${beerPost.id}`}>
|
||||
<span className="text-xl font-semibold">{beerPost.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Link
|
||||
className="link-hover link text-lg font-medium"
|
||||
href={`/beers/styles/${beerPost.style.id}`}
|
||||
>
|
||||
{beerPost.style.name}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<span>{beerPost.abv}% ABV</span>
|
||||
<span>{beerPost.ibu} IBU</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{
|
||||
/**
|
||||
* If there are more beer posts to load, show a loading component with a
|
||||
* skeleton loader and a loading spinner.
|
||||
*/
|
||||
!!isLoadingMore && !isAtEnd && (
|
||||
<BeerRecommendationLoadingComponent length={PAGE_SIZE} />
|
||||
)
|
||||
}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BreweryBeersSection;
|
||||
@@ -0,0 +1,60 @@
|
||||
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
|
||||
import BreweryPostQueryResult from '@/services/posts/brewery-post/schema/BreweryPostQueryResult';
|
||||
import CreateCommentValidationSchema from '@/services/schema/CommentSchema/CreateCommentValidationSchema';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FC } from 'react';
|
||||
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { z } from 'zod';
|
||||
import sendCreateBreweryCommentRequest from '@/requests/comments/brewery-comment/sendCreateBreweryCommentRequest';
|
||||
import createErrorToast from '@/util/createErrorToast';
|
||||
import CommentForm from '../Comments/CommentForm';
|
||||
|
||||
interface BreweryCommentFormProps {
|
||||
breweryPost: z.infer<typeof BreweryPostQueryResult>;
|
||||
mutate: ReturnType<typeof useBreweryPostComments>['mutate'];
|
||||
}
|
||||
|
||||
const BreweryCommentForm: FC<BreweryCommentFormProps> = ({ breweryPost, mutate }) => {
|
||||
const { register, handleSubmit, formState, watch, reset, setValue } = useForm<
|
||||
z.infer<typeof CreateCommentValidationSchema>
|
||||
>({
|
||||
defaultValues: { rating: 0 },
|
||||
resolver: zodResolver(CreateCommentValidationSchema),
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof CreateCommentValidationSchema>> = async (
|
||||
data,
|
||||
) => {
|
||||
const loadingToast = toast.loading('Posting a new comment...');
|
||||
try {
|
||||
await sendCreateBreweryCommentRequest({
|
||||
content: data.content,
|
||||
rating: data.rating,
|
||||
breweryPostId: breweryPost.id,
|
||||
});
|
||||
reset();
|
||||
toast.remove(loadingToast);
|
||||
toast.success('Comment posted successfully.');
|
||||
await mutate();
|
||||
} catch (error) {
|
||||
await mutate();
|
||||
toast.remove(loadingToast);
|
||||
createErrorToast(error);
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CommentForm
|
||||
handleSubmit={handleSubmit}
|
||||
onSubmit={onSubmit}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
formState={formState}
|
||||
register={register}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BreweryCommentForm;
|
||||
@@ -0,0 +1,88 @@
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
import BreweryPostQueryResult from '@/services/posts/brewery-post/schema/BreweryPostQueryResult';
|
||||
import { FC, MutableRefObject, useContext, useRef } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
|
||||
import {
|
||||
sendDeleteBreweryPostCommentRequest,
|
||||
sendEditBreweryPostCommentRequest,
|
||||
} from '@/requests/comments/brewery-comment';
|
||||
import CommentLoadingComponent from '../Comments/CommentLoadingComponent';
|
||||
import CommentsComponent from '../Comments/CommentsComponent';
|
||||
import BreweryCommentForm from './BreweryCommentForm';
|
||||
|
||||
interface BreweryBeerSectionProps {
|
||||
breweryPost: z.infer<typeof BreweryPostQueryResult>;
|
||||
}
|
||||
|
||||
const BreweryCommentsSection: FC<BreweryBeerSectionProps> = ({ breweryPost }) => {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const PAGE_SIZE = 4;
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
setSize,
|
||||
size,
|
||||
isLoadingMore,
|
||||
isAtEnd,
|
||||
mutate,
|
||||
comments: breweryComments,
|
||||
} = useBreweryPostComments({ id: breweryPost.id, pageSize: PAGE_SIZE });
|
||||
|
||||
const commentSectionRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3" ref={commentSectionRef}>
|
||||
<div className="card">
|
||||
<div className="card-body h-full">
|
||||
{user ? (
|
||||
<BreweryCommentForm breweryPost={breweryPost} mutate={mutate} />
|
||||
) : (
|
||||
<div className="flex h-52 flex-col items-center justify-center">
|
||||
<div className="text-lg font-bold">Log in to leave a comment.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
/**
|
||||
* If the comments are loading, show a loading component. Otherwise, show the
|
||||
* comments.
|
||||
*/
|
||||
isLoading ? (
|
||||
<div className="card pb-6">
|
||||
<CommentLoadingComponent length={PAGE_SIZE} />
|
||||
</div>
|
||||
) : (
|
||||
<CommentsComponent
|
||||
comments={breweryComments}
|
||||
isLoadingMore={isLoadingMore}
|
||||
isAtEnd={isAtEnd}
|
||||
pageSize={PAGE_SIZE}
|
||||
setSize={setSize}
|
||||
size={size}
|
||||
commentSectionRef={commentSectionRef}
|
||||
mutate={mutate}
|
||||
handleDeleteCommentRequest={(id) => {
|
||||
return sendDeleteBreweryPostCommentRequest({
|
||||
breweryPostId: breweryPost.id,
|
||||
commentId: id,
|
||||
});
|
||||
}}
|
||||
handleEditCommentRequest={(commentId, data) => {
|
||||
return sendEditBreweryPostCommentRequest({
|
||||
breweryPostId: breweryPost.id,
|
||||
commentId,
|
||||
body: { content: data.content, rating: data.rating },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BreweryCommentsSection;
|
||||
100
src/Website-v1/src/components/BreweryById/BreweryInfoHeader.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
import useGetBreweryPostLikeCount from '@/hooks/data-fetching/brewery-likes/useGetBreweryPostLikeCount';
|
||||
import useTimeDistance from '@/hooks/utilities/useTimeDistance';
|
||||
import BreweryPostQueryResult from '@/services/posts/brewery-post/schema/BreweryPostQueryResult';
|
||||
import { format } from 'date-fns';
|
||||
import { FC, useContext } from 'react';
|
||||
|
||||
import { FaRegEdit } from 'react-icons/fa';
|
||||
|
||||
import { z } from 'zod';
|
||||
import Link from 'next/link';
|
||||
import BreweryPostLikeButton from '../BreweryIndex/BreweryPostLikeButton';
|
||||
|
||||
interface BreweryInfoHeaderProps {
|
||||
breweryPost: z.infer<typeof BreweryPostQueryResult>;
|
||||
}
|
||||
const BreweryInfoHeader: FC<BreweryInfoHeaderProps> = ({ breweryPost }) => {
|
||||
const createdAt = new Date(breweryPost.createdAt);
|
||||
const timeDistance = useTimeDistance(createdAt);
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
const idMatches = user && breweryPost.postedBy.id === user.id;
|
||||
const isPostOwner = !!(user && idMatches);
|
||||
|
||||
const { likeCount, mutate } = useGetBreweryPostLikeCount(breweryPost.id);
|
||||
|
||||
return (
|
||||
<article className="card flex flex-col justify-center bg-base-300">
|
||||
<div className="card-body">
|
||||
<header className="flex justify-between">
|
||||
<div className="w-full space-y-2">
|
||||
<div className="flex w-full flex-row justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold lg:text-4xl">{breweryPost.name}</h1>
|
||||
<h2 className="text-lg font-semibold lg:text-2xl">
|
||||
Located in
|
||||
{` ${breweryPost.location.city}, ${
|
||||
breweryPost.location.stateOrProvince || breweryPost.location.country
|
||||
}`}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="italic">
|
||||
{' posted by '}
|
||||
<Link
|
||||
href={`/users/${breweryPost.postedBy.id}`}
|
||||
className="link-hover link"
|
||||
>
|
||||
{`${breweryPost.postedBy.username} `}
|
||||
</Link>
|
||||
{timeDistance && (
|
||||
<span
|
||||
className="tooltip tooltip-bottom"
|
||||
data-tip={format(createdAt, 'MM/dd/yyyy')}
|
||||
>{`${timeDistance} ago`}</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
{isPostOwner && (
|
||||
<div className="tooltip tooltip-left" data-tip={`Edit '${breweryPost.name}'`}>
|
||||
<Link
|
||||
href={`/breweries/${breweryPost.id}/edit`}
|
||||
className="btn btn-ghost btn-xs"
|
||||
>
|
||||
<FaRegEdit className="text-xl" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div className="space-y-2">
|
||||
<p>{breweryPost.description}</p>
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
{(!!likeCount || likeCount === 0) && (
|
||||
<span>
|
||||
Liked by {likeCount} {likeCount === 1 ? 'user' : 'users'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
{user && (
|
||||
<BreweryPostLikeButton
|
||||
breweryPostId={breweryPost.id}
|
||||
mutateCount={mutate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default BreweryInfoHeader;
|
||||
60
src/Website-v1/src/components/BreweryById/BreweryPostMap.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import useMediaQuery from '@/hooks/utilities/useMediaQuery';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import { FC, useMemo } from 'react';
|
||||
import Map, { Marker } from 'react-map-gl';
|
||||
|
||||
import LocationMarker from '../ui/LocationMarker';
|
||||
import ControlPanel from '../ui/maps/ControlPanel';
|
||||
|
||||
interface BreweryMapProps {
|
||||
coordinates: { latitude: number; longitude: number };
|
||||
token: string;
|
||||
}
|
||||
type MapStyles = Record<'light' | 'dark', `mapbox://styles/mapbox/${string}`>;
|
||||
|
||||
const BreweryPostMap: FC<BreweryMapProps> = ({
|
||||
coordinates: { latitude, longitude },
|
||||
token,
|
||||
}) => {
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
const windowIsDefined = typeof window !== 'undefined';
|
||||
const themeIsDefined = windowIsDefined && !!window.localStorage.getItem('theme');
|
||||
|
||||
const theme = (
|
||||
windowIsDefined && themeIsDefined ? window.localStorage.getItem('theme') : 'light'
|
||||
) as 'light' | 'dark';
|
||||
|
||||
const pin = useMemo(
|
||||
() => (
|
||||
<Marker latitude={latitude} longitude={longitude}>
|
||||
<LocationMarker />
|
||||
</Marker>
|
||||
),
|
||||
[latitude, longitude],
|
||||
);
|
||||
|
||||
const mapStyles: MapStyles = {
|
||||
light: 'mapbox://styles/mapbox/light-v10',
|
||||
dark: 'mapbox://styles/mapbox/dark-v11',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<Map
|
||||
initialViewState={{ latitude, longitude, zoom: 17 }}
|
||||
style={{ width: '100%', height: isDesktop ? 480 : 240 }}
|
||||
mapStyle={mapStyles[theme]}
|
||||
mapboxAccessToken={token}
|
||||
scrollZoom
|
||||
>
|
||||
<ControlPanel />
|
||||
{pin}
|
||||
</Map>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BreweryPostMap;
|
||||
64
src/Website-v1/src/components/BreweryIndex/BreweryCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
import useGetBreweryPostLikeCount from '@/hooks/data-fetching/brewery-likes/useGetBreweryPostLikeCount';
|
||||
import BreweryPostQueryResult from '@/services/posts/brewery-post/schema/BreweryPostQueryResult';
|
||||
import { FC, useContext } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CldImage } from 'next-cloudinary';
|
||||
import BreweryPostLikeButton from './BreweryPostLikeButton';
|
||||
|
||||
const BreweryCard: FC<{ brewery: z.infer<typeof BreweryPostQueryResult> }> = ({
|
||||
brewery,
|
||||
}) => {
|
||||
const { user } = useContext(UserContext);
|
||||
const { likeCount, mutate, isLoading } = useGetBreweryPostLikeCount(brewery.id);
|
||||
return (
|
||||
<div className="card" key={brewery.id}>
|
||||
<figure className="card-image h-96">
|
||||
<Link href={`/breweries/${brewery.id}`} className="h-full object-cover">
|
||||
{brewery.breweryImages.length > 0 && (
|
||||
<CldImage
|
||||
src={brewery.breweryImages[0].path}
|
||||
alt={brewery.name}
|
||||
width="1029"
|
||||
height="1029"
|
||||
crop="fill"
|
||||
className="h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</figure>
|
||||
<div className="card-body justify-between">
|
||||
<div>
|
||||
<Link href={`/breweries/${brewery.id}`} className="link-hover link">
|
||||
<span className="text-lg font-bold lg:text-xl xl:truncate">
|
||||
{brewery.name}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex w-full items-end justify-between">
|
||||
<div className="w-9/12">
|
||||
<h3 className="text-lg font-semibold lg:text-xl xl:truncate">
|
||||
{brewery.location.city},{' '}
|
||||
{brewery.location.stateOrProvince || brewery.location.country}
|
||||
</h3>
|
||||
<h4 className="text-lg font-semibold lg:text-xl">
|
||||
est. {brewery.dateEstablished.getFullYear()}
|
||||
</h4>
|
||||
<div className="mt-2">
|
||||
{!isLoading && <span>liked by {likeCount} users</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{!!user && !isLoading && (
|
||||
<BreweryPostLikeButton breweryPostId={brewery.id} mutateCount={mutate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BreweryCard;
|
||||
@@ -0,0 +1,30 @@
|
||||
import useCheckIfUserLikesBreweryPost from '@/hooks/data-fetching/brewery-likes/useCheckIfUserLikesBreweryPost';
|
||||
import useGetBreweryPostLikeCount from '@/hooks/data-fetching/brewery-likes/useGetBreweryPostLikeCount';
|
||||
import sendBreweryPostLikeRequest from '@/requests/likes/brewery-post-like/sendBreweryPostLikeRequest';
|
||||
import { FC, useState } from 'react';
|
||||
import LikeButton from '../ui/LikeButton';
|
||||
|
||||
const BreweryPostLikeButton: FC<{
|
||||
breweryPostId: string;
|
||||
mutateCount: ReturnType<typeof useGetBreweryPostLikeCount>['mutate'];
|
||||
}> = ({ breweryPostId, mutateCount }) => {
|
||||
const { isLiked, mutate: mutateLikeStatus } =
|
||||
useCheckIfUserLikesBreweryPost(breweryPostId);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleLike = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await sendBreweryPostLikeRequest(breweryPostId);
|
||||
await Promise.all([mutateCount(), mutateLikeStatus()]);
|
||||
setIsLoading(false);
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <LikeButton isLiked={!!isLiked} handleLike={handleLike} loading={isLoading} />;
|
||||
};
|
||||
|
||||
export default BreweryPostLikeButton;
|
||||
@@ -0,0 +1,285 @@
|
||||
import sendUploadBreweryImagesRequest from '@/requests/images/brewery-image/sendUploadBreweryImageRequest';
|
||||
|
||||
import CreateBreweryPostSchema from '@/services/posts/brewery-post/schema/CreateBreweryPostSchema';
|
||||
import UploadImageValidationSchema from '@/services/schema/ImageSchema/UploadImageValidationSchema';
|
||||
import createErrorToast from '@/util/createErrorToast';
|
||||
import { Tab } from '@headlessui/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AddressAutofillRetrieveResponse } from '@mapbox/search-js-core';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FC, Fragment } from 'react';
|
||||
|
||||
import {
|
||||
useForm,
|
||||
SubmitHandler,
|
||||
FieldError,
|
||||
UseFormRegister,
|
||||
FieldErrors,
|
||||
UseFormSetValue,
|
||||
} from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { z } from 'zod';
|
||||
import FormError from '../ui/forms/FormError';
|
||||
import FormInfo from '../ui/forms/FormInfo';
|
||||
import FormLabel from '../ui/forms/FormLabel';
|
||||
import FormSegment from '../ui/forms/FormSegment';
|
||||
import FormTextArea from '../ui/forms/FormTextArea';
|
||||
import FormTextInput from '../ui/forms/FormTextInput';
|
||||
import Button from '../ui/forms/Button';
|
||||
import { sendCreateBreweryPostRequest } from '@/requests/posts/brewery-post';
|
||||
|
||||
const AddressAutofill = dynamic(
|
||||
// @ts-expect-error
|
||||
() => import('@mapbox/search-js-react').then((mod) => mod.AddressAutofill),
|
||||
{ ssr: false },
|
||||
);
|
||||
const CreateBreweryPostWithImagesSchema = CreateBreweryPostSchema.merge(
|
||||
UploadImageValidationSchema,
|
||||
);
|
||||
|
||||
const InfoSection: FC<{
|
||||
register: UseFormRegister<z.infer<typeof CreateBreweryPostWithImagesSchema>>;
|
||||
errors: FieldErrors<z.infer<typeof CreateBreweryPostWithImagesSchema>>;
|
||||
isSubmitting: boolean;
|
||||
}> = ({ register, errors, isSubmitting }) => {
|
||||
return (
|
||||
<>
|
||||
<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 file-input-bordered w-full"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormSegment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LocationSection: FC<{
|
||||
register: UseFormRegister<z.infer<typeof CreateBreweryPostWithImagesSchema>>;
|
||||
errors: FieldErrors<z.infer<typeof CreateBreweryPostWithImagesSchema>>;
|
||||
isSubmitting: boolean;
|
||||
setValue: UseFormSetValue<z.infer<typeof CreateBreweryPostWithImagesSchema>>;
|
||||
mapboxAccessToken: string;
|
||||
}> = ({ register, errors, isSubmitting, setValue, mapboxAccessToken }) => {
|
||||
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 (
|
||||
<>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="address">Address</FormLabel>
|
||||
<FormError>{errors.address?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<AddressAutofill
|
||||
accessToken={mapboxAccessToken}
|
||||
onRetrieve={onAutoCompleteRetrieve}
|
||||
onChange={onAutoCompleteChange}
|
||||
>
|
||||
<input
|
||||
id="address"
|
||||
type="text"
|
||||
placeholder="1234 Main St"
|
||||
className={`input input-bordered 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateBreweryPostForm: FC<{
|
||||
mapboxAccessToken: string;
|
||||
}> = ({ mapboxAccessToken }) => {
|
||||
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({ body: 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);
|
||||
createErrorToast(error);
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit, (error) => {
|
||||
const fieldErrors = Object.keys(error).length;
|
||||
|
||||
toast.error(`Form submission failed.`);
|
||||
toast.error(`You have ${fieldErrors} errors in your form.`);
|
||||
})}
|
||||
className="form-control"
|
||||
autoComplete="off"
|
||||
>
|
||||
<Tab.Group as={Fragment}>
|
||||
<Tab.List className="tabs-boxed tabs grid grid-cols-2">
|
||||
<Tab className="tab uppercase ui-selected:tab-active">Information</Tab>
|
||||
<Tab className="tab uppercase ui-selected:tab-active">Location</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels className="mt-4">
|
||||
<Tab.Panel>
|
||||
<InfoSection
|
||||
register={register}
|
||||
errors={errors}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<LocationSection
|
||||
setValue={setValue}
|
||||
register={register}
|
||||
errors={errors}
|
||||
isSubmitting={isSubmitting}
|
||||
mapboxAccessToken={mapboxAccessToken}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
<div className="mt-8">
|
||||
<Button type="submit" isSubmitting={isSubmitting}>
|
||||
Create Brewery Post
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateBreweryPostForm;
|
||||
54
src/Website-v1/src/components/Comments/CommentCardBody.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
|
||||
import CommentQueryResult from '@/services/schema/CommentSchema/CommentQueryResult';
|
||||
import { FC, useState } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { z } from 'zod';
|
||||
|
||||
import CommentContentBody from './CommentContentBody';
|
||||
import EditCommentBody from './EditCommentBody';
|
||||
import UserAvatar from '../Account/UserAvatar';
|
||||
import { HandleDeleteCommentRequest, HandleEditCommentRequest } from './types';
|
||||
|
||||
interface CommentCardProps {
|
||||
comment: z.infer<typeof CommentQueryResult>;
|
||||
mutate: ReturnType<typeof useBeerPostComments>['mutate'];
|
||||
ref?: ReturnType<typeof useInView>['ref'];
|
||||
handleDeleteCommentRequest: HandleDeleteCommentRequest;
|
||||
handleEditCommentRequest: HandleEditCommentRequest;
|
||||
}
|
||||
|
||||
const CommentCardBody: FC<CommentCardProps> = ({
|
||||
comment,
|
||||
mutate,
|
||||
ref,
|
||||
handleDeleteCommentRequest,
|
||||
handleEditCommentRequest,
|
||||
}) => {
|
||||
const [inEditMode, setInEditMode] = useState(false);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex items-start">
|
||||
<div className="mx-3 w-[20%] justify-center sm:w-[12%]">
|
||||
<div className="h-20 pt-4">
|
||||
<UserAvatar user={comment.postedBy} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-full w-[88%]">
|
||||
{!inEditMode ? (
|
||||
<CommentContentBody comment={comment} setInEditMode={setInEditMode} />
|
||||
) : (
|
||||
<EditCommentBody
|
||||
comment={comment}
|
||||
mutate={mutate}
|
||||
setInEditMode={setInEditMode}
|
||||
handleDeleteCommentRequest={handleDeleteCommentRequest}
|
||||
handleEditCommentRequest={handleEditCommentRequest}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentCardBody;
|
||||
@@ -0,0 +1,55 @@
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
import { Dispatch, SetStateAction, FC, useContext } from 'react';
|
||||
import { FaEllipsisH } from 'react-icons/fa';
|
||||
import CommentQueryResult from '@/services/schema/CommentSchema/CommentQueryResult';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface CommentCardDropdownProps {
|
||||
comment: z.infer<typeof CommentQueryResult>;
|
||||
setInEditMode: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const CommentCardDropdown: FC<CommentCardDropdownProps> = ({
|
||||
comment,
|
||||
setInEditMode,
|
||||
}) => {
|
||||
const { user } = useContext(UserContext);
|
||||
const isCommentOwner = user?.id === comment.postedBy.id;
|
||||
|
||||
return (
|
||||
<div className="dropdown dropdown-end">
|
||||
<label tabIndex={0} className="btn btn-ghost btn-sm m-1">
|
||||
<FaEllipsisH />
|
||||
</label>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="menu dropdown-content rounded-box w-52 bg-base-100 p-2 shadow"
|
||||
>
|
||||
<li>
|
||||
{isCommentOwner ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setInEditMode(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert('This feature is not yet implemented.');
|
||||
}}
|
||||
>
|
||||
Report
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentCardDropdown;
|
||||
@@ -0,0 +1,77 @@
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
import useTimeDistance from '@/hooks/utilities/useTimeDistance';
|
||||
import { format } from 'date-fns';
|
||||
import { Dispatch, FC, SetStateAction, useContext } from 'react';
|
||||
import { Rating } from 'react-daisyui';
|
||||
import Link from 'next/link';
|
||||
import CommentQueryResult from '@/services/schema/CommentSchema/CommentQueryResult';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import classNames from 'classnames';
|
||||
import CommentCardDropdown from './CommentCardDropdown';
|
||||
|
||||
interface CommentContentBodyProps {
|
||||
comment: z.infer<typeof CommentQueryResult>;
|
||||
setInEditMode: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const CommentContentBody: FC<CommentContentBodyProps> = ({ comment, setInEditMode }) => {
|
||||
const { user } = useContext(UserContext);
|
||||
const timeDistance = useTimeDistance(new Date(comment.createdAt));
|
||||
const [ref, inView] = useInView({ triggerOnce: true });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('space-y-1 py-4 pr-3 fade-in-10', {
|
||||
'opacity-0': !inView,
|
||||
'animate-fade': inView,
|
||||
})}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-row justify-between">
|
||||
<div>
|
||||
<p className="font-semibold sm:text-2xl">
|
||||
<Link href={`/users/${comment.postedBy.id}`} className="link-hover link">
|
||||
{comment.postedBy.username}
|
||||
</Link>
|
||||
</p>
|
||||
<span className="italic">
|
||||
posted{' '}
|
||||
<time
|
||||
className="tooltip tooltip-bottom"
|
||||
data-tip={format(new Date(comment.createdAt), 'MM/dd/yyyy')}
|
||||
>
|
||||
{timeDistance}
|
||||
</time>{' '}
|
||||
ago
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<CommentCardDropdown comment={comment} setInEditMode={setInEditMode} />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Rating value={comment.rating}>
|
||||
{Array.from({ length: 5 }).map((val, index) => (
|
||||
<Rating.Item
|
||||
name="rating-1"
|
||||
className="mask mask-star cursor-default"
|
||||
disabled
|
||||
aria-disabled
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
</Rating>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm">{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentContentBody;
|
||||
85
src/Website-v1/src/components/Comments/CommentForm.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { FC } from 'react';
|
||||
import { Rating } from 'react-daisyui';
|
||||
import type {
|
||||
FormState,
|
||||
SubmitHandler,
|
||||
UseFormHandleSubmit,
|
||||
UseFormRegister,
|
||||
UseFormSetValue,
|
||||
UseFormWatch,
|
||||
} from 'react-hook-form';
|
||||
import FormError from '../ui/forms/FormError';
|
||||
import FormInfo from '../ui/forms/FormInfo';
|
||||
import FormLabel from '../ui/forms/FormLabel';
|
||||
import FormSegment from '../ui/forms/FormSegment';
|
||||
import FormTextArea from '../ui/forms/FormTextArea';
|
||||
import Button from '../ui/forms/Button';
|
||||
|
||||
interface Comment {
|
||||
content: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
interface CommentFormProps {
|
||||
handleSubmit: UseFormHandleSubmit<Comment>;
|
||||
onSubmit: SubmitHandler<Comment>;
|
||||
watch: UseFormWatch<Comment>;
|
||||
setValue: UseFormSetValue<Comment>;
|
||||
formState: FormState<Comment>;
|
||||
register: UseFormRegister<Comment>;
|
||||
}
|
||||
|
||||
const CommentForm: FC<CommentFormProps> = ({
|
||||
handleSubmit,
|
||||
onSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState,
|
||||
register,
|
||||
}) => {
|
||||
const { errors } = formState;
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
<div>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="content">Leave a comment</FormLabel>
|
||||
<FormError>{errors.content?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextArea
|
||||
id="content"
|
||||
formValidationSchema={register('content')}
|
||||
placeholder="Comment"
|
||||
rows={5}
|
||||
error={!!errors.content?.message}
|
||||
disabled={formState.isSubmitting}
|
||||
/>
|
||||
</FormSegment>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="rating">Rating</FormLabel>
|
||||
<FormError>{errors.rating?.message}</FormError>
|
||||
</FormInfo>
|
||||
<Rating
|
||||
value={watch('rating')}
|
||||
onChange={(value) => {
|
||||
setValue('rating', value);
|
||||
}}
|
||||
>
|
||||
<Rating.Item name="rating-1" className="mask mask-star" />
|
||||
<Rating.Item name="rating-1" className="mask mask-star" />
|
||||
<Rating.Item name="rating-1" className="mask mask-star" />
|
||||
<Rating.Item name="rating-1" className="mask mask-star" />
|
||||
<Rating.Item name="rating-1" className="mask mask-star" />
|
||||
</Rating>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="submit" isSubmitting={formState.isSubmitting}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentForm;
|
||||
@@ -0,0 +1,19 @@
|
||||
const CommentLoadingCardBody = () => {
|
||||
return (
|
||||
<div className="card-body h-52 fade-in-10">
|
||||
<div className="flex animate-pulse space-x-4 slide-in-from-top">
|
||||
<div className="flex-1 space-y-4 py-1">
|
||||
<div className="h-4 w-3/4 rounded bg-base-100" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 rounded bg-base-100" />
|
||||
<div className="h-4 w-11/12 rounded bg-base-100" />
|
||||
<div className="h-4 w-10/12 rounded bg-base-100" />
|
||||
<div className="h-4 w-11/12 rounded bg-base-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentLoadingCardBody;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { FC } from 'react';
|
||||
import Spinner from '../ui/Spinner';
|
||||
import CommentLoadingCardBody from './CommentLoadingCardBody';
|
||||
|
||||
interface CommentLoadingComponentProps {
|
||||
length: number;
|
||||
}
|
||||
|
||||
const CommentLoadingComponent: FC<CommentLoadingComponentProps> = ({ length }) => {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length }).map((_, i) => (
|
||||
<CommentLoadingCardBody key={i} />
|
||||
))}
|
||||
<div className="p-1">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentLoadingComponent;
|
||||
125
src/Website-v1/src/components/Comments/CommentsComponent.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { FC, MutableRefObject } from 'react';
|
||||
import { FaArrowUp } from 'react-icons/fa';
|
||||
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
|
||||
|
||||
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
|
||||
import useBeerStyleComments from '@/hooks/data-fetching/beer-style-comments/useBeerStyleComments';
|
||||
import NoCommentsCard from './NoCommentsCard';
|
||||
import CommentLoadingComponent from './CommentLoadingComponent';
|
||||
import CommentCardBody from './CommentCardBody';
|
||||
import { HandleDeleteCommentRequest, HandleEditCommentRequest } from './types';
|
||||
|
||||
type HookReturnType = ReturnType<
|
||||
typeof useBeerPostComments | typeof useBreweryPostComments | typeof useBeerStyleComments
|
||||
>;
|
||||
|
||||
interface CommentsComponentProps {
|
||||
comments: HookReturnType['comments'];
|
||||
isAtEnd: HookReturnType['isAtEnd'];
|
||||
isLoadingMore: HookReturnType['isLoadingMore'];
|
||||
mutate: HookReturnType['mutate'];
|
||||
setSize: HookReturnType['setSize'];
|
||||
size: HookReturnType['size'];
|
||||
commentSectionRef: MutableRefObject<HTMLDivElement | null>;
|
||||
handleDeleteCommentRequest: HandleDeleteCommentRequest;
|
||||
handleEditCommentRequest: HandleEditCommentRequest;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const CommentsComponent: FC<CommentsComponentProps> = ({
|
||||
comments,
|
||||
commentSectionRef,
|
||||
handleDeleteCommentRequest,
|
||||
handleEditCommentRequest,
|
||||
isAtEnd,
|
||||
isLoadingMore,
|
||||
mutate,
|
||||
pageSize,
|
||||
setSize,
|
||||
size,
|
||||
}) => {
|
||||
const { ref: penultimateCommentRef } = useInView({
|
||||
threshold: 0.1,
|
||||
/**
|
||||
* When the last comment comes into view, call setSize from the comment fetching hook
|
||||
* to load more comments.
|
||||
*/
|
||||
onChange: (visible) => {
|
||||
if (!visible || isAtEnd) return;
|
||||
setSize(size + 1);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!comments.length && (
|
||||
<div className="card h-full bg-base-300 pb-6">
|
||||
{comments.map((comment, index) => {
|
||||
const isLastComment = index === comments.length - 1;
|
||||
|
||||
/**
|
||||
* Attach a ref to the last comment in the list. When it comes into view, the
|
||||
* component will call setSize to load more comments.
|
||||
*/
|
||||
return (
|
||||
<div
|
||||
ref={isLastComment ? penultimateCommentRef : undefined}
|
||||
key={comment.id}
|
||||
>
|
||||
<CommentCardBody
|
||||
comment={comment}
|
||||
mutate={mutate}
|
||||
handleDeleteCommentRequest={handleDeleteCommentRequest}
|
||||
handleEditCommentRequest={handleEditCommentRequest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{
|
||||
/**
|
||||
* If there are more comments to load, show a loading component with a
|
||||
* skeleton loader and a loading spinner.
|
||||
*/
|
||||
!!isLoadingMore && <CommentLoadingComponent length={pageSize} />
|
||||
}
|
||||
|
||||
{
|
||||
/**
|
||||
* If the user has scrolled to the end of the comments, show a button that
|
||||
* will scroll them back to the top of the comments section.
|
||||
*/
|
||||
!!isAtEnd && (
|
||||
<div className="flex h-20 items-center justify-center text-center">
|
||||
<div
|
||||
className="tooltip tooltip-bottom"
|
||||
data-tip="Scroll back to top of comments."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
aria-label="Scroll back to top of comments"
|
||||
onClick={() => {
|
||||
commentSectionRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FaArrowUp />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!comments.length && <NoCommentsCard />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentsComponent;
|
||||
168
src/Website-v1/src/components/Comments/EditCommentBody.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FC, useState, Dispatch, SetStateAction } from 'react';
|
||||
import { Rating } from 'react-daisyui';
|
||||
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
|
||||
import CommentQueryResult from '@/services/schema/CommentSchema/CommentQueryResult';
|
||||
import CreateCommentValidationSchema from '@/services/schema/CommentSchema/CreateCommentValidationSchema';
|
||||
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
|
||||
import toast from 'react-hot-toast';
|
||||
import createErrorToast from '@/util/createErrorToast';
|
||||
import FormError from '../ui/forms/FormError';
|
||||
import FormInfo from '../ui/forms/FormInfo';
|
||||
import FormLabel from '../ui/forms/FormLabel';
|
||||
import FormSegment from '../ui/forms/FormSegment';
|
||||
import FormTextArea from '../ui/forms/FormTextArea';
|
||||
import { HandleDeleteCommentRequest, HandleEditCommentRequest } from './types';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface EditCommentBodyProps {
|
||||
comment: z.infer<typeof CommentQueryResult>;
|
||||
setInEditMode: Dispatch<SetStateAction<boolean>>;
|
||||
|
||||
mutate: ReturnType<
|
||||
typeof useBeerPostComments | typeof useBreweryPostComments
|
||||
>['mutate'];
|
||||
handleDeleteCommentRequest: HandleDeleteCommentRequest;
|
||||
handleEditCommentRequest: HandleEditCommentRequest;
|
||||
}
|
||||
|
||||
const EditCommentBody: FC<EditCommentBodyProps> = ({
|
||||
comment,
|
||||
setInEditMode,
|
||||
mutate,
|
||||
handleDeleteCommentRequest,
|
||||
handleEditCommentRequest,
|
||||
}) => {
|
||||
const { register, handleSubmit, formState, setValue, watch } = useForm<
|
||||
z.infer<typeof CreateCommentValidationSchema>
|
||||
>({
|
||||
defaultValues: { content: comment.content, rating: comment.rating },
|
||||
resolver: zodResolver(CreateCommentValidationSchema),
|
||||
});
|
||||
|
||||
const { errors, isSubmitting } = formState;
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const onDelete = async () => {
|
||||
const loadingToast = toast.loading('Deleting comment...');
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await handleDeleteCommentRequest(comment.id);
|
||||
await mutate();
|
||||
toast.remove(loadingToast);
|
||||
toast.success('Deleted comment.');
|
||||
} catch (error) {
|
||||
toast.remove(loadingToast);
|
||||
createErrorToast(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onEdit: SubmitHandler<z.infer<typeof CreateCommentValidationSchema>> = async (
|
||||
data,
|
||||
) => {
|
||||
const loadingToast = toast.loading('Submitting comment edits...');
|
||||
|
||||
try {
|
||||
setInEditMode(true);
|
||||
await handleEditCommentRequest(comment.id, data);
|
||||
await mutate();
|
||||
toast.remove(loadingToast);
|
||||
toast.success('Comment edits submitted successfully.');
|
||||
setInEditMode(false);
|
||||
} catch (error) {
|
||||
toast.remove(loadingToast);
|
||||
createErrorToast(error);
|
||||
setInEditMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disableForm = isSubmitting || isDeleting;
|
||||
|
||||
const [ref, inView] = useInView({ triggerOnce: true });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('py-4 pr-3 animate-in fade-in-10', {
|
||||
'opacity-0': !inView,
|
||||
'animate-fade': inView,
|
||||
})}
|
||||
ref={ref}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onEdit)} className="space-y-3">
|
||||
<div>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="content">Edit your comment</FormLabel>
|
||||
<FormError>{errors.content?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextArea
|
||||
id="content"
|
||||
formValidationSchema={register('content')}
|
||||
placeholder="Comment"
|
||||
rows={2}
|
||||
error={!!errors.content?.message}
|
||||
disabled={disableForm}
|
||||
/>
|
||||
</FormSegment>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="rating">Change your rating</FormLabel>
|
||||
<FormError>{errors.rating?.message}</FormError>
|
||||
</FormInfo>
|
||||
<Rating
|
||||
value={watch('rating')}
|
||||
onChange={(value) => {
|
||||
setValue('rating', value);
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 5 }).map((val, index) => (
|
||||
<Rating.Item
|
||||
name="rating-1"
|
||||
className="mask mask-star cursor-default"
|
||||
disabled={disableForm}
|
||||
aria-disabled={disableForm}
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
</Rating>
|
||||
</div>
|
||||
<div className="join">
|
||||
<button
|
||||
type="button"
|
||||
className="btn join-item btn-xs lg:btn-sm"
|
||||
disabled={disableForm}
|
||||
onClick={() => {
|
||||
setInEditMode(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disableForm}
|
||||
className="btn join-item btn-xs lg:btn-sm"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn join-item btn-xs lg:btn-sm"
|
||||
onClick={onDelete}
|
||||
disabled={disableForm}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditCommentBody;
|
||||
13
src/Website-v1/src/components/Comments/NoCommentsCard.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
const NoCommentsCard = () => {
|
||||
return (
|
||||
<div className="card bg-base-300">
|
||||
<div className="card-body h-64">
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<span className="text-lg font-bold">No comments yet.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoCommentsCard;
|
||||
12
src/Website-v1/src/components/Comments/types/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import CreateCommentValidationSchema from '@/services/schema/CommentSchema/CreateCommentValidationSchema';
|
||||
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
|
||||
import { z } from 'zod';
|
||||
|
||||
type APIResponse = z.infer<typeof APIResponseValidationSchema>;
|
||||
|
||||
export type HandleEditCommentRequest = (
|
||||
id: string,
|
||||
data: z.infer<typeof CreateCommentValidationSchema>,
|
||||
) => Promise<APIResponse>;
|
||||
|
||||
export type HandleDeleteCommentRequest = (id: string) => Promise<APIResponse>;
|
||||
182
src/Website-v1/src/components/CreateBeerPostForm.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import router from 'next/router';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { BeerStyle } from '@prisma/client';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useForm, SubmitHandler, FieldError } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import BreweryPostQueryResult from '@/services/posts/brewery-post/schema/BreweryPostQueryResult';
|
||||
import CreateBeerPostValidationSchema from '@/services/posts/beer-post/schema/CreateBeerPostValidationSchema';
|
||||
import UploadImageValidationSchema from '@/services/schema/ImageSchema/UploadImageValidationSchema';
|
||||
|
||||
import createErrorToast from '@/util/createErrorToast';
|
||||
|
||||
import { sendCreateBeerPostRequest } from '@/requests/posts/beer-post';
|
||||
import sendUploadBeerImagesRequest from '@/requests/images/beer-image/sendUploadBeerImageRequest';
|
||||
|
||||
import Button from './ui/forms/Button';
|
||||
import FormError from './ui/forms/FormError';
|
||||
import FormInfo from './ui/forms/FormInfo';
|
||||
import FormLabel from './ui/forms/FormLabel';
|
||||
import FormSegment from './ui/forms/FormSegment';
|
||||
import FormSelect from './ui/forms/FormSelect';
|
||||
import FormTextArea from './ui/forms/FormTextArea';
|
||||
import FormTextInput from './ui/forms/FormTextInput';
|
||||
|
||||
interface BeerFormProps {
|
||||
brewery: z.infer<typeof BreweryPostQueryResult>;
|
||||
styles: BeerStyle[];
|
||||
}
|
||||
|
||||
const CreateBeerPostWithImagesValidationSchema = CreateBeerPostValidationSchema.merge(
|
||||
UploadImageValidationSchema,
|
||||
);
|
||||
|
||||
const CreateBeerPostForm: FunctionComponent<BeerFormProps> = ({
|
||||
styles = [],
|
||||
brewery,
|
||||
}) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<z.infer<typeof CreateBeerPostWithImagesValidationSchema>>({
|
||||
resolver: zodResolver(CreateBeerPostWithImagesValidationSchema),
|
||||
defaultValues: { breweryId: brewery.id },
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<
|
||||
z.infer<typeof CreateBeerPostWithImagesValidationSchema>
|
||||
> = async (data) => {
|
||||
if (!(data.images instanceof FileList)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const loadingToast = toast.loading('Creating beer post...');
|
||||
const beerPost = await sendCreateBeerPostRequest({
|
||||
body: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
abv: data.abv,
|
||||
ibu: data.ibu,
|
||||
},
|
||||
breweryId: data.breweryId,
|
||||
styleId: data.styleId,
|
||||
});
|
||||
await sendUploadBeerImagesRequest({ beerPost, images: data.images });
|
||||
await router.push(`/beers/${beerPost.id}`);
|
||||
toast.dismiss(loadingToast);
|
||||
toast.success('Created beer post.');
|
||||
} catch (e) {
|
||||
createErrorToast(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="form-control" onSubmit={handleSubmit(onSubmit)}>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="name">Name</FormLabel>
|
||||
<FormError>{errors.name?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextInput
|
||||
placeholder="Lorem Ipsum Lager"
|
||||
formValidationSchema={register('name')}
|
||||
error={!!errors.name}
|
||||
type="text"
|
||||
id="name"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormSegment>
|
||||
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="typeId">Style</FormLabel>
|
||||
<FormError>{errors.styleId?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormSelect
|
||||
disabled={isSubmitting}
|
||||
formRegister={register('styleId')}
|
||||
error={!!errors.styleId}
|
||||
id="styleId"
|
||||
options={styles.map((style) => ({
|
||||
value: style.id,
|
||||
text: style.name,
|
||||
}))}
|
||||
placeholder="Beer style"
|
||||
message="Pick a beer style"
|
||||
/>
|
||||
</FormSegment>
|
||||
|
||||
<div className="flex flex-wrap md:mb-3">
|
||||
<div className="mb-2 w-full md:mb-0 md:w-1/2 md:pr-3">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="abv">ABV</FormLabel>
|
||||
<FormError>{errors.abv?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormTextInput
|
||||
disabled={isSubmitting}
|
||||
placeholder="12"
|
||||
formValidationSchema={register('abv', { valueAsNumber: true })}
|
||||
error={!!errors.abv}
|
||||
type="text"
|
||||
id="abv"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2 w-full md:mb-0 md:w-1/2 md:pl-3">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="ibu">IBU</FormLabel>
|
||||
<FormError>{errors.ibu?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormTextInput
|
||||
disabled={isSubmitting}
|
||||
placeholder="52"
|
||||
formValidationSchema={register('ibu', { valueAsNumber: true })}
|
||||
error={!!errors.ibu}
|
||||
type="text"
|
||||
id="lastName"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="description">Description</FormLabel>
|
||||
<FormError>{errors.description?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextArea
|
||||
disabled={isSubmitting}
|
||||
placeholder="Ratione cumque quas quia aut impedit ea culpa facere. Ut in sit et quas reiciendis itaque."
|
||||
error={!!errors.description}
|
||||
formValidationSchema={register('description')}
|
||||
id="description"
|
||||
rows={8}
|
||||
/>
|
||||
</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 file-input-bordered w-full"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormSegment>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="submit" isSubmitting={isSubmitting}>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateBeerPostForm;
|
||||
150
src/Website-v1/src/components/EditBeerPostForm.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import toast from 'react-hot-toast';
|
||||
import { z } from 'zod';
|
||||
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
import EditBeerPostValidationSchema from '@/services/posts/beer-post/schema/EditBeerPostValidationSchema';
|
||||
|
||||
import createErrorToast from '@/util/createErrorToast';
|
||||
import {
|
||||
sendEditBeerPostRequest,
|
||||
sendDeleteBeerPostRequest,
|
||||
} from '@/requests/posts/beer-post';
|
||||
import Button from './ui/forms/Button';
|
||||
import FormError from './ui/forms/FormError';
|
||||
import FormInfo from './ui/forms/FormInfo';
|
||||
import FormLabel from './ui/forms/FormLabel';
|
||||
import FormSegment from './ui/forms/FormSegment';
|
||||
import FormTextArea from './ui/forms/FormTextArea';
|
||||
import FormTextInput from './ui/forms/FormTextInput';
|
||||
|
||||
type EditBeerPostSchema = z.infer<typeof EditBeerPostValidationSchema>;
|
||||
|
||||
interface EditBeerPostFormProps {
|
||||
previousValues: EditBeerPostSchema;
|
||||
}
|
||||
|
||||
const EditBeerPostForm: FC<EditBeerPostFormProps> = ({ previousValues }) => {
|
||||
const router = useRouter();
|
||||
const { register, handleSubmit, formState } = useForm<EditBeerPostSchema>({
|
||||
resolver: zodResolver(EditBeerPostValidationSchema),
|
||||
defaultValues: previousValues,
|
||||
});
|
||||
|
||||
const { isSubmitting, errors } = formState;
|
||||
const onSubmit: SubmitHandler<EditBeerPostSchema> = async (data) => {
|
||||
try {
|
||||
const loadingToast = toast.loading('Editing beer post...');
|
||||
await sendEditBeerPostRequest({
|
||||
beerPostId: data.id,
|
||||
body: {
|
||||
name: data.name,
|
||||
abv: data.abv,
|
||||
ibu: data.ibu,
|
||||
description: data.description,
|
||||
},
|
||||
});
|
||||
await router.push(`/beers/${data.id}`);
|
||||
toast.success('Edited beer post.');
|
||||
toast.dismiss(loadingToast);
|
||||
} catch (e) {
|
||||
createErrorToast(e);
|
||||
await router.push(`/beers/${data.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
try {
|
||||
const loadingToast = toast.loading('Deleting beer post...');
|
||||
await sendDeleteBeerPostRequest({ beerPostId: previousValues.id });
|
||||
toast.dismiss(loadingToast);
|
||||
await router.push('/beers');
|
||||
toast.success('Deleted beer post.');
|
||||
} catch (e) {
|
||||
createErrorToast(e);
|
||||
await router.push(`/beers`);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form className="form-control" onSubmit={handleSubmit(onSubmit)}>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="name">Name</FormLabel>
|
||||
<FormError>{errors.name?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextInput
|
||||
placeholder="Lorem Ipsum Lager"
|
||||
formValidationSchema={register('name')}
|
||||
error={!!errors.name}
|
||||
type="text"
|
||||
id="name"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormSegment>
|
||||
|
||||
<div className="flex flex-wrap sm:text-xs md:mb-3">
|
||||
<div className="mb-2 w-full md:mb-0 md:w-1/2 md:pr-3">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="abv">ABV</FormLabel>
|
||||
<FormError>{errors.abv?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormTextInput
|
||||
disabled={isSubmitting}
|
||||
placeholder="12"
|
||||
formValidationSchema={register('abv', { valueAsNumber: true })}
|
||||
error={!!errors.abv}
|
||||
type="text"
|
||||
id="abv"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2 w-full md:mb-0 md:w-1/2 md:pl-3">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="ibu">IBU</FormLabel>
|
||||
<FormError>{errors.ibu?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormTextInput
|
||||
disabled={isSubmitting}
|
||||
placeholder="52"
|
||||
formValidationSchema={register('ibu', { valueAsNumber: true })}
|
||||
error={!!errors.ibu}
|
||||
type="text"
|
||||
id="lastName"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="description">Description</FormLabel>
|
||||
<FormError>{errors.description?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextArea
|
||||
disabled={isSubmitting}
|
||||
placeholder="Ratione cumque quas quia aut impedit ea culpa facere. Ut in sit et quas reiciendis itaque."
|
||||
error={!!errors.description}
|
||||
formValidationSchema={register('description')}
|
||||
id="description"
|
||||
rows={8}
|
||||
/>
|
||||
</FormSegment>
|
||||
|
||||
<div className="mt-2 space-y-4">
|
||||
<Button type="submit" isSubmitting={isSubmitting}>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</Button>
|
||||
<button
|
||||
className={`btn btn-primary w-full rounded-xl ${isSubmitting ? 'loading' : ''}`}
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditBeerPostForm;
|
||||
106
src/Website-v1/src/components/EditBreweryPostForm.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import EditBreweryPostValidationSchema from '@/services/posts/brewery-post/schema/EditBreweryPostValidationSchema';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { FC, useState } from 'react';
|
||||
import BreweryPostQueryResult from '@/services/posts/brewery-post/schema/BreweryPostQueryResult';
|
||||
import {
|
||||
sendDeleteBreweryPostRequest,
|
||||
sendEditBreweryPostRequest,
|
||||
} from '@/requests/posts/brewery-post';
|
||||
import FormError from './ui/forms/FormError';
|
||||
import FormInfo from './ui/forms/FormInfo';
|
||||
import FormLabel from './ui/forms/FormLabel';
|
||||
import FormSegment from './ui/forms/FormSegment';
|
||||
import FormTextArea from './ui/forms/FormTextArea';
|
||||
import FormTextInput from './ui/forms/FormTextInput';
|
||||
|
||||
interface EditBreweryPostFormProps {
|
||||
breweryPost: z.infer<typeof BreweryPostQueryResult>;
|
||||
}
|
||||
const EditBreweryPostForm: FC<EditBreweryPostFormProps> = ({ breweryPost }) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<z.infer<typeof EditBreweryPostValidationSchema>>({
|
||||
resolver: zodResolver(EditBreweryPostValidationSchema),
|
||||
defaultValues: {
|
||||
name: breweryPost.name,
|
||||
description: breweryPost.description,
|
||||
id: breweryPost.id,
|
||||
dateEstablished: breweryPost.dateEstablished,
|
||||
},
|
||||
});
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof EditBreweryPostValidationSchema>) => {
|
||||
await sendEditBreweryPostRequest({ breweryPostId: breweryPost.id, body: data });
|
||||
await router.push(`/breweries/${breweryPost.id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
await sendDeleteBreweryPostRequest({ breweryPostId: breweryPost.id });
|
||||
await router.push('/breweries');
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="form-control space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="w-full">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="name">Name</FormLabel>
|
||||
<FormError>{errors.name?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextInput
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
formValidationSchema={register('name')}
|
||||
error={!!errors.name}
|
||||
disabled={isSubmitting || isDeleting}
|
||||
/>
|
||||
</FormSegment>
|
||||
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="description">Description</FormLabel>
|
||||
<FormError>{errors.description?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextArea
|
||||
disabled={isSubmitting || isDeleting}
|
||||
placeholder="Ratione cumque quas quia aut impedit ea culpa facere. Ut in sit et quas reiciendis itaque."
|
||||
error={!!errors.description}
|
||||
formValidationSchema={register('description')}
|
||||
id="description"
|
||||
rows={8}
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-3">
|
||||
<button
|
||||
disabled={isSubmitting || isDeleting}
|
||||
className="btn btn-primary w-full"
|
||||
type="submit"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary w-full"
|
||||
type="button"
|
||||
disabled={isSubmitting || isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete Brewery
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditBreweryPostForm;
|
||||
92
src/Website-v1/src/components/Login/LoginForm.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import LoginValidationSchema from '@/services/users/auth/schema/LoginValidationSchema';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useContext } from 'react';
|
||||
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import createErrorToast from '@/util/createErrorToast';
|
||||
import FormError from '../ui/forms/FormError';
|
||||
import FormInfo from '../ui/forms/FormInfo';
|
||||
import FormLabel from '../ui/forms/FormLabel';
|
||||
import FormSegment from '../ui/forms/FormSegment';
|
||||
import FormTextInput from '../ui/forms/FormTextInput';
|
||||
import Button from '../ui/forms/Button';
|
||||
import { sendLoginUserRequest } from '@/requests/users/auth';
|
||||
|
||||
type LoginT = z.infer<typeof LoginValidationSchema>;
|
||||
const LoginForm = () => {
|
||||
const router = useRouter();
|
||||
const { register, handleSubmit, formState, reset } = useForm<LoginT>({
|
||||
resolver: zodResolver(LoginValidationSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { errors } = formState;
|
||||
|
||||
const { mutate } = useContext(UserContext);
|
||||
|
||||
const onSubmit: SubmitHandler<LoginT> = async (data) => {
|
||||
const loadingToast = toast.loading('Logging in...');
|
||||
try {
|
||||
await sendLoginUserRequest(data);
|
||||
await mutate!();
|
||||
toast.remove(loadingToast);
|
||||
toast.success('Logged in!');
|
||||
await router.push(`/users/current`);
|
||||
} catch (error) {
|
||||
toast.remove(loadingToast);
|
||||
createErrorToast(error);
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="form-control space-y-5" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="username">username</FormLabel>
|
||||
<FormError>{errors.username?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextInput
|
||||
id="username"
|
||||
type="text"
|
||||
formValidationSchema={register('username')}
|
||||
disabled={formState.isSubmitting}
|
||||
error={!!errors.username}
|
||||
placeholder="username"
|
||||
/>
|
||||
</FormSegment>
|
||||
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="password">password</FormLabel>
|
||||
<FormError>{errors.password?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextInput
|
||||
disabled={formState.isSubmitting}
|
||||
id="password"
|
||||
type="password"
|
||||
formValidationSchema={register('password')}
|
||||
error={!!errors.password}
|
||||
placeholder="password"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<Button type="submit" isSubmitting={formState.isSubmitting}>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
180
src/Website-v1/src/components/RegisterUserForm.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { CreateUserValidationSchemaWithUsernameAndEmailCheck } from '@/services/users/auth/schema/CreateUserValidationSchemas';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FC } from 'react';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import createErrorToast from '@/util/createErrorToast';
|
||||
import toast from 'react-hot-toast';
|
||||
import { sendRegisterUserRequest } from '@/requests/users/auth';
|
||||
import Button from './ui/forms/Button';
|
||||
import FormError from './ui/forms/FormError';
|
||||
import FormInfo from './ui/forms/FormInfo';
|
||||
import FormLabel from './ui/forms/FormLabel';
|
||||
import FormSegment from './ui/forms/FormSegment';
|
||||
import FormTextInput from './ui/forms/FormTextInput';
|
||||
|
||||
const RegisterUserForm: FC = () => {
|
||||
const router = useRouter();
|
||||
const { reset, register, handleSubmit, formState } = useForm<
|
||||
z.infer<typeof CreateUserValidationSchemaWithUsernameAndEmailCheck>
|
||||
>({ resolver: zodResolver(CreateUserValidationSchemaWithUsernameAndEmailCheck) });
|
||||
|
||||
const { errors } = formState;
|
||||
|
||||
const onSubmit = async (
|
||||
data: z.infer<typeof CreateUserValidationSchemaWithUsernameAndEmailCheck>,
|
||||
) => {
|
||||
try {
|
||||
const loadingToast = toast.loading('Registering user...');
|
||||
await sendRegisterUserRequest(data);
|
||||
reset();
|
||||
router.push('/', undefined, { shallow: true });
|
||||
|
||||
toast.remove(loadingToast);
|
||||
|
||||
toast.success('User registered!');
|
||||
} catch (error) {
|
||||
createErrorToast({
|
||||
toast,
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form className="form-control w-full" noValidate onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="lg:space-y-5">
|
||||
<div className="flex flex-col lg:flex-row lg:space-x-5">
|
||||
<div className="lg:w-[50%]">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="firstName">First name</FormLabel>
|
||||
<FormError>{errors.firstName?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextInput
|
||||
disabled={formState.isSubmitting}
|
||||
id="firstName"
|
||||
type="text"
|
||||
formValidationSchema={register('firstName')}
|
||||
error={!!errors.firstName}
|
||||
placeholder="John"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
|
||||
<div className="lg:w-[50%]">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="lastName">Last name</FormLabel>
|
||||
<FormError>{errors.lastName?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextInput
|
||||
disabled={formState.isSubmitting}
|
||||
id="lastName"
|
||||
type="text"
|
||||
formValidationSchema={register('lastName')}
|
||||
error={!!errors.lastName}
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:space-x-5">
|
||||
<div className="lg:w-[50%]">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="email">email</FormLabel>
|
||||
<FormError>{errors.email?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextInput
|
||||
disabled={formState.isSubmitting}
|
||||
id="email"
|
||||
type="email"
|
||||
formValidationSchema={register('email')}
|
||||
error={!!errors.email}
|
||||
placeholder="john.doe@example.com"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
<div className="lg:w-[50%]">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="username">username</FormLabel>
|
||||
<FormError>{errors.username?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextInput
|
||||
disabled={formState.isSubmitting}
|
||||
id="username"
|
||||
type="text"
|
||||
formValidationSchema={register('username')}
|
||||
error={!!errors.username}
|
||||
placeholder="johndoe"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:space-x-5">
|
||||
<div className="lg:w-[50%]">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="password">password</FormLabel>
|
||||
<FormError>{errors.password?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextInput
|
||||
disabled={formState.isSubmitting}
|
||||
id="password"
|
||||
type="password"
|
||||
formValidationSchema={register('password')}
|
||||
error={!!errors.password}
|
||||
placeholder="password"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
<div className="lg:w-[50%]">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="confirmPassword">confirm password</FormLabel>
|
||||
<FormError>{errors.confirmPassword?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextInput
|
||||
disabled={formState.isSubmitting}
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
formValidationSchema={register('confirmPassword')}
|
||||
error={!!errors.confirmPassword}
|
||||
placeholder="confirm password"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="dateOfBirth">Date of birth</FormLabel>
|
||||
<FormError>{errors.dateOfBirth?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormTextInput
|
||||
id="dateOfBirth"
|
||||
disabled={formState.isSubmitting}
|
||||
type="date"
|
||||
formValidationSchema={register('dateOfBirth')}
|
||||
error={!!errors.dateOfBirth}
|
||||
placeholder="date of birth"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
<Button type="submit" isSubmitting={formState.isSubmitting}>
|
||||
Register User
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterUserForm;
|
||||
67
src/Website-v1/src/components/UserPage/UserFollowButton.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import useFollowStatus from '@/hooks/data-fetching/user-follows/useFollowStatus';
|
||||
import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser';
|
||||
import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser';
|
||||
import { sendUserFollowRequest } from '@/requests/users/auth';
|
||||
|
||||
import GetUserSchema from '@/services/users/auth/schema/GetUserSchema';
|
||||
import { FC, useState } from 'react';
|
||||
import { FaUserCheck, FaUserPlus } from 'react-icons/fa';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface UserFollowButtonProps {
|
||||
mutateFollowerCount: ReturnType<typeof useGetUsersFollowingUser>['mutate'];
|
||||
mutateFollowingCount: ReturnType<typeof useGetUsersFollowedByUser>['mutate'];
|
||||
user: z.infer<typeof GetUserSchema>;
|
||||
}
|
||||
|
||||
const UserFollowButton: FC<UserFollowButtonProps> = ({
|
||||
user,
|
||||
mutateFollowerCount,
|
||||
mutateFollowingCount,
|
||||
}) => {
|
||||
const { isFollowed, mutate: mutateFollowStatus } = useFollowStatus(user.id);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onClick = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await sendUserFollowRequest({ userId: user.id });
|
||||
await Promise.all([
|
||||
mutateFollowStatus(),
|
||||
mutateFollowerCount(),
|
||||
mutateFollowingCount(),
|
||||
]);
|
||||
setIsLoading(false);
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm gap-2 rounded-2xl lg:btn-md ${
|
||||
!isFollowed ? 'btn-ghost outline' : 'btn-primary'
|
||||
}`}
|
||||
onClick={() => {
|
||||
onClick();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isFollowed ? (
|
||||
<>
|
||||
<FaUserCheck className="text-xl" />
|
||||
Followed
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaUserPlus className="text-xl" />
|
||||
Follow
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserFollowButton;
|
||||
85
src/Website-v1/src/components/UserPage/UserHeader.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import useTimeDistance from '@/hooks/utilities/useTimeDistance';
|
||||
|
||||
import { FC, useContext } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { format } from 'date-fns';
|
||||
import GetUserSchema from '@/services/users/auth/schema/GetUserSchema';
|
||||
import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser';
|
||||
import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser';
|
||||
import UserContext from '@/contexts/UserContext';
|
||||
import Link from 'next/link';
|
||||
import UserAvatar from '../Account/UserAvatar';
|
||||
import UserFollowButton from './UserFollowButton';
|
||||
|
||||
interface UserHeaderProps {
|
||||
user: z.infer<typeof GetUserSchema>;
|
||||
}
|
||||
const UserHeader: FC<UserHeaderProps> = ({ user }) => {
|
||||
const timeDistance = useTimeDistance(new Date(user.createdAt));
|
||||
|
||||
const { followingCount, mutate: mutateFollowingCount } = useGetUsersFollowedByUser({
|
||||
userId: user.id,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { followerCount, mutate: mutateFollowerCount } = useGetUsersFollowingUser({
|
||||
userId: user.id,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { user: currentUser } = useContext(UserContext);
|
||||
|
||||
return (
|
||||
<header className="card items-center text-center">
|
||||
<div className="card-body w-full items-center justify-center">
|
||||
<div className="h-40 w-40">
|
||||
<UserAvatar user={user} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold lg:text-4xl">{user.username}</h1>
|
||||
|
||||
<div className="flex space-x-3 text-lg font-bold">
|
||||
<span>{followingCount} Following</span>
|
||||
<span>{followerCount} Followers</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="italic">
|
||||
joined{' '}
|
||||
{timeDistance && (
|
||||
<span
|
||||
className="tooltip tooltip-bottom"
|
||||
data-tip={format(new Date(user.createdAt), 'MM/dd/yyyy')}
|
||||
>
|
||||
{`${timeDistance} ago`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{user.bio && (
|
||||
<div className="my-2 w-6/12">
|
||||
<p className="text-sm">{user.bio}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="my-2 flex items-center justify-center">
|
||||
{currentUser?.id !== user.id ? (
|
||||
<UserFollowButton
|
||||
mutateFollowerCount={mutateFollowerCount}
|
||||
user={user}
|
||||
mutateFollowingCount={mutateFollowingCount}
|
||||
/>
|
||||
) : (
|
||||
<Link href={`/users/account/edit-profile`} className="btn btn-primary btn-sm">
|
||||
Edit Profile
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserHeader;
|
||||
56
src/Website-v1/src/components/ui/CustomToast.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { FC, ReactNode } from 'react';
|
||||
import toast, { Toast, Toaster, resolveValue } from 'react-hot-toast';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
|
||||
const toastToClassName = (toastType: Toast['type']) => {
|
||||
let className: 'alert-success' | 'alert-error' | 'alert-info';
|
||||
|
||||
switch (toastType) {
|
||||
case 'success':
|
||||
className = 'alert-success';
|
||||
break;
|
||||
case 'error':
|
||||
className = 'alert-error';
|
||||
break;
|
||||
default:
|
||||
className = 'alert-info';
|
||||
}
|
||||
|
||||
return className;
|
||||
};
|
||||
|
||||
const CustomToast: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
duration: 2500,
|
||||
}}
|
||||
>
|
||||
{(t) => {
|
||||
const alertType = toastToClassName(t.type);
|
||||
return (
|
||||
<div
|
||||
className={`alert ${alertType} flex w-full items-start justify-between shadow-lg duration-200 animate-in fade-in lg:w-4/12`}
|
||||
>
|
||||
<p className="w-full text-left">{resolveValue(t.message, t)}</p>
|
||||
{t.type !== 'loading' && (
|
||||
<div>
|
||||
<button
|
||||
className="btn btn-circle btn-ghost btn-xs"
|
||||
onClick={() => toast.dismiss(t.id)}
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Toaster>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default CustomToast;
|
||||
12
src/Website-v1/src/components/ui/Layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { FC, ReactNode } from 'react';
|
||||
import Navbar from './Navbar';
|
||||
|
||||
const Layout: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<div className="flex h-full flex-col" id="app">
|
||||
<Navbar />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Layout;
|
||||
37
src/Website-v1/src/components/ui/LikeButton.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { FC } from 'react';
|
||||
import { FaThumbsUp, FaRegThumbsUp } from 'react-icons/fa';
|
||||
|
||||
interface LikeButtonProps {
|
||||
isLiked: boolean;
|
||||
handleLike: () => Promise<void>;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const LikeButton: FC<LikeButtonProps> = ({ isLiked, handleLike, loading }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm gap-2 rounded-2xl lg:btn-md ${
|
||||
!isLiked ? 'btn-ghost outline' : 'btn-primary'
|
||||
}`}
|
||||
onClick={() => {
|
||||
handleLike();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{isLiked ? (
|
||||
<>
|
||||
<FaThumbsUp className="lg:text-2xl" />
|
||||
Liked
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaRegThumbsUp className="lg:text-2xl" />
|
||||
Like
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default LikeButton;
|
||||
26
src/Website-v1/src/components/ui/LoadingCard.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
const LoadingCard: FC = () => {
|
||||
return (
|
||||
<div className="card bg-base-300">
|
||||
<figure className="h-96 border-8 border-base-300 bg-base-300">
|
||||
<div className="h-full w-full animate-pulse rounded-md bg-base-100" />
|
||||
</figure>
|
||||
<div className="card-body h-52">
|
||||
<div className="flex animate-pulse space-x-4">
|
||||
<div className="flex-1 space-y-4 py-1">
|
||||
<div className="h-4 w-3/4 rounded bg-base-100" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 rounded bg-base-100" />
|
||||
<div className="h-4 w-5/6 rounded bg-base-100" />
|
||||
<div className="h-4 w-5/6 rounded bg-base-100" />
|
||||
<div className="h-4 rounded bg-base-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingCard;
|
||||
20
src/Website-v1/src/components/ui/LocationMarker.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React, { FC } from 'react';
|
||||
import { HiLocationMarker } from 'react-icons/hi';
|
||||
|
||||
interface LocationMarkerProps {
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
color?: 'blue' | 'red' | 'green' | 'yellow';
|
||||
}
|
||||
|
||||
const sizeClasses: Record<NonNullable<LocationMarkerProps['size']>, `text-${string}`> = {
|
||||
sm: 'text-lg',
|
||||
md: 'text-xl',
|
||||
lg: 'text-2xl',
|
||||
xl: 'text-3xl',
|
||||
};
|
||||
|
||||
const LocationMarker: FC<LocationMarkerProps> = ({ size = 'md', color = 'blue' }) => {
|
||||
return <HiLocationMarker className={`${sizeClasses[size]} text-${color}-600`} />;
|
||||
};
|
||||
|
||||
export default React.memo(LocationMarker);
|
||||
138
src/Website-v1/src/components/ui/Navbar.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import useMediaQuery from '@/hooks/utilities/useMediaQuery';
|
||||
import useNavbar from '@/hooks/utilities/useNavbar';
|
||||
// import useTheme from '@/hooks/utilities/useTheme';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { FC, useRef } from 'react';
|
||||
// import { MdDarkMode, MdLightMode } from 'react-icons/md';
|
||||
|
||||
import { FaBars } from 'react-icons/fa';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const DesktopLinks: FC = () => {
|
||||
const { pages, currentURL } = useNavbar();
|
||||
|
||||
return (
|
||||
<div className="block flex-none">
|
||||
<ul className="menu menu-horizontal menu-sm">
|
||||
{pages.map((page) => {
|
||||
return (
|
||||
<li key={page.slug}>
|
||||
<Link tabIndex={0} href={page.slug} className="hover:bg-primary-focus">
|
||||
<span
|
||||
className={`text-lg uppercase ${
|
||||
currentURL === page.slug ? 'font-extrabold' : 'font-bold'
|
||||
} text-base-content`}
|
||||
>
|
||||
{page.name}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileLinks: FC = () => {
|
||||
const { pages } = useNavbar();
|
||||
|
||||
const drawerRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<div className="flex-none lg:hidden">
|
||||
<div className="drawer drawer-end">
|
||||
<input id="my-drawer" type="checkbox" className="drawer-toggle" ref={drawerRef} />
|
||||
<div className="drawer-content">
|
||||
<label htmlFor="my-drawer" className="btn btn-ghost drawer-button">
|
||||
<FaBars />
|
||||
</label>
|
||||
</div>
|
||||
<div className="drawer-side">
|
||||
<label
|
||||
htmlFor="my-drawer"
|
||||
aria-label="close sidebar"
|
||||
className="drawer-overlay"
|
||||
/>
|
||||
<ul className="menu min-h-full bg-primary pr-16 text-base-content">
|
||||
{pages.map((page) => {
|
||||
return (
|
||||
<li key={page.slug}>
|
||||
<Link
|
||||
href={page.slug}
|
||||
tabIndex={0}
|
||||
rel={page.slug === '/resume/main.pdf' ? 'noopener noreferrer' : ''}
|
||||
target={page.slug === '/resume/main.pdf' ? '_blank' : ''}
|
||||
onClick={() => {
|
||||
if (!drawerRef.current) return;
|
||||
drawerRef.current.checked = false;
|
||||
}}
|
||||
>
|
||||
<span className="text-lg font-bold uppercase">{page.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Navbar = () => {
|
||||
const isDesktopView = useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
const { currentURL } = useNavbar();
|
||||
|
||||
const backgroundIsTransparent = currentURL === '/';
|
||||
|
||||
const isOnHomePage = currentURL === '/';
|
||||
|
||||
// const { theme, setTheme } = useTheme();
|
||||
return (
|
||||
<div
|
||||
className={classNames('navbar fixed top-0 z-20 h-10 min-h-10 text-base-content', {
|
||||
'bg-transparent': backgroundIsTransparent,
|
||||
'bg-primary': !backgroundIsTransparent,
|
||||
})}
|
||||
>
|
||||
<div className="flex-1">
|
||||
{isOnHomePage ? null : (
|
||||
<Link className="btn btn-ghost btn-sm" href="/">
|
||||
<span className="cursor-pointer text-lg font-bold">The Biergarten App</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* <div
|
||||
className="tooltip tooltip-left"
|
||||
data-tip={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
|
||||
>
|
||||
<div>
|
||||
{theme === 'light' ? (
|
||||
<button
|
||||
className="btn btn-circle btn-ghost btn-md"
|
||||
data-set-theme="dark"
|
||||
data-act-class="ACTIVECLASS"
|
||||
onClick={() => setTheme('dark')}
|
||||
>
|
||||
<MdLightMode className="text-xl" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-circle btn-ghost btn-md"
|
||||
data-set-theme="light"
|
||||
data-act-class="ACTIVECLASS"
|
||||
onClick={() => setTheme('light')}
|
||||
>
|
||||
<MdDarkMode className="text-xl" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div> */}
|
||||
<div>{isDesktopView ? <DesktopLinks /> : <MobileLinks />}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Navbar;
|
||||
23
src/Website-v1/src/components/ui/SmLoadingCard.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
const SMLoadingCard: FC = () => {
|
||||
return (
|
||||
<div className="card bg-base-300">
|
||||
<div className="card-body h-52">
|
||||
<div className="flex animate-pulse space-x-4">
|
||||
<div className="flex-1 space-y-4 py-1">
|
||||
<div className="h-4 w-3/4 rounded bg-base-100" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 rounded bg-base-100" />
|
||||
<div className="h-4 w-5/6 rounded bg-base-100" />
|
||||
<div className="h-4 w-5/6 rounded bg-base-100" />
|
||||
<div className="h-4 rounded bg-base-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SMLoadingCard;
|
||||
44
src/Website-v1/src/components/ui/Spinner.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
interface SpinnerProps {
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
const Spinner: FC<SpinnerProps> = ({ size = 'md' }) => {
|
||||
const spinnerWidths: Record<NonNullable<SpinnerProps['size']>, `w-[${number}px]`> = {
|
||||
xs: 'w-[45px]',
|
||||
sm: 'w-[90px]',
|
||||
md: 'w-[135px]',
|
||||
lg: 'w-[180px]',
|
||||
xl: 'w-[225px]',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
className="flex flex-col items-center justify-center rounded-3xl text-primary"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${spinnerWidths[size]} animate-spin fill-base-content`}
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Spinner;
|
||||
24
src/Website-v1/src/components/ui/forms/Button.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
|
||||
interface FormButtonProps {
|
||||
children: string;
|
||||
type: 'button' | 'submit' | 'reset';
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
const Button: FunctionComponent<FormButtonProps> = ({
|
||||
children,
|
||||
type,
|
||||
isSubmitting = false,
|
||||
}) => (
|
||||
// eslint-disable-next-line react/button-has-type
|
||||
<button
|
||||
type={type}
|
||||
className={`btn btn-primary w-full rounded-xl`}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
export default Button;
|
||||
16
src/Website-v1/src/components/ui/forms/FormError.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
|
||||
/**
|
||||
* @example
|
||||
* <FormError>Something went wrong!</FormError>;
|
||||
*/
|
||||
const FormError: FunctionComponent<{ children: string | undefined }> = ({ children }) =>
|
||||
children ? (
|
||||
<div
|
||||
className="my-1 h-3 text-xs font-semibold italic text-error-content"
|
||||
role="alert"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null;
|
||||
export default FormError;
|
||||
18
src/Website-v1/src/components/ui/forms/FormInfo.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
|
||||
interface FormInfoProps {
|
||||
children: [ReactNode, ReactNode];
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* <FormInfo>
|
||||
* <FormLabel htmlFor="name">Name</FormLabel>
|
||||
* <FormError>{errors.name?.message}</FormError>
|
||||
* </FormInfo>;
|
||||
*/
|
||||
const FormInfo: FunctionComponent<FormInfoProps> = ({ children }) => (
|
||||
<div className="flex justify-between">{children}</div>
|
||||
);
|
||||
|
||||
export default FormInfo;
|
||||
21
src/Website-v1/src/components/ui/forms/FormLabel.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
|
||||
interface FormLabelProps {
|
||||
htmlFor: string;
|
||||
children: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* <FormLabel htmlFor="name">Name</FormLabel>;
|
||||
*/
|
||||
const FormLabel: FunctionComponent<FormLabelProps> = ({ htmlFor, children }) => (
|
||||
<label
|
||||
className="my-1 block text-xs font-extrabold uppercase tracking-wide lg:text-sm"
|
||||
htmlFor={htmlFor}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
export default FormLabel;
|
||||
39
src/Website-v1/src/components/ui/forms/FormPageLayout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ReactNode, FC } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { IconType } from 'react-icons';
|
||||
import { BiArrowBack } from 'react-icons/bi';
|
||||
|
||||
interface FormPageLayoutProps {
|
||||
children: ReactNode;
|
||||
headingText: string;
|
||||
headingIcon: IconType;
|
||||
backLink: string;
|
||||
backLinkText: string;
|
||||
}
|
||||
|
||||
const FormPageLayout: FC<FormPageLayoutProps> = ({
|
||||
children: FormComponent,
|
||||
headingIcon,
|
||||
headingText,
|
||||
backLink,
|
||||
backLinkText,
|
||||
}) => {
|
||||
return (
|
||||
<div className="my-20 flex flex-col items-center justify-center">
|
||||
<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 btn-ghost btn-sm p-0">
|
||||
<BiArrowBack className="text-xl" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
{headingIcon({ className: 'text-4xl' })}{' '}
|
||||
<h1 className="text-center text-3xl font-bold">{headingText}</h1>
|
||||
</div>
|
||||
<div className="mt-3">{FormComponent}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormPageLayout;
|
||||
12
src/Website-v1/src/components/ui/forms/FormSegment.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
|
||||
/** A container for both the form error and form label. */
|
||||
interface FormInfoProps {
|
||||
children: Array<JSX.Element> | JSX.Element;
|
||||
}
|
||||
|
||||
const FormSegment: FunctionComponent<FormInfoProps> = ({ children }) => (
|
||||
<div className="mb-2">{children}</div>
|
||||
);
|
||||
|
||||
export default FormSegment;
|
||||
64
src/Website-v1/src/components/ui/forms/FormSelect.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { UseFormRegisterReturn } from 'react-hook-form';
|
||||
|
||||
interface FormSelectProps {
|
||||
options: readonly { value: string; text: string }[];
|
||||
id: string;
|
||||
formRegister: UseFormRegisterReturn<string>;
|
||||
error: boolean;
|
||||
placeholder: string;
|
||||
message: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* <FormSelect
|
||||
* options={[
|
||||
* { value: '1', text: 'One' },
|
||||
* { value: '2', text: 'Two' },
|
||||
* { value: '3', text: 'Three' },
|
||||
* ]}
|
||||
* id="test"
|
||||
* formRegister={register('test')}
|
||||
* error={true}
|
||||
* placeholder="Test"
|
||||
* message="Select an option"
|
||||
* />;
|
||||
*
|
||||
* @param props
|
||||
* @param props.options The options to display in the select.
|
||||
* @param props.id The id of the select.
|
||||
* @param props.formRegister The form register hook from react-hook-form.
|
||||
* @param props.error Whether or not the select has an error.
|
||||
* @param props.placeholder The placeholder text for the select.
|
||||
* @param props.message The message to display when no option is selected.
|
||||
*/
|
||||
const FormSelect: FunctionComponent<FormSelectProps> = ({
|
||||
options,
|
||||
id,
|
||||
error,
|
||||
formRegister,
|
||||
placeholder,
|
||||
message,
|
||||
disabled = false,
|
||||
}) => (
|
||||
<select
|
||||
id={id}
|
||||
className={`select select-bordered block w-full rounded-lg ${
|
||||
error ? 'select-error' : ''
|
||||
}`}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
{...formRegister}
|
||||
>
|
||||
<option value="">{message}</option>
|
||||
{options.map(({ value, text }) => (
|
||||
<option key={value} value={value}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
export default FormSelect;
|
||||
52
src/Website-v1/src/components/ui/forms/FormTextArea.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { UseFormRegisterReturn } from 'react-hook-form';
|
||||
|
||||
interface FormTextAreaProps {
|
||||
placeholder?: string;
|
||||
formValidationSchema: UseFormRegisterReturn<string>;
|
||||
error: boolean;
|
||||
id: string;
|
||||
rows: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* <FormTextArea
|
||||
* id="test"
|
||||
* formValidationSchema={register('test')}
|
||||
* error={true}
|
||||
* placeholder="Test"
|
||||
* rows={5}
|
||||
* disabled
|
||||
* />;
|
||||
*
|
||||
* @param props
|
||||
* @param props.placeholder The placeholder text for the textarea.
|
||||
* @param props.formValidationSchema The form register hook from react-hook-form.
|
||||
* @param props.error Whether or not the textarea has an error.
|
||||
* @param props.id The id of the textarea.
|
||||
* @param props.rows The number of rows to display in the textarea.
|
||||
* @param props.disabled Whether or not the textarea is disabled.
|
||||
*/
|
||||
const FormTextArea: FunctionComponent<FormTextAreaProps> = ({
|
||||
placeholder = '',
|
||||
formValidationSchema,
|
||||
error,
|
||||
id,
|
||||
rows,
|
||||
disabled = false,
|
||||
}) => (
|
||||
<textarea
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
className={`text-md textarea textarea-bordered m-0 w-full resize-none rounded-lg border border-solid transition ease-in-out ${
|
||||
error ? 'textarea-error' : ''
|
||||
}`}
|
||||
{...formValidationSchema}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
export default FormTextArea;
|
||||
59
src/Website-v1/src/components/ui/forms/FormTextInput.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/* eslint-disable react/require-default-props */
|
||||
import { FunctionComponent } from 'react';
|
||||
import { UseFormRegisterReturn } from 'react-hook-form';
|
||||
|
||||
interface FormInputProps {
|
||||
placeholder?: string;
|
||||
formValidationSchema: UseFormRegisterReturn<string>;
|
||||
error: boolean;
|
||||
// eslint-disable-next-line react/require-default-props
|
||||
type: 'email' | 'password' | 'text' | 'date';
|
||||
id: string;
|
||||
height?: string;
|
||||
disabled?: boolean;
|
||||
autoComplete?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* <FormTextInput
|
||||
* placeholder="Lorem Ipsum Lager"
|
||||
* formValidationSchema={register('name')}
|
||||
* error={!!errors.name}
|
||||
* type="text"
|
||||
* id="name"
|
||||
* disabled
|
||||
* />;
|
||||
*
|
||||
* @param param0 The props for the FormTextInput component
|
||||
* @param param0.placeholder The placeholder text for the input
|
||||
* @param param0.formValidationSchema The validation schema for the input, provided by
|
||||
* react-hook-form.
|
||||
* @param param0.error Whether or not the input has an error.
|
||||
* @param param0.type The input type (email, password, text, date).
|
||||
* @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 = '',
|
||||
formValidationSchema,
|
||||
error,
|
||||
type,
|
||||
id,
|
||||
disabled = false,
|
||||
}) => (
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
className={`input input-bordered w-full appearance-none rounded-lg transition ease-in-out ${
|
||||
error ? 'input-error' : ''
|
||||
}`}
|
||||
{...formValidationSchema}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
export default FormTextInput;
|
||||
12
src/Website-v1/src/components/ui/maps/ControlPanel.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { FC, memo } from 'react';
|
||||
import { FullscreenControl, NavigationControl, ScaleControl } from 'react-map-gl';
|
||||
|
||||
const ControlPanel: FC = () => (
|
||||
<>
|
||||
<FullscreenControl position="top-left" />
|
||||
<NavigationControl position="top-left" />
|
||||
<ScaleControl />
|
||||
</>
|
||||
);
|
||||
|
||||
export default memo(ControlPanel);
|
||||
35
src/Website-v1/src/config/auth/cookie.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { serialize, parse } from 'cookie';
|
||||
import { SessionRequest } from './types';
|
||||
import { NODE_ENV, SESSION_MAX_AGE, SESSION_TOKEN_NAME } from '../env';
|
||||
|
||||
export function setTokenCookie(res: NextApiResponse, token: string) {
|
||||
const cookie = serialize(SESSION_TOKEN_NAME, token, {
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
httpOnly: false,
|
||||
secure: NODE_ENV === 'production',
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
});
|
||||
|
||||
res.setHeader('Set-Cookie', cookie);
|
||||
}
|
||||
|
||||
export function removeTokenCookie(res: NextApiResponse) {
|
||||
const cookie = serialize(SESSION_TOKEN_NAME, '', { maxAge: -1, path: '/' });
|
||||
res.setHeader('Set-Cookie', cookie);
|
||||
}
|
||||
|
||||
export function parseCookies(req: SessionRequest) {
|
||||
// For API Routes we don't need to parse the cookies.
|
||||
if (req.cookies) return req.cookies;
|
||||
|
||||
// For pages we do need to parse the cookies.
|
||||
const cookie = req.headers?.cookie;
|
||||
return parse(cookie || '');
|
||||
}
|
||||
|
||||
export function getTokenCookie(req: SessionRequest) {
|
||||
const cookies = parseCookies(req);
|
||||
return cookies[SESSION_TOKEN_NAME];
|
||||
}
|
||||
24
src/Website-v1/src/config/auth/localStrat.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import Local from 'passport-local';
|
||||
import { findUserByUsernameService } from '@/services/users/auth';
|
||||
import ServerError from '../util/ServerError';
|
||||
import { validatePassword } from './passwordFns';
|
||||
|
||||
const localStrat = new Local.Strategy(async (username, password, done) => {
|
||||
try {
|
||||
const user = await findUserByUsernameService({ username });
|
||||
if (!user) {
|
||||
throw new ServerError('Username or password is incorrect.', 401);
|
||||
}
|
||||
|
||||
const isValidLogin = await validatePassword(user.hash, password);
|
||||
if (!isValidLogin) {
|
||||
throw new ServerError('Username or password is incorrect.', 401);
|
||||
}
|
||||
|
||||
done(null, { id: user.id, username: user.username });
|
||||
} catch (error) {
|
||||
done(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default localStrat;
|
||||
6
src/Website-v1/src/config/auth/passwordFns.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import argon2 from 'argon2';
|
||||
|
||||
export const hashPassword = async (password: string) => argon2.hash(password);
|
||||
|
||||
export const validatePassword = async (hash: string, password: string) =>
|
||||
argon2.verify(hash, password);
|
||||
53
src/Website-v1/src/config/auth/session.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import Iron from '@hapi/iron';
|
||||
import {
|
||||
SessionRequest,
|
||||
BasicUserInfoSchema,
|
||||
UserSessionSchema,
|
||||
} from '@/config/auth/types';
|
||||
import { z } from 'zod';
|
||||
import { SESSION_MAX_AGE, SESSION_SECRET } from '@/config/env';
|
||||
import { setTokenCookie, getTokenCookie } from './cookie';
|
||||
import ServerError from '../util/ServerError';
|
||||
|
||||
export async function setLoginSession(
|
||||
res: NextApiResponse,
|
||||
session: z.infer<typeof BasicUserInfoSchema>,
|
||||
) {
|
||||
if (!SESSION_SECRET) {
|
||||
throw new ServerError('Authentication is not configured.', 500);
|
||||
}
|
||||
const createdAt = Date.now();
|
||||
const obj = { ...session, createdAt, maxAge: SESSION_MAX_AGE };
|
||||
const token = await Iron.seal(obj, SESSION_SECRET, Iron.defaults);
|
||||
|
||||
setTokenCookie(res, token);
|
||||
}
|
||||
|
||||
export async function getLoginSession(req: SessionRequest) {
|
||||
if (!SESSION_SECRET) {
|
||||
throw new ServerError('Authentication is not configured.', 500);
|
||||
}
|
||||
|
||||
const token = getTokenCookie(req);
|
||||
if (!token) {
|
||||
throw new ServerError('You are not logged in.', 401);
|
||||
}
|
||||
|
||||
const session = await Iron.unseal(token, SESSION_SECRET, Iron.defaults);
|
||||
|
||||
const parsed = UserSessionSchema.safeParse(session);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new ServerError('Session is invalid.', 401);
|
||||
}
|
||||
|
||||
const { createdAt, maxAge } = parsed.data;
|
||||
|
||||
const expiresAt = createdAt + maxAge * 1000;
|
||||
if (Date.now() > expiresAt) {
|
||||
throw new ServerError('Session expired', 401);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
26
src/Website-v1/src/config/auth/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import GetUserSchema from '@/services/users/auth/schema/GetUserSchema';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { NextApiRequest } from 'next';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const BasicUserInfoSchema = z.object({
|
||||
id: z.string().cuid(),
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
export const UserSessionSchema = BasicUserInfoSchema.merge(
|
||||
z.object({
|
||||
createdAt: z.number(),
|
||||
maxAge: z.number(),
|
||||
}),
|
||||
);
|
||||
|
||||
export interface UserExtendedNextApiRequest extends NextApiRequest {
|
||||
user?: z.infer<typeof GetUserSchema>;
|
||||
}
|
||||
|
||||
export type SessionRequest = IncomingMessage & {
|
||||
cookies: Partial<{
|
||||
[key: string]: string;
|
||||
}>;
|
||||
};
|
||||
95
src/Website-v1/src/config/cloudinary/CloudinaryStorage.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
|
||||
import type { StorageEngine } from 'multer';
|
||||
import type { UploadApiOptions, UploadApiResponse, v2 as cloudinary } from 'cloudinary';
|
||||
import type { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Represents a storage engine for uploading files to Cloudinary.
|
||||
*
|
||||
* @example
|
||||
* const storage = new CloudinaryStorage({
|
||||
* cloudinary,
|
||||
* params: {
|
||||
* folder: 'my-folder',
|
||||
* allowed_formats: ['jpg', 'png'],
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
class CloudinaryStorage implements StorageEngine {
|
||||
private cloudinary: typeof cloudinary;
|
||||
|
||||
private params: UploadApiOptions;
|
||||
|
||||
/**
|
||||
* Creates an instance of CloudinaryStorage.
|
||||
*
|
||||
* @param options - The options for configuring the Cloudinary storage engine.
|
||||
* @param options.cloudinary - The Cloudinary instance.
|
||||
* @param options.params - The parameters for uploading files to Cloudinary.
|
||||
*/
|
||||
constructor(options: { cloudinary: typeof cloudinary; params: UploadApiOptions }) {
|
||||
this.cloudinary = options.cloudinary;
|
||||
this.params = options.params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the file from Cloudinary.
|
||||
*
|
||||
* @param req - The request object.
|
||||
* @param file - The file to be removed.
|
||||
* @param callback - The callback function to be called if an error occurs.
|
||||
*/
|
||||
_removeFile(req: Request, file: Express.Multer.File, callback: (error: Error) => void) {
|
||||
this.cloudinary.uploader.destroy(file.filename, { invalidate: true }, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the file upload to Cloudinary.
|
||||
*
|
||||
* @param req - The request object.
|
||||
* @param file - The file to be uploaded.
|
||||
* @param callback - The callback function to be called after the file is uploaded.
|
||||
*/
|
||||
_handleFile(
|
||||
req: Request,
|
||||
file: Express.Multer.File,
|
||||
callback: (error?: unknown, info?: Partial<Express.Multer.File>) => void,
|
||||
) {
|
||||
this.uploadFile(file)
|
||||
.then((cloudResponse) => {
|
||||
callback(null, {
|
||||
path: cloudResponse.secure_url,
|
||||
size: cloudResponse.bytes,
|
||||
filename: cloudResponse.public_id,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
callback(error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to Cloudinary.
|
||||
*
|
||||
* @param file - The file to be uploaded.
|
||||
* @returns A promise that resolves to the upload response.
|
||||
*/
|
||||
private uploadFile(file: Express.Multer.File): Promise<UploadApiResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = this.cloudinary.uploader.upload_stream(
|
||||
this.params,
|
||||
(err, response) => {
|
||||
if (err != null) {
|
||||
return reject(err);
|
||||
}
|
||||
return resolve(response!);
|
||||
},
|
||||
);
|
||||
|
||||
file.stream.pipe(stream);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default CloudinaryStorage;
|
||||
11
src/Website-v1/src/config/cloudinary/helpers/deleteImage.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { cloudinary } from '..';
|
||||
|
||||
/**
|
||||
* Deletes an image from Cloudinary.
|
||||
*
|
||||
* @param path - The cloudinary path of the image to be deleted.
|
||||
* @returns A promise that resolves when the image is successfully deleted.
|
||||
*/
|
||||
const deleteImage = (path: string) => cloudinary.uploader.destroy(path);
|
||||
|
||||
export default deleteImage;
|
||||
23
src/Website-v1/src/config/cloudinary/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { v2 as cloudinary } from 'cloudinary';
|
||||
|
||||
import {
|
||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
|
||||
CLOUDINARY_KEY,
|
||||
CLOUDINARY_SECRET,
|
||||
NODE_ENV,
|
||||
} from '../env';
|
||||
import CloudinaryStorage from './CloudinaryStorage';
|
||||
|
||||
cloudinary.config({
|
||||
cloud_name: NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
|
||||
api_key: CLOUDINARY_KEY,
|
||||
api_secret: CLOUDINARY_SECRET,
|
||||
});
|
||||
|
||||
/** Cloudinary storage instance. */
|
||||
const storage = new CloudinaryStorage({
|
||||
cloudinary,
|
||||
params: { folder: NODE_ENV === 'production' ? 'biergarten' : 'biergarten-dev' },
|
||||
});
|
||||
|
||||
export { cloudinary, storage };
|
||||
210
src/Website-v1/src/config/env/index.ts
vendored
Normal file
@@ -0,0 +1,210 @@
|
||||
/* eslint-disable prefer-destructuring */
|
||||
import { z } from 'zod';
|
||||
import { env } from 'process';
|
||||
import ServerError from '../util/ServerError';
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
/**
|
||||
* Environment variables are validated at runtime to ensure that they are present and have
|
||||
* the correct type. This is done using the zod library.
|
||||
*/
|
||||
const envSchema = z.object({
|
||||
BASE_URL: z.string().url(),
|
||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME: z.string(),
|
||||
CLOUDINARY_KEY: z.string(),
|
||||
CLOUDINARY_SECRET: z.string(),
|
||||
RESET_PASSWORD_TOKEN_SECRET: z.string(),
|
||||
CONFIRMATION_TOKEN_SECRET: z.string(),
|
||||
SESSION_SECRET: z.string(),
|
||||
SESSION_TOKEN_NAME: z.string(),
|
||||
SESSION_MAX_AGE: z.coerce.number().positive(),
|
||||
|
||||
POSTGRES_PRISMA_URL: z.string().url(),
|
||||
POSTGRES_URL_NON_POOLING: z.string().url(),
|
||||
SHADOW_DATABASE_URL: z.string().url(),
|
||||
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']),
|
||||
SPARKPOST_API_KEY: z.string(),
|
||||
SPARKPOST_SENDER_ADDRESS: z.string().email(),
|
||||
MAPBOX_ACCESS_TOKEN: z.string(),
|
||||
|
||||
ADMIN_PASSWORD: z.string().regex(/^(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9]).{8,}$/),
|
||||
});
|
||||
|
||||
const parsed = envSchema.safeParse(env);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new ServerError('Invalid environment variables', 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL of the application.
|
||||
*
|
||||
* @example
|
||||
* 'https://example.com';
|
||||
*/
|
||||
export const BASE_URL = parsed.data.BASE_URL;
|
||||
|
||||
/**
|
||||
* Cloudinary cloud name.
|
||||
*
|
||||
* @example
|
||||
* 'my-cloud';
|
||||
*
|
||||
* @see https://cloudinary.com/documentation/cloudinary_references
|
||||
* @see https://cloudinary.com/console
|
||||
*/
|
||||
|
||||
export const NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME =
|
||||
parsed.data.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME;
|
||||
|
||||
/**
|
||||
* Cloudinary API key.
|
||||
*
|
||||
* @example
|
||||
* '123456789012345';
|
||||
*
|
||||
* @see https://cloudinary.com/documentation/cloudinary_references
|
||||
* @see https://cloudinary.com/console
|
||||
*/
|
||||
export const CLOUDINARY_KEY = parsed.data.CLOUDINARY_KEY;
|
||||
|
||||
/**
|
||||
* Cloudinary API secret.
|
||||
*
|
||||
* @example
|
||||
* 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
*
|
||||
* @see https://cloudinary.com/documentation/cloudinary_references
|
||||
* @see https://cloudinary.com/console
|
||||
*/
|
||||
export const CLOUDINARY_SECRET = parsed.data.CLOUDINARY_SECRET;
|
||||
|
||||
/**
|
||||
* Secret key for signing confirmation tokens.
|
||||
*
|
||||
* @example
|
||||
* 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
*
|
||||
* @see README.md for instructions on generating a secret key.
|
||||
*/
|
||||
export const CONFIRMATION_TOKEN_SECRET = parsed.data.CONFIRMATION_TOKEN_SECRET;
|
||||
|
||||
/**
|
||||
* Secret key for signing reset password tokens.
|
||||
*
|
||||
* @example
|
||||
* 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
*
|
||||
* @see README.md for instructions on generating a secret key.
|
||||
*/
|
||||
|
||||
export const RESET_PASSWORD_TOKEN_SECRET = parsed.data.RESET_PASSWORD_TOKEN_SECRET;
|
||||
|
||||
/**
|
||||
* Secret key for signing session cookies.
|
||||
*
|
||||
* @example
|
||||
* 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
*
|
||||
* @see README.md for instructions on generating a secret key.
|
||||
*/
|
||||
export const SESSION_SECRET = parsed.data.SESSION_SECRET;
|
||||
|
||||
/**
|
||||
* Name of the session cookie.
|
||||
*
|
||||
* @example
|
||||
* 'my-app-session';
|
||||
*/
|
||||
export const SESSION_TOKEN_NAME = parsed.data.SESSION_TOKEN_NAME;
|
||||
|
||||
/**
|
||||
* Maximum age of the session cookie in seconds.
|
||||
*
|
||||
* @example
|
||||
* '86400000'; // 24 hours
|
||||
*/
|
||||
export const SESSION_MAX_AGE = parsed.data.SESSION_MAX_AGE;
|
||||
|
||||
/**
|
||||
* PostgreSQL connection URL for Prisma taken from Neon.
|
||||
*
|
||||
* @example
|
||||
* 'postgresql://user:password@host:5432/database';
|
||||
*
|
||||
* @see https://neon.tech/docs/guides/prisma
|
||||
*/
|
||||
export const POSTGRES_PRISMA_URL = parsed.data.POSTGRES_PRISMA_URL;
|
||||
|
||||
/**
|
||||
* Non-pooling PostgreSQL connection URL taken from Neon.
|
||||
*
|
||||
* @example
|
||||
* 'postgresql://user:password@host:5432/database';
|
||||
*
|
||||
* @see https://neon.tech/docs/guides/prisma
|
||||
*/
|
||||
export const POSTGRES_URL_NON_POOLING = parsed.data.POSTGRES_URL_NON_POOLING;
|
||||
|
||||
/**
|
||||
* The URL of another Neon PostgreSQL database to shadow for migrations.
|
||||
*
|
||||
* @example
|
||||
* 'postgresql://user:password@host:5432/database';
|
||||
*
|
||||
* @see https://neon.tech/docs/guides/prisma-migrate
|
||||
*/
|
||||
export const SHADOW_DATABASE_URL = parsed.data.SHADOW_DATABASE_URL;
|
||||
|
||||
/**
|
||||
* Node environment.
|
||||
*
|
||||
* @example
|
||||
* 'production';
|
||||
*
|
||||
* @see https://nodejs.org/api/process.html#process_process_env
|
||||
*/
|
||||
export const NODE_ENV = parsed.data.NODE_ENV;
|
||||
|
||||
/**
|
||||
* SparkPost API key.
|
||||
*
|
||||
* @example
|
||||
* 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
*
|
||||
* @see https://app.sparkpost.com/account/api-keys
|
||||
*/
|
||||
export const SPARKPOST_API_KEY = parsed.data.SPARKPOST_API_KEY;
|
||||
|
||||
/**
|
||||
* Sender email address for SparkPost.
|
||||
*
|
||||
* @example
|
||||
* 'noreply@example.com';
|
||||
*
|
||||
* @see https://app.sparkpost.com/domains/list/sending
|
||||
*/
|
||||
export const SPARKPOST_SENDER_ADDRESS = parsed.data.SPARKPOST_SENDER_ADDRESS;
|
||||
|
||||
/**
|
||||
* Your Mapbox access token.
|
||||
*
|
||||
* @example
|
||||
* 'pk.abcdefghijklmnopqrstuvwxyz123456';
|
||||
*
|
||||
* @see https://docs.mapbox.com/help/how-mapbox-works/access-tokens/
|
||||
*/
|
||||
export const MAPBOX_ACCESS_TOKEN = parsed.data.MAPBOX_ACCESS_TOKEN;
|
||||
|
||||
/**
|
||||
* Admin password for seeding the database.
|
||||
*
|
||||
* @example
|
||||
* 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
*
|
||||
* @see README.md for instructions on generating a secret key.
|
||||
*/
|
||||
|
||||
export const ADMIN_PASSWORD = parsed.data.ADMIN_PASSWORD;
|
||||
58
src/Website-v1/src/config/jwt/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { BasicUserInfoSchema } from '@/config/auth/types';
|
||||
import jwt, { JsonWebTokenError } from 'jsonwebtoken';
|
||||
import { z } from 'zod';
|
||||
import { CONFIRMATION_TOKEN_SECRET, RESET_PASSWORD_TOKEN_SECRET } from '../env';
|
||||
import ServerError from '../util/ServerError';
|
||||
|
||||
export const generateConfirmationToken = (user: z.infer<typeof BasicUserInfoSchema>) => {
|
||||
return jwt.sign(user, CONFIRMATION_TOKEN_SECRET, { expiresIn: '3m' });
|
||||
};
|
||||
|
||||
export const verifyConfirmationToken = async (token: string) => {
|
||||
try {
|
||||
const decoded = jwt.verify(token, CONFIRMATION_TOKEN_SECRET);
|
||||
|
||||
const parsed = BasicUserInfoSchema.safeParse(decoded);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new ServerError('Invalid token.', 401);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'jwt expired') {
|
||||
throw new ServerError(
|
||||
'Your confirmation token is expired. Please generate a new one.',
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
throw new ServerError('Something went wrong', 500);
|
||||
}
|
||||
};
|
||||
|
||||
export const generateResetPasswordToken = (user: z.infer<typeof BasicUserInfoSchema>) => {
|
||||
return jwt.sign(user, RESET_PASSWORD_TOKEN_SECRET, { expiresIn: '5m' });
|
||||
};
|
||||
export const verifyResetPasswordToken = async (token: string) => {
|
||||
try {
|
||||
const decoded = jwt.verify(token, RESET_PASSWORD_TOKEN_SECRET);
|
||||
|
||||
const parsed = BasicUserInfoSchema.safeParse(decoded);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
} catch (error) {
|
||||
if (error instanceof JsonWebTokenError) {
|
||||
throw new ServerError(
|
||||
'Your reset password token is invalid. Please generate a new one.',
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
throw new ServerError('Something went wrong', 500);
|
||||
}
|
||||
};
|
||||
12
src/Website-v1/src/config/mapbox/geocoder.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import mbxGeocoding from '@mapbox/mapbox-sdk/services/geocoding';
|
||||
|
||||
import { MAPBOX_ACCESS_TOKEN } from '../env';
|
||||
|
||||
const geocoder = mbxGeocoding({ accessToken: MAPBOX_ACCESS_TOKEN });
|
||||
|
||||
const geocode = async (query: string) => {
|
||||
const geoData = await geocoder.forwardGeocode({ query, limit: 1 }).send();
|
||||
return geoData.body.features[0];
|
||||
};
|
||||
|
||||
export default geocode;
|
||||
28
src/Website-v1/src/config/multer/uploadMiddleware.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import multer from 'multer';
|
||||
import { expressWrapper } from 'next-connect';
|
||||
import { storage } from '../cloudinary';
|
||||
|
||||
const fileFilter: multer.Options['fileFilter'] = (req, file, callback) => {
|
||||
const { mimetype } = file;
|
||||
|
||||
const isImage = mimetype.startsWith('image/');
|
||||
|
||||
if (!isImage) {
|
||||
callback(null, false);
|
||||
}
|
||||
callback(null, true);
|
||||
};
|
||||
|
||||
export const uploadMiddlewareMultiple = expressWrapper(
|
||||
multer({ storage, fileFilter, limits: { files: 5, fileSize: 15 * 1024 * 1024 } }).array(
|
||||
'images',
|
||||
),
|
||||
);
|
||||
|
||||
export const singleUploadMiddleware = expressWrapper(
|
||||
multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: { files: 1, fileSize: 15 * 1024 * 1024 },
|
||||
}).single('image'),
|
||||
);
|
||||
41
src/Website-v1/src/config/nextConnect/NextConnectOptions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { RequestHandler } from 'next-connect/dist/types/node';
|
||||
import type { HandlerOptions } from 'next-connect/dist/types/types';
|
||||
import { z } from 'zod';
|
||||
import logger from '../pino/logger';
|
||||
|
||||
import ServerError from '../util/ServerError';
|
||||
import { NODE_ENV } from '../env';
|
||||
|
||||
type NextConnectOptionsT = HandlerOptions<
|
||||
RequestHandler<
|
||||
NextApiRequest,
|
||||
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
|
||||
>
|
||||
>;
|
||||
|
||||
const NextConnectOptions: NextConnectOptionsT = {
|
||||
onNoMatch(req, res) {
|
||||
res.status(405).json({
|
||||
message: 'Method not allowed.',
|
||||
statusCode: 405,
|
||||
success: false,
|
||||
});
|
||||
},
|
||||
onError(error, req, res) {
|
||||
if (NODE_ENV !== 'production') {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Internal server error.';
|
||||
const statusCode = error instanceof ServerError ? error.statusCode : 500;
|
||||
res.status(statusCode).json({
|
||||
message,
|
||||
statusCode,
|
||||
success: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default NextConnectOptions;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { NextHandler } from 'next-connect';
|
||||
|
||||
import ServerError from '@/config/util/ServerError';
|
||||
import { findUserByIdService } from '@/services/users/auth';
|
||||
import { getLoginSession } from '@/config/auth/session';
|
||||
import { UserExtendedNextApiRequest } from '@/config/auth/types';
|
||||
|
||||
/** Get the current user from the session. Adds the user to the request object. */
|
||||
const getCurrentUser = async (
|
||||
req: UserExtendedNextApiRequest,
|
||||
res: NextApiResponse,
|
||||
next: NextHandler,
|
||||
) => {
|
||||
const session = await getLoginSession(req);
|
||||
const user = await findUserByIdService({ userId: session?.id });
|
||||
|
||||
if (!user) {
|
||||
throw new ServerError('User is not logged in.', 401);
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
return next();
|
||||
};
|
||||
|
||||
export default getCurrentUser;
|
||||
@@ -0,0 +1,47 @@
|
||||
import ServerError from '@/config/util/ServerError';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { NextHandler } from 'next-connect';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface ValidateRequestArgs {
|
||||
bodySchema?: z.ZodSchema<any>;
|
||||
querySchema?: z.ZodSchema<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to validate the request body and/or query against a zod schema.
|
||||
*
|
||||
* @example
|
||||
* const handler = nextConnect(NextConnectConfig).post(
|
||||
* validateRequest({ bodySchema: BeerPostValidationSchema }),
|
||||
* getCurrentUser,
|
||||
* createBeerPost,
|
||||
* );
|
||||
*
|
||||
* @param args
|
||||
* @param args.bodySchema The body schema to validate against.
|
||||
* @param args.querySchema The query schema to validate against.
|
||||
* @throws ServerError with status code 400 if the request body or query is invalid.
|
||||
*/
|
||||
const validateRequest = ({ bodySchema, querySchema }: ValidateRequestArgs) => {
|
||||
return (req: NextApiRequest, res: NextApiResponse, next: NextHandler) => {
|
||||
if (bodySchema) {
|
||||
const parsed = bodySchema.safeParse(JSON.parse(JSON.stringify(req.body)));
|
||||
if (!parsed.success) {
|
||||
throw new ServerError('Invalid request body.', 400);
|
||||
}
|
||||
req.body = parsed.data;
|
||||
}
|
||||
|
||||
if (querySchema) {
|
||||
const parsed = querySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
throw new ServerError('Invalid request query.', 400);
|
||||
}
|
||||
req.query = parsed.data;
|
||||
}
|
||||
return next();
|
||||
};
|
||||
};
|
||||
|
||||
export default validateRequest;
|
||||
5
src/Website-v1/src/config/pino/logger.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import pino from 'pino';
|
||||
|
||||
const logger = pino();
|
||||
|
||||
export default logger;
|
||||