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}
+
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 (
<>
-