Website updates: add new app scaffold, archive legacy site, and refresh docs/tooling (#173)

This commit is contained in:
Aaron Po
2026-03-15 22:56:14 -04:00
committed by GitHub
parent 9238036042
commit 581863d69b
413 changed files with 20897 additions and 9831 deletions

View File

@@ -0,0 +1,7 @@
build
node_modules
.react-router
package-lock.json
storybook-static
test-results
debug-storybook.log

View File

@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"tabWidth": 3,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf"
}

View File

@@ -0,0 +1,9 @@
// Generated by React Router
import "react-router";
declare module "react-router" {
interface Future {
v8_middleware: false
}
}

View File

@@ -0,0 +1,105 @@
// Generated by React Router
import "react-router"
declare module "react-router" {
interface Register {
pages: Pages
routeFiles: RouteFiles
routeModules: RouteModules
}
}
type Pages = {
"/": {
params: {};
};
"/theme": {
params: {};
};
"/login": {
params: {};
};
"/register": {
params: {};
};
"/logout": {
params: {};
};
"/dashboard": {
params: {};
};
"/confirm": {
params: {};
};
"/beers": {
params: {};
};
"/breweries": {
params: {};
};
"/beer-styles": {
params: {};
};
};
type RouteFiles = {
"root.tsx": {
id: "root";
page: "/" | "/theme" | "/login" | "/register" | "/logout" | "/dashboard" | "/confirm" | "/beers" | "/breweries" | "/beer-styles";
};
"routes/home.tsx": {
id: "routes/home";
page: "/";
};
"routes/theme.tsx": {
id: "routes/theme";
page: "/theme";
};
"routes/login.tsx": {
id: "routes/login";
page: "/login";
};
"routes/register.tsx": {
id: "routes/register";
page: "/register";
};
"routes/logout.tsx": {
id: "routes/logout";
page: "/logout";
};
"routes/dashboard.tsx": {
id: "routes/dashboard";
page: "/dashboard";
};
"routes/confirm.tsx": {
id: "routes/confirm";
page: "/confirm";
};
"routes/beers.tsx": {
id: "routes/beers";
page: "/beers";
};
"routes/breweries.tsx": {
id: "routes/breweries";
page: "/breweries";
};
"routes/beer-styles.tsx": {
id: "routes/beer-styles";
page: "/beer-styles";
};
};
type RouteModules = {
"root": typeof import("./app/root.tsx");
"routes/home": typeof import("./app/routes/home.tsx");
"routes/theme": typeof import("./app/routes/theme.tsx");
"routes/login": typeof import("./app/routes/login.tsx");
"routes/register": typeof import("./app/routes/register.tsx");
"routes/logout": typeof import("./app/routes/logout.tsx");
"routes/dashboard": typeof import("./app/routes/dashboard.tsx");
"routes/confirm": typeof import("./app/routes/confirm.tsx");
"routes/beers": typeof import("./app/routes/beers.tsx");
"routes/breweries": typeof import("./app/routes/breweries.tsx");
"routes/beer-styles": typeof import("./app/routes/beer-styles.tsx");
};

View File

@@ -0,0 +1,18 @@
// Generated by React Router
declare module "virtual:react-router/server-build" {
import { ServerBuild } from "react-router";
export const assets: ServerBuild["assets"];
export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"];
export const basename: ServerBuild["basename"];
export const entry: ServerBuild["entry"];
export const future: ServerBuild["future"];
export const isSpaMode: ServerBuild["isSpaMode"];
export const prerender: ServerBuild["prerender"];
export const publicPath: ServerBuild["publicPath"];
export const routeDiscovery: ServerBuild["routeDiscovery"];
export const routes: ServerBuild["routes"];
export const ssr: ServerBuild["ssr"];
export const allowedActionOrigins: ServerBuild["allowedActionOrigins"];
export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"];
}

View File

