Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 12 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,55 +1,22 @@
<p align="center">
<img src="./public/cssglogo.svg" alt="CSSG Logo" width="200">
</p>
# CCC

# CSSG Starter Template
A CS + SG project in partnership with The Campus &amp; Community Coalition

This is a starter template for CSSG projects using React, TypeScript, and Tailwind CSS. It is configured to be used with VS Code Dev Containers for a consistent development environment.
## About The Campus &amp; Community Coalition

## Prerequisites
The Campus & Community Coalition (CCC) is a collaborative force bringing together university and community partners to address the harms associated with high-risk drinking. By fostering open dialogue, sharing power, and using data-driven strategies, they work to create an environment where everyone can thrive socially, academically, and economically.

Before you begin, ensure you have the following installed:
## Project Mission

- [Git](https://git-scm.com/)
- [Docker Desktop](https://www.docker.com/products/docker-desktop)
- [Visual Studio Code](https://code.visualstudio.com/)
- [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) for VS Code.
CCC wants to migrate from under Downtown Chapel Hill's website to their own. [Click here to see the current website.](https://www.downtownchapelhill.com/coalition)

## Getting Started
## Project Overview

1. **Fork the repository:**
This website will be used primarily to display important trends regarding alcohol use in the Chapel Hill community alongside other important resources.

Fork this repository. Then, clone your forked repository:
This webpage uses React. [Click here to get started with React.](https://react.dev/learn)

```bash
git clone <your-forked-repository-url>
cd <repository-name>
```
## Get Started

2. **Open in VS Code/Cursor:**

Open the cloned repository folder in VS Code or Cursor.

3. **Open in Dev Container:**

Once the project is open in VS Code, you will be prompted to "Reopen in Container". Click on it.

If you don't see the prompt, you can open the command palette and run "Dev Containers: Reopen in Container".
- **Windows/Linux:** `Ctrl+Shift+P`
- **Mac:** `Cmd+Shift+P`

This will build the Docker container for the development environment. The first build might take a few minutes. Subsequent loads will be much faster.

## Available Commands

Inside the dev container, you can use the following commands:

| Command | Description |
| :------------------ | :--------------------------------------------------------- |
| `npm run dev` | Starts the development server with Hot Module Replacement. |
| `npm run build` | Builds the application for production. |
| `npm run start` | Serves the production build. |
| `npm run lint` | Lints the codebase using ESLint. |
| `npm run lint:fix` | Lints and automatically fixes issues. |
| `npm run format` | Formats the code using Prettier. |
| `npm run typecheck` | Runs the TypeScript compiler to check for type errors. |
Look at our environment setup docs: [Environment Setup](docs/environment_setup.md)\
Also, make sure to look over the: [Contributing Guidelines](docs/contributing_guidelines.md)
7 changes: 3 additions & 4 deletions app/app.css
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
@import "tailwindcss";

@theme {
--font-sans:
"Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-sans: "Figtree";
}

html,
body {
@apply bg-white dark:bg-gray-950;
@apply bg-white text-black;
color-scheme: light;

@media (prefers-color-scheme: dark) {
color-scheme: dark;
Expand Down
Empty file added app/components/test.tsx
Empty file.
20 changes: 20 additions & 0 deletions app/helpers/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const DISALLOWED_EMAIL_CHARS = /[\s()[\];:<>\\/"'`~!#$%^&*|+=?{}]/;
const BASIC_EMAIL_SHAPE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;

export function normalizeEmail(value: string): string {
return (value || "").trim().toLowerCase();
}

export function isEmailValid(value: string): boolean {
const email = normalizeEmail(value);

if (!email) return false;
if (DISALLOWED_EMAIL_CHARS.test(email)) return false;
if (!BASIC_EMAIL_SHAPE.test(email)) return false;

return true;
}

export function areRequiredFilled(obj: Record<string, string>): boolean {
return Object.values(obj).every((v) => (v || "").trim().length > 0);
}
Binary file added app/images/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const links: Route.LinksFunction = () => [
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
href: "https://fonts.googleapis.com/css2?family=Figtree:ital,wght@0,300..900;1,300..900&display=swap",
},
];

Expand Down
11 changes: 9 additions & 2 deletions app/routes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import { type RouteConfig, index } from "@react-router/dev/routes";
import { type RouteConfig, route } from "@react-router/dev/routes";

export default [index("routes/starter.tsx")] satisfies RouteConfig;
export default [
route("/newsletter", "routes/newsletter.tsx"),
route("/data", "routes/data.tsx"),
route("*", "routes/error.tsx"),
route("/people", "routes/people.tsx"),
route("/research", "routes/research.tsx"),
route("/", "routes/starter.tsx"),
] satisfies RouteConfig;
8 changes: 8 additions & 0 deletions app/routes/data.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from "react";
export default function Data() {
return (
<>
<h1>Data Page</h1>
</>
);
}
8 changes: 8 additions & 0 deletions app/routes/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from "react";
export default function Error() {
return (
<>
<h1>Not Found</h1>
</>
);
}
189 changes: 189 additions & 0 deletions app/routes/newsletter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import React from "react";
import {
areRequiredFilled,
isEmailValid,
normalizeEmail,
} from "~/helpers/validation";

const INITIAL_FORM = {
firstName: "",
lastName: "",
email: "",
};

export default function Newsletter() {
const [formValues, setFormValues] =
React.useState<typeof INITIAL_FORM>(INITIAL_FORM);
const [hasInteracted, setHasInteracted] = React.useState(false);
const [successMessage, setSuccessMessage] = React.useState<string | null>(
null
);

const fieldsComplete = React.useMemo(
() => areRequiredFilled(formValues),
[formValues]
);

const emailValid = React.useMemo(
() => isEmailValid(formValues.email),
[formValues.email]
);

const validationError = !fieldsComplete
? "All required fields are not filled."
: !emailValid
? "Invalid email format."
: null;

const shouldShowError = hasInteracted && Boolean(validationError);
const isSubmitDisabled = !fieldsComplete || !emailValid;

const handleChange =
(field: keyof typeof INITIAL_FORM) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;

setHasInteracted(true);
setSuccessMessage(null);
setFormValues((prev) => ({
...prev,
[field]: value,
}));
};

const handleBlur = (field: keyof typeof INITIAL_FORM) => () => {
setFormValues((prev) => ({
...prev,
[field]:
field === "email"
? normalizeEmail(prev.email)
: prev[field].trim(),
}));
};

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setHasInteracted(true);

if (validationError) {
setSuccessMessage(null);
setFormValues((prev) => ({
...prev,
email: normalizeEmail(prev.email),
firstName: prev.firstName.trim(),
lastName: prev.lastName.trim(),
}));
return;
}

const normalizedForm = {
firstName: formValues.firstName.trim(),
lastName: formValues.lastName.trim(),
email: normalizeEmail(formValues.email),
};

setFormValues(normalizedForm);
setSuccessMessage("Thanks for signing up!");
};

return (
<section className="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4 py-10 bg-[#f2f2f2]">
<div className="w-full max-w-xl rounded-[40px] bg-[#d9d9d9] p-10 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
<h1 className="text-center text-3xl font-semibold text-black">
Sign Up for the Newsletter!
</h1>
<p className="mt-3 text-center text-base text-[#333333]">
Receive updates, events, and more! Our newsletters are sent
out quarterly
</p>

<form
className="mt-8 space-y-6"
onSubmit={handleSubmit}
noValidate
>
<div className="space-y-2">
<label
className="block text-sm font-medium text-[#2e2e2e]"
htmlFor="newsletter-first-name"
>
First Name
</label>
<input
id="newsletter-first-name"
name="firstName"
type="text"
autoComplete="given-name"
value={formValues.firstName}
onChange={handleChange("firstName")}
onBlur={handleBlur("firstName")}
required
className="h-14 w-full rounded-full border border-transparent bg-white px-6 text-base text-black outline-none transition focus:border-[#b0b0b0] focus:shadow-[0_0_0_3px_rgba(0,0,0,0.08)]"
/>
</div>

<div className="space-y-2">
<label
className="block text-sm font-medium text-[#2e2e2e]"
htmlFor="newsletter-last-name"
>
Last Name
</label>
<input
id="newsletter-last-name"
name="lastName"
type="text"
autoComplete="family-name"
value={formValues.lastName}
onChange={handleChange("lastName")}
onBlur={handleBlur("lastName")}
required
className="h-14 w-full rounded-full border border-transparent bg-white px-6 text-base text-black outline-none transition focus:border-[#b0b0b0] focus:shadow-[0_0_0_3px_rgba(0,0,0,0.08)]"
/>
</div>

<div className="space-y-2">
<label
className="block text-sm font-medium text-[#2e2e2e]"
htmlFor="newsletter-email"
>
Email Address
</label>
<input
id="newsletter-email"
name="email"
type="email"
autoComplete="email"
value={formValues.email}
onChange={handleChange("email")}
onBlur={handleBlur("email")}
required
inputMode="email"
className="h-14 w-full rounded-full border border-transparent bg-white px-6 text-base text-black outline-none transition focus:border-[#b0b0b0] focus:shadow-[0_0_0_3px_rgba(0,0,0,0.08)]"
/>
</div>

<div className="min-h-[1.25rem]" aria-live="polite">
{shouldShowError && validationError ? (
<p className="text-center text-sm font-medium text-[#b00000]">
{validationError}
</p>
) : successMessage ? (
<p className="text-center text-sm font-medium text-[#1b5e20]">
{successMessage}
</p>
) : null}
</div>

<button
type="submit"
disabled={isSubmitDisabled}
className="mx-auto flex h-14 w-full items-center justify-center rounded-full bg-white text-base font-medium text-[#2e2e2e] transition hover:bg-[#f7f7f7] disabled:cursor-not-allowed disabled:opacity-60"
>
Sign Up
</button>
</form>
</div>
</section>
);
}
8 changes: 8 additions & 0 deletions app/routes/people.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from "react";
export default function People() {
return (
<>
<h1>People Page</h1>
</>
);
}
8 changes: 8 additions & 0 deletions app/routes/research.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from "react";
export default function Research() {
return (
<>
<h1>Research Page</h1>
</>
);
}
Loading