diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 3365bc6..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,136 +0,0 @@ -/** - * This is intended to be a basic starting point for linting in the Indie Stack. - * It relies on recommended configs out of the box for simplicity, but you can - * and should modify this configuration to best suit your team's needs. - */ - -/** @type {import('eslint').Linter.Config} */ -module.exports = { - root: true, - parserOptions: { - ecmaVersion: "latest", - sourceType: "module", - ecmaFeatures: { - jsx: true, - }, - }, - env: { - browser: true, - commonjs: true, - es6: true, - }, - - // Base config - extends: ["eslint:recommended"], - - overrides: [ - // React - { - files: ["**/*.{js,jsx,ts,tsx}"], - plugins: ["react", "jsx-a11y"], - extends: [ - "plugin:react/recommended", - "plugin:react/jsx-runtime", - "plugin:react-hooks/recommended", - "plugin:jsx-a11y/recommended", - "prettier", - ], - settings: { - react: { - version: "detect", - }, - formComponents: ["Form"], - linkComponents: [ - { name: "Link", linkAttribute: "to" }, - { name: "NavLink", linkAttribute: "to" }, - ], - }, - rules: { - "react/jsx-no-leaked-render": [ - "warn", - { validStrategies: ["ternary"] }, - ], - }, - }, - - // Typescript - { - files: ["**/*.{ts,tsx}"], - plugins: ["@typescript-eslint", "import"], - parser: "@typescript-eslint/parser", - settings: { - "import/internal-regex": "^~/", - "import/resolver": { - node: { - extensions: [".ts", ".tsx"], - }, - typescript: { - alwaysTryTypes: true, - }, - }, - }, - extends: [ - "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/stylistic", - "plugin:import/recommended", - "plugin:import/typescript", - "prettier", - ], - rules: { - "import/order": [ - "error", - { - alphabetize: { caseInsensitive: true, order: "asc" }, - groups: ["builtin", "external", "internal", "parent", "sibling"], - "newlines-between": "always", - }, - ], - }, - }, - - // Markdown - { - files: ["**/*.md"], - plugins: ["markdown"], - extends: ["plugin:markdown/recommended", "prettier"], - }, - - // Jest/Vitest - { - files: ["**/*.test.{js,jsx,ts,tsx}"], - plugins: ["jest", "jest-dom", "testing-library"], - extends: [ - "plugin:jest/recommended", - "plugin:jest-dom/recommended", - "plugin:testing-library/react", - "prettier", - ], - env: { - "jest/globals": true, - }, - settings: { - jest: { - // we're using vitest which has a very similar API to jest - // (so the linting plugins work nicely), but it means we have to explicitly - // set the jest version. - version: 28, - }, - }, - }, - - // Cypress - { - files: ["cypress/**/*.ts"], - plugins: ["cypress"], - extends: ["plugin:cypress/recommended", "prettier"], - }, - - // Node - { - files: [".eslintrc.js", "mocks/**/*.js"], - env: { - node: true, - }, - }, - ], -}; diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ae1d2a0..f6619ce 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -122,14 +122,14 @@ jobs: uses: actions/checkout@v4 - name: 👀 Read app name - uses: SebRollen/toml-action@v1.0.2 + uses: SebRollen/toml-action@v1.2.0 id: app_name with: file: fly.toml field: app - name: 🎈 Setup Fly - uses: superfly/flyctl-actions/setup-flyctl@v1.4 + uses: superfly/flyctl-actions/setup-flyctl@1.5 - name: 🚀 Deploy Staging if: ${{ github.ref == 'refs/heads/dev' }} diff --git a/Dockerfile b/Dockerfile index c9048b1..006fe85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,8 +53,8 @@ WORKDIR /myapp COPY --from=production-deps /myapp/node_modules /myapp/node_modules COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma -COPY --from=build /myapp/build /myapp/build -COPY --from=build /myapp/public /myapp/public +COPY --from=build /myapp/build/server /myapp/build/server +COPY --from=build /myapp/build/client /myapp/build/client COPY --from=build /myapp/package.json /myapp/package.json COPY --from=build /myapp/start.sh /myapp/start.sh COPY --from=build /myapp/prisma /myapp/prisma diff --git a/app/components/arrow.tsx b/app/components/arrow.tsx new file mode 100644 index 0000000..72284c8 --- /dev/null +++ b/app/components/arrow.tsx @@ -0,0 +1,31 @@ +import { ArrowRightIcon } from "@radix-ui/react-icons"; +import { type ClassValue } from "clsx"; + +import { cn } from "~/lib/utils"; + +export interface ArrowProps { + orientation?: "up" | "right" | "down" | "left"; + className?: string | ClassValue[]; +} + +export function Arrow({ orientation = "right", className }: ArrowProps) { + return ( + <> +
+ +
+ + ); +} diff --git a/app/components/auto-link.tsx b/app/components/auto-link.tsx new file mode 100644 index 0000000..b7ba7ed --- /dev/null +++ b/app/components/auto-link.tsx @@ -0,0 +1,33 @@ +/** + * This function converts plain text with links into text + * where the links have been replaced with an anchor tag. + * Grabbed this from here: https://www.30secondsofcode.org/react/s/auto-link/ + * @param { text } + * @returns A string of text where the detected links were replaced with anchor tags. + */ +export function AutoLink({ text }: { text: string }) { + const delimiter = + /((?:https?:\/\/)?(?:(?:[a-z0-9]?(?:[a-z0-9\-]{1,61}[a-z0-9])?\.[^\.|\s])+[a-z\.]*[a-z]+|(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3})(?::\d{1,5})*[a-z0-9.,_\/~#&=;%+?\-\\(\\)]*)/gi; + + return ( + <> + {text.split(delimiter).map((word) => { + const match = word.match(delimiter); + if (match) { + const url = match[0]; + return ( + + {url} + + ); + } + return word; + })} + + ); +} diff --git a/app/components/dinner-card.tsx b/app/components/dinner-card.tsx index 6603a1d..5e98a21 100644 --- a/app/components/dinner-card.tsx +++ b/app/components/dinner-card.tsx @@ -2,10 +2,10 @@ import type { Event } from "@prisma/client"; import type { SerializeFrom } from "@remix-run/node"; import { Link } from "@remix-run/react"; -import { getEventImageUrl } from "~/utils"; - import { Button } from "./ui/button"; +import { getEventImageUrl } from "~/utils/misc"; + export function DinnerCard({ event, preferredLocale, @@ -17,30 +17,29 @@ export function DinnerCard({ const imageUrl = getEventImageUrl(event.imageId); return ( -
+
-
-
- {event.title} -
- +
+ +
{event.title}
+

{event.description}

diff --git a/app/components/dinner-view.tsx b/app/components/dinner-view.tsx index 2dbe8ff..9c4caf1 100644 --- a/app/components/dinner-view.tsx +++ b/app/components/dinner-view.tsx @@ -1,7 +1,9 @@ import type { Address, Event } from "@prisma/client"; import { SerializeFrom } from "@remix-run/node"; -import { getEventImageUrl } from "~/utils"; +import { AutoLink } from "./auto-link"; + +import { getEventImageUrl } from "~/utils/misc"; export interface DinnerViewProps { event: @@ -14,39 +16,42 @@ export function DinnerView({ event }: DinnerViewProps) { const imageUrl = getEventImageUrl(event.imageId); return ( -
-
-

{event.title}

-
- -
+
+
-
-
- -
+
+ +
+ +
+

{event.title}

+
+ + {/*

{`${event.address.streetName} ${event.address.houseNumber}`}

{`${event.address.zip} ${event.address.city}`}

-
+
*/}
-
+ {/*

{`Cost, ${event.price} CHF (Non-Profit)`}

-
+
*/}
-

{event.description}

+

+ +

); diff --git a/app/components/footer.tsx b/app/components/footer.tsx index 35de24f..ec5d4f8 100644 --- a/app/components/footer.tsx +++ b/app/components/footer.tsx @@ -3,8 +3,8 @@ import { Link } from "@remix-run/react"; export function Footer() { return ( -
-
+
+

moku pona

    diff --git a/app/components/forms.tsx b/app/components/forms.tsx index 4f437b9..bd6fd75 100644 --- a/app/components/forms.tsx +++ b/app/components/forms.tsx @@ -1,5 +1,7 @@ +import { useInputControl } from "@conform-to/react"; import React, { useId } from "react"; +import { Checkbox, CheckboxProps } from "./ui/checkbox"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; import { Textarea } from "./ui/textarea"; @@ -116,3 +118,62 @@ export function SelectField({
); } + +export function CheckboxField({ + labelProps, + buttonProps, + errors, + className, +}: { + labelProps: React.ComponentProps<"label">; + buttonProps: CheckboxProps & { + name: string; + form: string; + value?: string; + }; + errors?: ListOfErrors; + className?: string; +}) { + const { key, defaultChecked, ...checkboxProps } = buttonProps; + const fallbackId = useId(); + const checkedValue = buttonProps.value ?? "on"; + const input = useInputControl({ + key, + name: buttonProps.name, + formId: buttonProps.form, + initialValue: defaultChecked ? checkedValue : undefined, + }); + const id = buttonProps.id ?? fallbackId; + const errorId = errors?.length ? `${id}-error` : undefined; + + return ( +
+
+ { + input.change(state.valueOf() ? checkedValue : ""); + buttonProps.onCheckedChange?.(state); + }} + onFocus={(event) => { + input.focus(); + buttonProps.onFocus?.(event); + }} + onBlur={(event) => { + input.blur(); + buttonProps.onBlur?.(event); + }} + type="button" + /> +
+
+ {errorId ? : null} +
+
+ ); +} diff --git a/app/components/illustrations.tsx b/app/components/illustrations.tsx new file mode 100644 index 0000000..dfcf8d1 --- /dev/null +++ b/app/components/illustrations.tsx @@ -0,0 +1,191 @@ +import { type ClassValue } from "clsx"; + +import { cn } from "~/lib/utils"; + +export interface IllustrationProps { + className?: string | ClassValue[]; +} + +export function FruitDrinkIllustration({ className }: IllustrationProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function CoffeeIllustration({ className }: IllustrationProps) { + return ( + + + + ); +} diff --git a/app/components/logo.tsx b/app/components/logo.tsx new file mode 100644 index 0000000..832bfdf --- /dev/null +++ b/app/components/logo.tsx @@ -0,0 +1,37 @@ +import { type ClassValue } from "clsx"; + +import { cn } from "~/lib/utils"; + +export interface LogoProps { + className?: string | ClassValue[]; +} + +export function Logo({ className }: LogoProps) { + return ( + + + + + ); +} diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx index a9ecf68..12848d3 100644 --- a/app/components/ui/button.tsx +++ b/app/components/ui/button.tsx @@ -9,8 +9,7 @@ const buttonVariants = cva( { variants: { variant: { - default: - "bg-primary text-primary-foreground shadow hover:bg-primary/90", + default: "bg-accent text-gray-950 hover:bg-accent/90", destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", outline: diff --git a/app/components/ui/checkbox.tsx b/app/components/ui/checkbox.tsx index de5d6d0..f781580 100644 --- a/app/components/ui/checkbox.tsx +++ b/app/components/ui/checkbox.tsx @@ -5,6 +5,13 @@ import * as React from "react"; import { cn } from "~/lib/utils"; +export type CheckboxProps = Omit< + React.ComponentPropsWithoutRef, + "type" +> & { + type?: string; +}; + const Checkbox = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { diff --git a/app/components/ui/input.tsx b/app/components/ui/input.tsx index f40eb96..8b2e8a7 100644 --- a/app/components/ui/input.tsx +++ b/app/components/ui/input.tsx @@ -2,7 +2,6 @@ import * as React from "react"; import { cn } from "~/lib/utils"; -// eslint-disable-next-line @typescript-eslint/no-empty-interface export interface InputProps extends React.InputHTMLAttributes {} diff --git a/app/components/ui/sonner.tsx b/app/components/ui/sonner.tsx new file mode 100644 index 0000000..69108b5 --- /dev/null +++ b/app/components/ui/sonner.tsx @@ -0,0 +1,25 @@ +import { Toaster as Sonner } from "sonner"; + +type ToasterProps = React.ComponentProps; + +const Toaster = ({ ...props }: ToasterProps) => { + return ( + + ); +}; + +export { Toaster }; diff --git a/app/components/ui/textarea.tsx b/app/components/ui/textarea.tsx index eff65c5..61e2d2a 100644 --- a/app/components/ui/textarea.tsx +++ b/app/components/ui/textarea.tsx @@ -2,7 +2,6 @@ import * as React from "react"; import { cn } from "~/lib/utils"; -// eslint-disable-next-line @typescript-eslint/no-empty-interface export interface InputProps extends React.InputHTMLAttributes {} diff --git a/app/db.server.ts b/app/db.server.ts index ac5701f..123b8d1 100644 --- a/app/db.server.ts +++ b/app/db.server.ts @@ -1,6 +1,6 @@ import { PrismaClient } from "@prisma/client"; -import { singleton } from "./singleton.server"; +import { singleton } from "./utils/singleton.server"; // Hard-code a unique key, so we can look up the client when this module gets re-imported const prisma = singleton("prisma", () => new PrismaClient()); diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 86c5fc9..cb1f5d7 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -9,7 +9,7 @@ import { PassThrough } from "node:stream"; import type { EntryContext } from "@remix-run/node"; import { createReadableStreamFromReadable } from "@remix-run/node"; import { RemixServer } from "@remix-run/react"; -import isbot from "isbot"; +import { isbot } from "isbot"; import { renderToPipeableStream } from "react-dom/server"; const ABORT_DELAY = 5_000; diff --git a/app/hooks/useToast.tsx b/app/hooks/useToast.tsx new file mode 100644 index 0000000..4cb4db6 --- /dev/null +++ b/app/hooks/useToast.tsx @@ -0,0 +1,16 @@ +import { useEffect } from "react"; +import { toast as showToast } from "sonner"; + +import type { Toast } from "~/utils/toast.server"; + +export function useToast(toast?: Toast | null) { + useEffect(() => { + if (toast) { + setTimeout(() => { + showToast[toast.type](toast.title, { + description: toast.description, + }); + }, 0); + } + }, [toast]); +} diff --git a/app/lib/csv-builder.server.ts b/app/lib/csv-builder.server.ts new file mode 100644 index 0000000..9e03aee --- /dev/null +++ b/app/lib/csv-builder.server.ts @@ -0,0 +1,58 @@ +const newLine = "\n"; + +export interface CSVReturnObject { + // Mime type, always "text/csv" + mimeType: "text/csv"; + + // The size of the content string in characters/bytes + size: number; + + // The concatenated string of values + data: string; +} + +export function buildCSVObject( + header: string[], + values: string[][], + separator = ",", +): CSVReturnObject { + const combinedArray = [[...header], ...values]; + const sanitizedArray = combinedArray.map((valueArray) => { + return valueArray.map((value) => sanitizeCSVValue(value)); + }); + + const data = nestedArrayToCSVString(sanitizedArray, separator); + return { + mimeType: "text/csv", + size: data.length, + data, + }; +} + +function sanitizeCSVValue(value: string) { + const valuesToSanitize = [",", "\n"]; + let needsSanitization = false; + + valuesToSanitize.forEach((sanitizeValue) => { + if (value.includes(sanitizeValue)) { + needsSanitization = true; + } + }); + + if (needsSanitization) return '"' + value + '"'; + return value; +} + +function arrayToCSVString(array: string[], separator = ","): string { + return [...array, newLine].join(separator); +} + +function nestedArrayToCSVString(array: string[][], separator = ","): string { + let text = ""; + + array.forEach((nestedArray) => { + text += arrayToCSVString(nestedArray, separator); + }); + + return text; +} diff --git a/app/models/event-response.server.ts b/app/models/event-response.server.ts index 276839a..c9829cb 100644 --- a/app/models/event-response.server.ts +++ b/app/models/event-response.server.ts @@ -4,12 +4,22 @@ export async function createEventResponse( eventId: string, name: string, email: string, + phone: string, + vegetarian = false, + student = false, + restrictions?: string, + comment?: string, ) { return prisma.eventResponse.create({ data: { name, email, + phone, eventId, + vegetarian, + student, + restrictions, + comment, }, }); } diff --git a/app/models/user.server.ts b/app/models/user.server.ts index 61f045a..611183f 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -68,7 +68,6 @@ export async function verifyLogin( return null; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { password: _password, ...userWithoutPassword } = userWithPassword; return userWithoutPassword; diff --git a/app/optimize-images.ts b/app/optimize-images.ts new file mode 100644 index 0000000..829dfcc --- /dev/null +++ b/app/optimize-images.ts @@ -0,0 +1,47 @@ +import path from "node:path"; + +import sharp from "sharp"; + +const landingPagePath = path.join( + __dirname, + "..", + "public", + "landing-page.jpg", +); + +async function optimize() { + const variants = [ + { + size: "original", + width: 1080, + }, + { + size: "lg", + width: 864, + }, + { + size: "md", + width: 648, + }, + { + size: "sm", + width: 432, + }, + ]; + + variants.forEach(async ({ size, width }) => { + const optimizedPath = path.join( + __dirname, + "..", + "public", + `landing-page-${size}.webp`, + ); + + await sharp(landingPagePath).resize(width).webp().toFile(optimizedPath); + }); +} + +optimize().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/app/root.tsx b/app/root.tsx index 584a189..76409bd 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,4 +1,4 @@ -import { cssBundleHref } from "@remix-run/css-bundle"; +import { HamburgerMenuIcon, InstagramLogoIcon } from "@radix-ui/react-icons"; import type { LinksFunction, LoaderFunctionArgs, @@ -9,20 +9,17 @@ import { Form, Link, Links, - LiveReload, Meta, Outlet, Scripts, ScrollRestoration, - useLocation, + useLoaderData, useSubmit, } from "@remix-run/react"; import { useRef } from "react"; -import { getUserWithRole } from "~/session.server"; -import stylesheet from "~/tailwind.css"; - import { Footer } from "./components/footer"; +import { Logo } from "./components/logo"; import { Button } from "./components/ui/button"; import { DropdownMenu, @@ -31,73 +28,140 @@ import { DropdownMenuPortal, DropdownMenuTrigger, } from "./components/ui/dropdown-menu"; -import { cn } from "./lib/utils"; -import { getDomainUrl, useOptionalUser, useUser } from "./utils"; +import { Toaster } from "./components/ui/sonner"; +import { useToast } from "./hooks/useToast"; +import { combineHeaders, getDomainUrl, useOptionalUser } from "./utils/misc"; +import { getToast } from "./utils/toast.server"; + +import stylesheet from "~/tailwind.css?url"; +import { getUserWithRole } from "~/utils/session.server"; export type RootLoaderData = SerializeFrom; export const links: LinksFunction = () => [ { rel: "stylesheet", href: stylesheet }, - ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), + { + rel: "preload", + href: "/fonts/OpenSans-VF.woff2", + as: "font", + type: "font/woff2", + crossOrigin: "anonymous", + }, + { + rel: "preload", + href: "/fonts/OpenSans-Italic-VF.woff2", + as: "font", + type: "font/woff2", + crossOrigin: "anonymous", + }, + { rel: "apple-touch-icon", sizes: "180x180", href: "/apple-touch-icon.png" }, + { + rel: "icon", + type: "image/png", + sizes: "32x32", + href: "/favicon-32x32.png", + }, + { + rel: "icon", + type: "image/png", + sizes: "16x16", + href: "/favicon-16x16.png", + }, + { rel: "manifest", href: "/site.webmanifest" }, ]; export const loader = async ({ request }: LoaderFunctionArgs) => { const domainUrl = getDomainUrl(request); const user = await getUserWithRole(request); - return json({ user, domainUrl }); + const { toast, headers } = await getToast(request); + return json({ user, toast, domainUrl }, { headers: combineHeaders(headers) }); }; export default function App() { return ( - + - + - + ); } function Document() { - const user = useOptionalUser(); - const location = useLocation(); - - const isAdminSection = location.pathname.startsWith("/admin"); + const optionalUser = useOptionalUser(); + const { toast } = useLoaderData(); + useToast(toast); return ( <> -