@@ -0,0 +1,59 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../root.js")
type Info = GetInfo<{
file: "root.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../root.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../beer-styles.js")
type Info = GetInfo<{
file: "routes/beer-styles.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/beer-styles";
module: typeof import("../beer-styles.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../beers.js")
type Info = GetInfo<{
file: "routes/beers.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/beers";
module: typeof import("../beers.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../breweries.js")
type Info = GetInfo<{
file: "routes/breweries.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/breweries";
module: typeof import("../breweries.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../confirm.js")
type Info = GetInfo<{
file: "routes/confirm.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/confirm";
module: typeof import("../confirm.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../dashboard.js")
type Info = GetInfo<{
file: "routes/dashboard.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard";
module: typeof import("../dashboard.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../home.js")
type Info = GetInfo<{
file: "routes/home.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/home";
module: typeof import("../home.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../login.js")
type Info = GetInfo<{
file: "routes/login.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/login";
module: typeof import("../login.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../logout.js")
type Info = GetInfo<{
file: "routes/logout.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/logout";
module: typeof import("../logout.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../register.js")
type Info = GetInfo<{
file: "routes/register.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/register";
module: typeof import("../register.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../theme.js")
type Info = GetInfo<{
file: "routes/theme.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/theme";
module: typeof import("../theme.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,50 @@
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: [
'../stories/Configure.mdx',
'../stories/SubmitButton.stories.tsx',
'../stories/FormField.stories.tsx',
'../stories/Navbar.stories.tsx',
'../stories/Toast.stories.tsx',
'../stories/Themes.stories.tsx',
],
addons: [
'@chromatic-com/storybook',
'@storybook/addon-vitest',
'@storybook/addon-a11y',
'@storybook/addon-docs',
'@storybook/addon-onboarding',
],
framework: '@storybook/react-vite',
async viteFinal(config) {
config.plugins = (config.plugins ?? []).filter((plugin) => {
if (!plugin) {
return true;
}
const pluginName = typeof plugin === 'object' && 'name' in plugin ? plugin.name : '';
return !pluginName.startsWith('react-router');
});
config.build ??= {};
config.build.rollupOptions ??= {};
const previousOnWarn = config.build.rollupOptions.onwarn;
config.build.rollupOptions.onwarn = (warning, warn) => {
if (warning.code === 'MODULE_LEVEL_DIRECTIVE') {
return;
}
if (typeof previousOnWarn === 'function') {
previousOnWarn(warning, warn);
return;
}
warn(warning);
};
return config;
},
};
export default config;

View File

@@ -0,0 +1,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..900;1,9..40,100..900&family=Volkhov:ital,wght@0,400;0,700;1,400;1,700&display=swap"
/>

View File

@@ -0,0 +1,63 @@
import type { Preview } from '@storybook/react-vite';
import { createElement } from 'react';
import { MemoryRouter } from 'react-router';
import '../app/app.css';
import { biergartenThemes, defaultThemeName, isBiergartenTheme } from '../app/lib/themes';
const preview: Preview = {
globalTypes: {
theme: {
description: 'Active Biergarten theme',
toolbar: {
title: 'Theme',
icon: 'paintbrush',
dynamicTitle: true,
items: biergartenThemes.map((theme) => ({
value: theme.value,
title: theme.label,
})),
},
},
},
initialGlobals: {
theme: defaultThemeName,
},
decorators: [
(Story, context) => {
const theme = isBiergartenTheme(String(context.globals.theme))
? context.globals.theme
: defaultThemeName;
return createElement(
MemoryRouter,
undefined,
createElement(
'div',
{
'data-theme': theme,
className: 'bg-base-200 p-6 text-base-content',
},
createElement('div', { className: 'mx-auto max-w-7xl' }, createElement(Story)),
),
);
},
],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
layout: 'padded',
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo',
},
},
};
export default preview;

View File

@@ -0,0 +1,7 @@
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
import { setProjectAnnotations } from '@storybook/react-vite';
import * as projectAnnotations from './preview';
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);

View File

@@ -1,219 +0,0 @@
# 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
![Schema](./schema.svg)
## 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.

251
src/Website/app/app.css Normal file
View File

@@ -0,0 +1,251 @@
@import 'tailwindcss';
@plugin "daisyui" {
themes: biergarten-lager, biergarten-stout, biergarten-cassis, biergarten-weizen;
}
@theme {
--font-sans:
'DM Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: 'Volkhov', ui-serif, Georgia, serif;
}
h1,
h2,
h3,
h4,
h5,
h6,
.card-title {
font-family: var(--font-serif);
}
/* ─────────────────────────────────────────
BIERGARTEN LAGER
Light. Warm parchment base, mellow amber
primary, softened mahogany secondary.
───────────────────────────────────────── */
@plugin "daisyui/theme" {
name: 'biergarten-lager';
default: true;
prefersdark: false;
color-scheme: 'light';
--color-base-100: oklch(96% 0.012 82); /* warm parchment */
--color-base-200: oklch(92% 0.018 80); /* brushed paper */
--color-base-300: oklch(87% 0.025 78); /* tinted linen */
--color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */
--color-primary: oklch(65% 0.085 62); /* mellow amber */
--color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */
--color-secondary: oklch(42% 0.05 42); /* softened mahogany */
--color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */
--color-accent: oklch(93% 0.015 90); /* frothy cream */
--color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */
--color-neutral: oklch(28% 0.02 46); /* warm roast dark */
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */
--color-info: oklch(46% 0.065 145); /* muted hop green */
--color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */
--color-success: oklch(70% 0.06 122); /* soft barley gold */
--color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */
--color-warning: oklch(72% 0.09 56); /* toned amber */
--color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */
--color-error: oklch(54% 0.09 22); /* restrained cherry */
--color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */
--color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */
--color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */
--color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */
--color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */
--color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */
--radius-selector: 0.375rem;
--radius-field: 0.5rem;
--radius-box: 0.875rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 1;
}
/* ─────────────────────────────────────────
BIERGARTEN STOUT
Dark. Charred barrel base, golden amber
primary, deep mahogany secondary.
───────────────────────────────────────── */
@plugin "daisyui/theme" {
name: 'biergarten-stout';
default: false;
prefersdark: true;
color-scheme: 'dark';
--color-base-100: oklch(14% 0.006 45); /* charred barrel black */
--color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
--color-base-300: oklch(23% 0.01 42); /* deep brown */
--color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */
--color-primary: oklch(68% 0.055 60); /* golden amber */
--color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */
--color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */
--color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */
--color-accent: oklch(82% 0.01 88); /* frothy cream head */
--color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */
--color-neutral: oklch(20% 0.008 45); /* near-black with warmth */
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */
--color-info: oklch(60% 0.04 145); /* cool hop green */
--color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */
--color-success: oklch(66% 0.038 120); /* fresh barley */
--color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */
--color-warning: oklch(70% 0.055 55); /* amber harvest */
--color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */
--color-error: oklch(50% 0.06 20); /* deep cherry kriek */
--color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */
--color-surface: oklch(26% 0.012 45); /* elevated dark panel */
--color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */
--color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */
--color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */
--radius-selector: 0.375rem;
--radius-field: 0.5rem;
--radius-box: 0.875rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 1;
}
/* ─────────────────────────────────────────
BIERGARTEN CASSIS
Dark. Blackberry base, cassis berry
primary, sour cherry secondary.
───────────────────────────────────────── */
@plugin "daisyui/theme" {
name: 'biergarten-cassis';
default: false;
prefersdark: false;
color-scheme: 'dark';
--color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */
--color-base-200: oklch(17% 0.013 292); /* deep purple-black */
--color-base-300: oklch(22% 0.016 290); /* dark grape */
--color-base-content: oklch(90% 0.014 300); /* pale lavender-white — 10.7:1 on base-100 */
--color-primary: oklch(72% 0.075 295); /* cassis berry purple */
--color-primary-content: oklch(95% 0.01 295); /* pale lavender — 4.5:1 on primary */
--color-secondary: oklch(68% 0.06 10); /* sour cherry rose */
--color-secondary-content: oklch(92% 0.006 10); /* warm near-white — 4.6:1 on secondary */
--color-accent: oklch(75% 0.045 130); /* tart lime zest */
--color-accent-content: oklch(98.5% 0.01 130); /* near-white — 4.8:1 on accent */
--color-neutral: oklch(18% 0.016 290); /* deep blackened grape */
--color-neutral-content: oklch(88% 0.01 295); /* pale lavender — 9.3:1 on neutral */
--color-info: oklch(62% 0.04 250); /* muted indigo */
--color-info-content: oklch(88% 0.008 250); /* pale indigo-white — 4.8:1 on info */
--color-success: oklch(65% 0.04 145); /* elderberry green */
--color-success-content: oklch(90% 0.008 145); /* pale green-white — 4.7:1 on success */
--color-warning: oklch(70% 0.05 65); /* sour apricot */
--color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */
--color-error: oklch(50% 0.055 22); /* kriek red */
--color-error-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */
--color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */
--color-surface-content: oklch(90% 0.014 300); /* pale lavender-white — 10.4:1 on surface */
--color-muted: oklch(
77.6% 0.022 300
); /* desaturated lavender — 4.6:1 on base-100, 4.5:1 on surface */
--color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */
--color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white — 10.1:1 on highlight */
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 1;
}
/* ─────────────────────────────────────────
BIERGARTEN WEIZEN
Light. Near-white barley-green base,
fresh-cut barley primary, sage secondary.
───────────────────────────────────────── */
@plugin "daisyui/theme" {
name: 'biergarten-weizen';
default: false;
prefersdark: false;
color-scheme: 'light';
--color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */
--color-base-200: oklch(96% 0.012 114); /* pale barley wash */
--color-base-300: oklch(92% 0.019 116); /* light straw */
--color-base-content: oklch(20% 0.022 122); /* deep green-black — 19.5:1 on base-100 */
--color-primary: oklch(52% 0.085 118); /* fresh-cut barley green */
--color-primary-content: oklch(97% 0.005 118); /* near-white — 12.5:1 on primary */
--color-secondary: oklch(44% 0.055 128); /* muted sage stem */
--color-secondary-content: oklch(97% 0.005 128); /* near-white — 14.8:1 on secondary */
--color-accent: oklch(93% 0.03 148); /* pale morning dew */
--color-accent-content: oklch(22% 0.022 148); /* deep green — 13.4:1 on accent */
--color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */
--color-neutral-content: oklch(98.9% 0.005 118); /* near-white — 4.6:1 on neutral */
--color-info: oklch(38% 0.065 232); /* clear summer sky */
--color-info-content: oklch(98% 0.005 232); /* near-white — 16.8:1 on info */
--color-success: oklch(38% 0.085 145); /* young shoot green */
--color-success-content: oklch(98% 0.005 145); /* near-white — 16.8:1 on success */
--color-warning: oklch(68% 0.1 76); /* ripening grain amber */
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */
--color-error: oklch(52% 0.1 18); /* dusty rose red */
--color-error-content: oklch(98% 0.005 15); /* near-white — 12.5:1 on error */
--color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */
--color-surface-content: oklch(20% 0.022 122); /* deep green-black — 14.0:1 on surface */
--color-muted: oklch(38% 0.055 120); /* sage green — 18.1:1 on base-100, 13.0:1 on surface */
--color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */
--color-highlight-content: oklch(20% 0.022 122); /* deep green-black — 7.8:1 on highlight */
--radius-selector: 2rem;
--radius-field: 2rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}

View File

@@ -0,0 +1,144 @@
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
} from '@headlessui/react';
import { Link } from 'react-router';
interface NavbarProps {
auth: {
username: string;
accessToken: string;
refreshToken: string;
userAccountId: string;
} | null;
}
export default function Navbar({ auth }: NavbarProps) {
const navLinks = [
{ to: '/theme', label: 'Theme' },
{ to: '/beers', label: 'Beers' },
{ to: '/breweries', label: 'Breweries' },
{ to: '/beer-styles', label: 'Beer Styles' },
];
return (
<Disclosure
as="nav"
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
>
{({ open }) => (
<>
<div className="navbar mx-auto max-w-7xl px-2 sm:px-4">
<div className="navbar-start gap-2">
<DisclosureButton
className="btn btn-ghost btn-square lg:hidden"
aria-label="Toggle navigation"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="h-5 w-5 stroke-current"
>
{open ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
)}
</svg>
</DisclosureButton>
<Link to="/" className="text-xl font-bold">
🍺 The Biergarten App
</Link>
</div>
<div className="navbar-center hidden lg:flex gap-2">
{navLinks.map((link) => (
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm">
{link.label}
</Link>
))}
</div>
<div className="navbar-end gap-2">
{!auth && (
<Link to="/register" className="btn btn-ghost btn-sm hidden sm:inline-flex">
Register User
</Link>
)}
{auth ? (
<>
<Link to="/dashboard" className="btn btn-primary btn-sm">
Dashboard
</Link>
<Menu as="div" className="relative">
<MenuButton className="btn btn-ghost btn-sm">
{auth.username}
</MenuButton>
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
<MenuItem>
{({ focus }) => (
<Link to="/dashboard" className={focus ? 'active' : ''}>
Dashboard
</Link>
)}
</MenuItem>
<MenuItem>
{({ focus }) => (
<Link to="/logout" className={focus ? 'active' : ''}>
Logout
</Link>
)}
</MenuItem>
</MenuItems>
</Menu>
</>
) : (
<Link to="/login" className="btn btn-primary btn-sm">
Login
</Link>
)}
</div>
</div>
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
<div className="flex flex-col gap-2">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="btn btn-ghost btn-sm justify-start"
>
{link.label}
</Link>
))}
{!auth && (
<Link to="/register" className="btn btn-ghost btn-sm justify-start">
Register User
</Link>
)}
</div>
</DisclosurePanel>
</>
)}
</Disclosure>
);
}

View File

@@ -0,0 +1,40 @@
import { Description, Field, Label } from '@headlessui/react';
type FormFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
label: string;
error?: string;
hint?: string;
labelClassName?: string;
inputClassName?: string;
hintClassName?: string;
};
export default function FormField({
label,
error,
hint,
className,
labelClassName,
inputClassName,
hintClassName,
...inputProps
}: FormFieldProps) {
return (
<Field className={className ?? 'space-y-1'}>
<Label htmlFor={inputProps.id} className={labelClassName ?? 'label font-medium'}>
{label}
</Label>
<input
{...inputProps}
className={inputClassName ?? `input w-full ${error ? 'input-error' : ''}`}
/>
{error ? (
<Description className={hintClassName ?? 'label text-error'}>{error}</Description>
) : hint ? (
<Description className={hintClassName ?? 'label'}>{hint}</Description>
) : null}
</Field>
);
}

View File

@@ -0,0 +1,31 @@
import { Button } from '@headlessui/react';
interface SubmitButtonProps {
isSubmitting: boolean;
idleText: string;
submittingText: string;
className?: string;
}
export default function SubmitButton({
isSubmitting,
idleText,
submittingText,
className,
}: SubmitButtonProps) {
return (
<Button
type="submit"
disabled={isSubmitting}
className={className ?? 'btn btn-primary w-full mt-2'}
>
{isSubmitting ? (
<>
<span className="loading loading-spinner loading-sm" /> {submittingText}
</>
) : (
idleText
)}
</Button>
);
}

View File

@@ -0,0 +1,25 @@
import { Toaster } from 'react-hot-toast';
export default function ToastProvider() {
return (
<Toaster
position="top-right"
toastOptions={{
duration: 3500,
className: 'rounded-box border border-base-300 bg-base-100 text-base-content shadow-lg',
success: {
iconTheme: {
primary: 'var(--color-success)',
secondary: 'var(--color-success-content)',
},
},
error: {
iconTheme: {
primary: 'var(--color-error)',
secondary: 'var(--color-error-content)',
},
},
}}
/>
);
}

View File

@@ -0,0 +1,6 @@
import toast from 'react-hot-toast';
export const showSuccessToast = (message: string) => toast.success(message);
export const showErrorToast = (message: string) => toast.error(message);
export const showInfoToast = (message: string) => toast(message);
export const dismissToasts = () => toast.dismiss();

View File

@@ -0,0 +1,162 @@
import { createCookieSessionStorage, redirect } from 'react-router';
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
export interface AuthTokens {
accessToken: string;
refreshToken: string;
userAccountId: string;
username: string;
}
interface ApiResponse<T> {
message: string;
payload: T;
}
interface LoginPayload {
userAccountId: string;
username: string;
refreshToken: string;
accessToken: string;
}
interface RegistrationPayload extends LoginPayload {
confirmationEmailSent: boolean;
}
const sessionStorage = createCookieSessionStorage({
cookie: {
name: '__session',
httpOnly: true,
maxAge: 60 * 60 * 24 * 21, // 21 days (matches refresh token)
path: '/',
sameSite: 'lax',
secrets: [process.env.SESSION_SECRET || 'dev-secret-change-me'],
secure: process.env.NODE_ENV === 'production',
},
});
export async function getSession(request: Request) {
return sessionStorage.getSession(request.headers.get('Cookie'));
}
export async function commitSession(session: Awaited<ReturnType<typeof getSession>>) {
return sessionStorage.commitSession(session);
}
export async function destroySession(session: Awaited<ReturnType<typeof getSession>>) {
return sessionStorage.destroySession(session);
}
export async function requireAuth(request: Request): Promise<AuthTokens> {
const session = await getSession(request);
const accessToken = session.get('accessToken');
const refreshToken = session.get('refreshToken');
if (!accessToken || !refreshToken) {
throw redirect('/login');
}
return {
accessToken,
refreshToken,
userAccountId: session.get('userAccountId'),
username: session.get('username'),
};
}
export async function getOptionalAuth(request: Request): Promise<AuthTokens | null> {
const session = await getSession(request);
const accessToken = session.get('accessToken');
if (!accessToken) return null;
return {
accessToken,
refreshToken: session.get('refreshToken'),
userAccountId: session.get('userAccountId'),
username: session.get('username'),
};
}
export async function login(username: string, password: string) {
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Login failed (${res.status})`);
}
const data: ApiResponse<LoginPayload> = await res.json();
return data.payload;
}
export async function register(body: {
username: string;
firstName: string;
lastName: string;
email: string;
dateOfBirth: string;
password: string;
}) {
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Registration failed (${res.status})`);
}
const data: ApiResponse<RegistrationPayload> = await res.json();
return data.payload;
}
export async function refreshTokens(refreshToken: string) {
const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
throw new Error('Token refresh failed');
}
const data: ApiResponse<LoginPayload> = await res.json();
return data.payload;
}
export async function confirmEmail(token: string, accessToken: string) {
const res = await fetch(`${API_BASE_URL}/api/auth/confirm?token=${encodeURIComponent(token)}`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Confirmation failed (${res.status})`);
}
const data: ApiResponse<{ userAccountId: string; confirmedDate: string }> = await res.json();
return data.payload;
}
export async function createAuthSession(payload: LoginPayload, redirectTo: string) {
const session = await sessionStorage.getSession();
session.set('accessToken', payload.accessToken);
session.set('refreshToken', payload.refreshToken);
session.set('userAccountId', payload.userAccountId);
session.set('username', payload.username);
return redirect(redirectTo, {
headers: { 'Set-Cookie': await commitSession(session) },
});
}

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
export const loginSchema = z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
});
export type LoginSchema = z.infer<typeof loginSchema>;
export const registerSchema = z
.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters'),
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Invalid email address'),
dateOfBirth: z.string().min(1, 'Date of birth is required'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/[0-9]/, 'Password must contain a number'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords must match',
path: ['confirmPassword'],
});
export type RegisterSchema = z.infer<typeof registerSchema>;

View File

@@ -0,0 +1,41 @@
export type ThemeName =
| 'biergarten-lager'
| 'biergarten-stout'
| 'biergarten-cassis'
| 'biergarten-weizen';
export interface ThemeOption {
value: ThemeName;
label: string;
vibe: string;
}
export const defaultThemeName: ThemeName = 'biergarten-lager';
export const themeStorageKey = 'biergarten-theme';
export const biergartenThemes: ThemeOption[] = [
{
value: 'biergarten-lager',
label: 'Biergarten Lager',
vibe: 'Muted parchment, mellow amber, daytime beer garden',
},
{
value: 'biergarten-stout',
label: 'Biergarten Stout',
vibe: 'Charred barrel, deep roast, cozy evening cellar',
},
{
value: 'biergarten-cassis',
label: 'Biergarten Cassis',
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
},
{
value: 'biergarten-weizen',
label: 'Biergarten Weizen',
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
},
];
export function isBiergartenTheme(value: string | null | undefined): value is ThemeName {
return biergartenThemes.some((theme) => theme.value === value);
}

90
src/Website/app/root.tsx Normal file
View File

@@ -0,0 +1,90 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from 'react-router';
import type { Route } from './+types/root';
import './app.css';
import Navbar from './components/Navbar';
import ToastProvider from './components/toast/ToastProvider';
import { getOptionalAuth } from './lib/auth.server';
export const links: Route.LinksFunction = () => [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..900;1,9..40,100..900&family=Volkhov:ital,wght@0,400;0,700;1,400;1,700&display=swap',
},
];
export const loader = async ({ request }: Route.LoaderArgs) => {
const auth = await getOptionalAuth(request);
return { auth };
};
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App({ loaderData }: Route.ComponentProps) {
const { auth } = loaderData;
return (
<>
<Navbar auth={auth} />
<ToastProvider />
<Outlet />
</>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = 'Oops!';
let details = 'An unexpected error occurred.';
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Error';
details =
error.status === 404
? 'The requested page could not be found.'
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

14
src/Website/app/routes.ts Normal file
View File

@@ -0,0 +1,14 @@
import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('theme', 'routes/theme.tsx'),
route('login', 'routes/login.tsx'),
route('register', 'routes/register.tsx'),
route('logout', 'routes/logout.tsx'),
route('dashboard', 'routes/dashboard.tsx'),
route('confirm', 'routes/confirm.tsx'),
route('beers', 'routes/beers.tsx'),
route('breweries', 'routes/breweries.tsx'),
route('beer-styles', 'routes/beer-styles.tsx'),
] satisfies RouteConfig;

View File

@@ -0,0 +1,16 @@
import type { Route } from './+types/beer-styles';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Beer Styles | The Biergarten App' }];
}
export default function BeerStyles() {
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
<p className="text-base-content/70">Learn about different beer styles.</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import type { Route } from './+types/beers';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Beers | The Biergarten App' }];
}
export default function Beers() {
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-4">Beers</h1>
<p className="text-base-content/70">Explore our collection of beers.</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import type { Route } from './+types/breweries';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Breweries | The Biergarten App' }];
}
export default function Breweries() {
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
<p className="text-base-content/70">Discover our partner breweries.</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { useEffect } from 'react';
import { Link } from 'react-router';
import { showErrorToast, showSuccessToast } from '../components/toast/toast';
import { confirmEmail, requireAuth } from '../lib/auth.server';
import type { Route } from './+types/confirm';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Confirm Email | The Biergarten App' }];
}
export async function loader({ request }: Route.LoaderArgs) {
const auth = await requireAuth(request);
const url = new URL(request.url);
const token = url.searchParams.get('token');
if (!token) {
return { success: false as const, error: 'Missing confirmation token.' };
}
try {
const payload = await confirmEmail(token, auth.accessToken);
return {
success: true as const,
confirmedDate: payload.confirmedDate,
};
} catch (err) {
return {
success: false as const,
error: err instanceof Error ? err.message : 'Confirmation failed.',
};
}
}
export default function Confirm({ loaderData }: Route.ComponentProps) {
useEffect(() => {
if (loaderData.success) {
showSuccessToast('Email confirmed successfully.');
return;
}
showErrorToast(loaderData.error);
}, [loaderData]);
return (
<div className="hero min-h-screen bg-base-200">
<div className="card w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body items-center text-center gap-4">
{loaderData.success ? (
<>
<div className="text-success text-6xl"></div>
<h1 className="card-title text-2xl">Email Confirmed!</h1>
<p className="text-base-content/70">
Your email address has been successfully verified.
</p>
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
<span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
Confirmed at
</span>
<p className="font-mono mt-1">
{new Date(loaderData.confirmedDate).toLocaleString()}
</p>
</div>
<div className="card-actions w-full pt-2">
<Link to="/dashboard" className="btn btn-primary w-full">
Go to Dashboard
</Link>
</div>
</>
) : (
<>
<div className="text-error text-6xl"></div>
<h1 className="card-title text-2xl">Confirmation Failed</h1>
<div role="alert" className="alert alert-error alert-soft w-full">
<span>{loaderData.error}</span>
</div>
<p className="text-base-content/70 text-sm">
The confirmation link may have expired (valid for 30 minutes) or already
been used.
</p>
<div className="card-actions w-full pt-2 flex-col gap-2">
<Link to="/dashboard" className="btn btn-primary w-full">
Back to Dashboard
</Link>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { requireAuth } from '../lib/auth.server';
import type { Route } from './+types/dashboard';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Dashboard | The Biergarten App' }];
}
export async function loader({ request }: Route.LoaderArgs) {
const auth = await requireAuth(request);
return {
username: auth.username,
userAccountId: auth.userAccountId,
};
}
export default function Dashboard({ loaderData }: Route.ComponentProps) {
const { username, userAccountId } = loaderData;
return (
<div className="min-h-screen bg-base-200">
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title text-2xl">Welcome, {username}!</h2>
<p className="text-base-content/70">
You are successfully authenticated. This is a protected page that requires a
valid session.
</p>
<div className="bg-base-200 rounded-box p-4 mt-2">
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
Session Info
</p>
<div className="stats stats-vertical w-full">
<div className="stat py-2">
<div className="stat-title">Username</div>
<div className="stat-value text-lg font-mono">{username}</div>
</div>
<div className="stat py-2">
<div className="stat-title">User ID</div>
<div className="stat-desc font-mono text-xs mt-1">{userAccountId}</div>
</div>
</div>
</div>
</div>
</div>
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title">Auth Flow Demo</h2>
<p className="text-sm text-base-content/70">
This demo showcases the following authentication features:
</p>
<ul className="list">
<li className="list-row">
<div>
<p className="font-semibold">Login</p>
<p className="text-sm text-base-content/60">
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
username &amp; password
</p>
</div>
</li>
<li className="list-row">
<div>
<p className="font-semibold">Register</p>
<p className="text-sm text-base-content/60">
POST to <code className="kbd kbd-sm">/api/auth/register</code> with
full user details
</p>
</div>
</li>
<li className="list-row">
<div>
<p className="font-semibold">Session</p>
<p className="text-sm text-base-content/60">
JWT access &amp; refresh tokens stored in an HTTP-only cookie
</p>
</div>
</li>
<li className="list-row">
<div>
<p className="font-semibold">Protected Routes</p>
<p className="text-sm text-base-content/60">
This dashboard requires authentication via{' '}
<code className="kbd kbd-sm">requireAuth()</code>
</p>
</div>
</li>
<li className="list-row">
<div>
<p className="font-semibold">Token Refresh</p>
<p className="text-sm text-base-content/60">
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with
refresh token
</p>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { Link } from 'react-router';
import { getOptionalAuth } from '../lib/auth.server';
import type { Route } from './+types/home';
export function meta({}: Route.MetaArgs) {
return [
{ title: 'The Biergarten App' },
{ name: 'description', content: 'Welcome to The Biergarten App' },
];
}
export async function loader({ request }: Route.LoaderArgs) {
const auth = await getOptionalAuth(request);
return { username: auth?.username ?? null };
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { username } = loaderData;
return (
<div className="hero min-h-screen bg-base-200">
<div className="hero-content text-center">
<div className="max-w-md space-y-6">
<h1 className="text-5xl font-bold">🍺 The Biergarten App</h1>
<p className="text-lg text-base-content/70">Authentication Demo</p>
{username ? (
<>
<p className="text-base-content/80">
Welcome back, <span className="font-semibold text-primary">{username}</span>
!
</p>
<div className="flex gap-3 justify-center">
<Link to="/dashboard" className="btn btn-primary">
Dashboard
</Link>
<Link to="/logout" className="btn btn-ghost">
Logout
</Link>
</div>
</>
) : (
<div className="flex gap-3 justify-center">
<Link to="/login" className="btn btn-primary">
Login
</Link>
<Link to="/register" className="btn btn-outline">
Register
</Link>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { HomeSimpleDoor, LogIn, UserPlus } from 'iconoir-react';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Link, redirect, useNavigation, useSubmit } from 'react-router';
import FormField from '../components/forms/FormField';
import SubmitButton from '../components/forms/SubmitButton';
import { showErrorToast } from '../components/toast/toast';
import { createAuthSession, getOptionalAuth, login } from '../lib/auth.server';
import { loginSchema, type LoginSchema } from '../lib/schemas';
import type { Route } from './+types/login';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Login | The Biergarten App' }];
}
export async function loader({ request }: Route.LoaderArgs) {
const auth = await getOptionalAuth(request);
if (auth) throw redirect('/dashboard');
return null;
}
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const result = loginSchema.safeParse({
username: formData.get('username'),
password: formData.get('password'),
});
if (!result.success) {
return { error: result.error.issues[0].message };
}
try {
const payload = await login(result.data.username, result.data.password);
return createAuthSession(payload, '/dashboard');
} catch (err) {
return { error: err instanceof Error ? err.message : 'Login failed.' };
}
}
export default function Login({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const submit = useSubmit();
const isSubmitting = navigation.state === 'submitting';
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginSchema>({ resolver: zodResolver(loginSchema) });
const onSubmit = handleSubmit((data) => {
submit(data, { method: 'post' });
});
useEffect(() => {
if (actionData?.error) {
showErrorToast(actionData.error);
}
}, [actionData?.error]);
return (
<div className="hero min-h-screen bg-base-200">
<div className="card w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body gap-4">
<div className="text-center">
<h1 className="card-title text-3xl justify-center gap-2">
<LogIn className="size-7" aria-hidden="true" />
Login
</h1>
<p className="text-base-content/70">Sign in to your Biergarten account</p>
</div>
{actionData?.error && (
<div role="alert" className="alert alert-error alert-soft">
<span>{actionData.error}</span>
</div>
)}
<form onSubmit={onSubmit} className="space-y-3">
<FormField
id="username"
type="text"
autoComplete="username"
placeholder="your_username"
label="Username"
error={errors.username?.message}
{...register('username')}
/>
<FormField
id="password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
label="Password"
error={errors.password?.message}
{...register('password')}
/>
<SubmitButton
isSubmitting={isSubmitting}
idleText="Sign In"
submittingText="Signing in..."
/>
</form>
<div className="divider text-xs">New here?</div>
<div className="text-center space-y-2">
<Link to="/register" className="btn btn-outline btn-sm w-full gap-2">
<UserPlus className="size-4" aria-hidden="true" />
Create an account
</Link>
<Link
to="/"
className="link link-hover text-sm text-base-content/60 inline-flex items-center gap-1"
>
<HomeSimpleDoor className="size-4" aria-hidden="true" />
Back to home
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { redirect } from 'react-router';
import { destroySession, getSession } from '../lib/auth.server';
import type { Route } from './+types/logout';
export async function loader({ request }: Route.LoaderArgs) {
const session = await getSession(request);
return redirect('/', {
headers: { 'Set-Cookie': await destroySession(session) },
});
}

View File

@@ -0,0 +1,189 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Link, redirect, useNavigation, useSubmit } from 'react-router';
import FormField from '../components/forms/FormField';
import SubmitButton from '../components/forms/SubmitButton';
import { showErrorToast } from '../components/toast/toast';
import { createAuthSession, getOptionalAuth, register } from '../lib/auth.server';
import { registerSchema, type RegisterSchema } from '../lib/schemas';
import type { Route } from './+types/register';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Register | The Biergarten App' }];
}
export async function loader({ request }: Route.LoaderArgs) {
const auth = await getOptionalAuth(request);
if (auth) throw redirect('/dashboard');
return null;
}
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const result = registerSchema.safeParse({
username: formData.get('username'),
firstName: formData.get('firstName'),
lastName: formData.get('lastName'),
email: formData.get('email'),
dateOfBirth: formData.get('dateOfBirth'),
password: formData.get('password'),
confirmPassword: formData.get('confirmPassword'),
});
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors;
return { error: null, fieldErrors };
}
try {
const body = {
username: result.data.username,
firstName: result.data.firstName,
lastName: result.data.lastName,
email: result.data.email,
dateOfBirth: result.data.dateOfBirth,
password: result.data.password,
};
const payload = await register(body);
return createAuthSession(payload, '/dashboard');
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Registration failed.',
fieldErrors: null,
};
}
}
export default function Register({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const submit = useSubmit();
const isSubmitting = navigation.state === 'submitting';
const {
register: field,
handleSubmit,
formState: { errors },
} = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
const onSubmit = handleSubmit((data) => {
submit(data, { method: 'post' });
});
useEffect(() => {
if (actionData?.error) {
showErrorToast(actionData.error);
}
}, [actionData?.error]);
return (
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div className="card w-full max-w-lg bg-base-100 shadow-xl">
<div className="card-body gap-4">
<div className="text-center">
<h1 className="card-title text-3xl justify-center">Register</h1>
<p className="text-base-content/70">Create your Biergarten account</p>
</div>
{actionData?.error && (
<div role="alert" className="alert alert-error alert-soft">
<span>{actionData.error}</span>
</div>
)}
<form onSubmit={onSubmit} className="space-y-3">
<FormField
id="username"
type="text"
autoComplete="username"
placeholder="your_username"
label="Username"
hint="3-64 characters, alphanumeric and . _ -"
error={errors.username?.message}
{...field('username')}
/>
<div className="grid grid-cols-2 gap-3">
<FormField
id="firstName"
type="text"
autoComplete="given-name"
placeholder="Jane"
label="First Name"
error={errors.firstName?.message}
{...field('firstName')}
/>
<FormField
id="lastName"
type="text"
autoComplete="family-name"
placeholder="Doe"
label="Last Name"
error={errors.lastName?.message}
{...field('lastName')}
/>
</div>
<FormField
id="email"
type="email"
autoComplete="email"
placeholder="jane@example.com"
label="Email"
error={errors.email?.message}
{...field('email')}
/>
<FormField
id="dateOfBirth"
type="date"
label="Date of Birth"
hint="Must be 19 years or older"
error={errors.dateOfBirth?.message}
{...field('dateOfBirth')}
/>
<FormField
id="password"
type="password"
autoComplete="new-password"
placeholder="••••••••"
label="Password"
hint="8+ chars: uppercase, lowercase, digit, special character"
error={errors.password?.message}
{...field('password')}
/>
<FormField
id="confirmPassword"
type="password"
autoComplete="new-password"
placeholder="••••••••"
label="Confirm Password"
error={errors.confirmPassword?.message}
{...field('confirmPassword')}
/>
<SubmitButton
isSubmitting={isSubmitting}
idleText="Create Account"
submittingText="Creating account..."
/>
</form>
<div className="divider text-xs">Already have an account?</div>
<div className="text-center space-y-2">
<Link to="/login" className="btn btn-outline btn-sm w-full">
Sign in
</Link>
<Link to="/" className="link link-hover text-sm text-base-content/60">
Back to home
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react';
import {
biergartenThemes,
defaultThemeName,
isBiergartenTheme,
themeStorageKey,
type ThemeName,
} from '../lib/themes';
import type { Route } from './+types/theme';
export function meta({}: Route.MetaArgs) {
return [
{ title: 'Theme | The Biergarten App' },
{
name: 'description',
content: 'Theme guide and switcher for The Biergarten App',
},
];
}
function applyTheme(theme: ThemeName) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(themeStorageKey, theme);
}
export default function ThemePage() {
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(() => {
if (typeof window === 'undefined') {
return defaultThemeName;
}
const savedTheme = localStorage.getItem(themeStorageKey);
return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
});
useEffect(() => {
applyTheme(selectedTheme);
}, [selectedTheme]);
const activeTheme =
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
return (
<main className="min-h-screen bg-base-200 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<section className="card border border-base-300 bg-base-100 shadow-xl">
<div className="card-body gap-4">
<h1 className="card-title text-3xl sm:text-4xl">Theme Guide</h1>
<p className="text-base-content/70">
Four themes, four moods from the sun-bleached clarity of a Weizen afternoon
to the deep berry dark of a Cassis barrel. Every theme shares the same semantic
token structure so components stay consistent while the atmosphere shifts
completely.
</p>
<div className="alert alert-info alert-soft">
<span>
Active theme: <strong>{activeTheme.label}</strong> {activeTheme.vibe}
</span>
</div>
</div>
</section>
<section className="card border border-base-300 bg-base-100 shadow-xl">
<div className="card-body gap-4">
<h2 className="card-title text-2xl">Theme switcher</h2>
<p className="text-base-content/70">Pick a theme and preview it immediately.</p>
<div
className="join join-vertical sm:join-horizontal"
role="radiogroup"
aria-label="Theme selector"
>
{biergartenThemes.map((theme) => {
const checked = selectedTheme === theme.value;
return (
<label
key={theme.value}
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
>
<input
type="radio"
name="theme"
value={theme.value}
className="sr-only"
checked={checked}
onChange={() => {
setSelectedTheme(theme.value);
applyTheme(theme.value);
}}
/>
{theme.label}
</label>
);
})}
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="card border border-base-300 bg-base-100 shadow-lg">
<div className="card-body">
<h3 className="card-title">Brand colors</h3>
<div className="grid grid-cols-2 gap-2 text-sm font-medium">
<div className="rounded-box bg-primary p-3 text-primary-content">
Primary
</div>
<div className="rounded-box bg-secondary p-3 text-secondary-content">
Secondary
</div>
<div className="rounded-box bg-accent p-3 text-accent-content">Accent</div>
<div className="rounded-box bg-neutral p-3 text-neutral-content">
Neutral
</div>
</div>
</div>
</div>
<div className="card border border-base-300 bg-base-100 shadow-lg">
<div className="card-body">
<h3 className="card-title">Status colors</h3>
<div className="space-y-2 text-sm font-medium">
<div className="rounded-box bg-info p-3 text-info-content">Info</div>
<div className="rounded-box bg-success p-3 text-success-content">
Success
</div>
<div className="rounded-box bg-warning p-3 text-warning-content">
Warning
</div>
<div className="rounded-box bg-error p-3 text-error-content">Error</div>
</div>
</div>
</div>
<div className="card border border-base-300 bg-base-100 shadow-lg md:col-span-2 xl:col-span-1">
<div className="card-body">
<h3 className="card-title">Core style outline</h3>
<ul className="list list-disc space-y-2 pl-5 text-base-content/80">
<li>Warm serif headings paired with clear sans-serif body text</li>
<li>Rounded, tactile surfaces with subtle depth and grain</li>
<li>Semantic token usage to keep contrast consistent in both themes</li>
</ul>
</div>
</div>
</section>
<section className="card border border-base-300 bg-base-100 shadow-xl">
<div className="card-body gap-4">
<h2 className="card-title text-2xl">Component preview</h2>
<div className="flex flex-wrap gap-3">
<button className="btn btn-primary">Primary action</button>
<button className="btn btn-secondary">Secondary action</button>
<button className="btn btn-accent">Accent action</button>
<button className="btn btn-ghost">Ghost action</button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div role="alert" className="alert alert-success alert-soft">
<span>Theme tokens are applied consistently.</span>
</div>
<div role="alert" className="alert alert-warning alert-soft">
<span>Use semantic colors over hard-coded color values.</span>
</div>
</div>
</div>
</section>
</div>
</main>
);
}

View File

@@ -0,0 +1,52 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from 'eslint-plugin-storybook';
import js from '@eslint/js';
import prettierConfig from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['build/**', 'node_modules/**', '.react-router/**', 'coverage/**'],
},
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
...tseslint.configs.recommended,
...tseslint.configs.stylistic,
],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
'react-hooks': reactHooks,
},
rules: {
...reactHooks.configs.recommended.rules,
'no-empty-pattern': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
prettierConfig,
storybook.configs['flat/recommended'],
);

View File

@@ -1,15 +0,0 @@
/** @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);

File diff suppressed because it is too large Load Diff

View File

@@ -1,98 +1,68 @@
{
"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"
}
"name": "biergarten-website",
"type": "module",
"version": "0.0.0",
"scripts": {
"dev": "react-router dev",
"build": "react-router build",
"start": "NODE_ENV=production node ./build/server/index.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
"format:check": "prettier . --check",
"typegen": "react-router typegen",
"typecheck": "npm run typegen && tsc -p tsconfig.json",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test:storybook": "vitest run --project storybook",
"test:storybook:playwright": "playwright test -c playwright.storybook.config.ts"
},
"dependencies": {
"@headlessui/react": "^2.2.9",
"@hookform/resolvers": "^5.2.2",
"@react-router/dev": "^7.13.1",
"@react-router/express": "^7.13.1",
"@react-router/node": "^7.13.1",
"iconoir-react": "^7.11.0",
"isbot": "^5.1.36",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"react-hot-toast": "^2.6.0",
"react-router": "^7.13.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@eslint/js": "^9.0.0",
"@playwright/test": "^1.58.2",
"@storybook/addon-a11y": "^10.2.19",
"@storybook/addon-docs": "^10.2.19",
"@storybook/addon-onboarding": "^10.2.19",
"@storybook/addon-vitest": "^10.2.19",
"@storybook/react-vite": "^10.2.19",
"@tailwindcss/postcss": "^4.2.1",
"@types/express": "^5.0.6",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitest/browser-playwright": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0",
"autoprefixer": "^10.4.27",
"daisyui": "^5.5.19",
"eslint": "^9.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-storybook": "^10.2.19",
"globals": "^17.4.0",
"playwright": "^1.58.2",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"storybook": "^10.2.19",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^7.0.0",
"vitest": "^4.1.0"
}
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from '@playwright/test';
const port = process.env.STORYBOOK_PORT ?? '6006';
const baseURL = process.env.STORYBOOK_URL ?? `http://127.0.0.1:${port}`;
export default defineConfig({
testDir: './tests/playwright',
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
use: {
baseURL,
trace: 'on-first-retry',
},
webServer: {
command: `npm run storybook -- --ci --port ${port}`,
url: baseURL,
reuseExistingServer: true,
timeout: 120_000,
},
});

View File

@@ -1,6 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

View File

@@ -1,6 +0,0 @@
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/)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,11 +0,0 @@
{
"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"
}

View File

@@ -1,5 +0,0 @@
User-agent: *
Disallow: /api/
Disallow: /login/
Disallow: /register/
Disallow: /users/

View File

@@ -0,0 +1,5 @@
import type { Config } from '@react-router/dev/config';
export default {
ssr: true,
} satisfies Config;

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 256 KiB

View File

@@ -1,176 +0,0 @@
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;

View File

@@ -1,82 +0,0 @@
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;

View File

@@ -1,84 +0,0 @@
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;

View File

@@ -1,96 +0,0 @@
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;

View File

@@ -1,101 +0,0 @@
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;

View File

@@ -1,93 +0,0 @@
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;

View File

@@ -1,29 +0,0 @@
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;

View File

@@ -1,39 +0,0 @@
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;

View File

@@ -1,29 +0,0 @@
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;

View File

@@ -1,61 +0,0 @@
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;

View File

@@ -1,109 +0,0 @@
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;

View File

@@ -1,88 +0,0 @@
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;

View File

@@ -1,35 +0,0 @@
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;

View File

@@ -1,33 +0,0 @@
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;

View File

@@ -1,106 +0,0 @@
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;

View File

@@ -1,73 +0,0 @@
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;

View File

@@ -1,100 +0,0 @@
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;

View File

@@ -1,65 +0,0 @@
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;

View File

@@ -1,86 +0,0 @@
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;

View File

@@ -1,112 +0,0 @@
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;

View File

@@ -1,34 +0,0 @@
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;

View File

@@ -1,48 +0,0 @@
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;

View File

@@ -1,111 +0,0 @@
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;

View File

@@ -1,60 +0,0 @@
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;

View File

@@ -1,88 +0,0 @@
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;

View File

@@ -1,100 +0,0 @@
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;

View File

@@ -1,60 +0,0 @@
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;

View File

@@ -1,64 +0,0 @@
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;

View File

@@ -1,30 +0,0 @@
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;

View File

@@ -1,285 +0,0 @@
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;

View File

@@ -1,54 +0,0 @@
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;

View File

@@ -1,55 +0,0 @@
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;

View File

@@ -1,77 +0,0 @@
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;

View File

@@ -1,85 +0,0 @@
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;

View File

@@ -1,19 +0,0 @@
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;

View File

@@ -1,22 +0,0 @@
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;

View File

@@ -1,125 +0,0 @@
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;

View File

@@ -1,168 +0,0 @@
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;

View File

@@ -1,13 +0,0 @@
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;

View File

@@ -1,12 +0,0 @@
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>;

Some files were not shown because too many files have changed in this diff Show More