diff --git a/.dockerignore b/.dockerignore
index 3f2eaf49..b6ca0db2 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -7,3 +7,6 @@ infra
storage/*
docker-compose.dev.yml
dockerfile.dev
+dockerfile.handler
+dockerfile.worker
+Frontend
diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml
index bd35b30e..977ed269 100644
--- a/.github/workflows/deploy.yaml
+++ b/.github/workflows/deploy.yaml
@@ -39,3 +39,11 @@ jobs:
- name: Deploy worker
run: |
aws ecs update-service --cluster wca-registration --service wca-registration-worker --force-new-deployment
+ - name: Deploy Frontend
+ run: |
+ cd ./Frontend
+ npm install
+ npm run build-prod
+ aws s3 cp dist/bundle.js s3://assets.registration.worldcubeassociation.org/bundle.js
+ aws s3 cp dist/bundle.css s3://assets.registration.worldcubeassociation.org/bundle.css
+ aws cloudfront create-invalidation --distribution-id E322K044MBR5FG --paths "/bundle.js" "/bundle.css" --output text
diff --git a/.gitignore b/.gitignore
index c5925fce..97ee3c96 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,5 @@ tmp
node_modules
localstack
yarn.lock
+
+*/package-lock.json
diff --git a/Frontend/.eslintrc.js b/Frontend/.eslintrc.js
new file mode 100644
index 00000000..573f18d9
--- /dev/null
+++ b/Frontend/.eslintrc.js
@@ -0,0 +1,18 @@
+const { configure, presets } = require('eslint-kit')
+
+module.exports = configure({
+ mode: 'only-errors',
+ presets: [
+ presets.imports(),
+ presets.prettier(),
+ presets.node(),
+ presets.react(),
+ ],
+ extend: {
+ rules: {
+ 'import/no-default-export': 'off',
+ "react/jsx-uses-vars": "error",
+ "react/jsx-uses-react": "error"
+ },
+ }
+})
diff --git a/Frontend/.gitignore b/Frontend/.gitignore
new file mode 100644
index 00000000..53c37a16
--- /dev/null
+++ b/Frontend/.gitignore
@@ -0,0 +1 @@
+dist
\ No newline at end of file
diff --git a/Frontend/.prettierrc b/Frontend/.prettierrc
new file mode 100644
index 00000000..2e4bf83a
--- /dev/null
+++ b/Frontend/.prettierrc
@@ -0,0 +1,6 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "tabWidth": 2,
+ "quoteProps": "consistent"
+}
\ No newline at end of file
diff --git a/Frontend/README.md b/Frontend/README.md
new file mode 100644
index 00000000..53c31a11
--- /dev/null
+++ b/Frontend/README.md
@@ -0,0 +1,7 @@
+# Frontend
+The Frontend is written in React and served by AWS Cloudfront. Whenever you push changes to the frontend
+the js bundle is built and the cache is invalidated.
+
+## How to run
+
+Go to the root folder and run `docker compose -f docker-compose.dev.yml up`
diff --git a/Frontend/dockerfile b/Frontend/dockerfile
new file mode 100644
index 00000000..96ceec6d
--- /dev/null
+++ b/Frontend/dockerfile
@@ -0,0 +1,12 @@
+# Set the base image to the official Node.js image
+FROM node:18 as build
+
+# Create a working directory for the app
+WORKDIR /app
+
+# Copy the package.json and package-lock.json files to the working directory
+COPY package*.json ./
+COPY index.html ./
+
+# Install dependencies using npm
+RUN npm install
diff --git a/Frontend/index.html b/Frontend/index.html
new file mode 100644
index 00000000..5888051b
--- /dev/null
+++ b/Frontend/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ WCA Registration Test Page
+
+
+
+
+
+
+
+
+
diff --git a/Frontend/package.json b/Frontend/package.json
new file mode 100644
index 00000000..1814097c
--- /dev/null
+++ b/Frontend/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "wca-registration-frontend",
+ "version": "1.0.0",
+ "description": "The Frontend for the WCA Registration service",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/thewca/wca-registration.git"
+ },
+ "author": "WCA Software Team",
+ "license": "GPL-3.0-or-later",
+ "bugs": {
+ "url": "https://github.com/thewca/wca-registration/issues"
+ },
+ "homepage": "https://github.com/thewca/wca-registration/Frontend/README.md",
+ "scripts": {
+ "build-dev": "API_URL=\"http://localhost:3001\" node src/build.js",
+ "build-prod": "API_URL=\"https://registration.worldcubeassociation.org\" node src/build.js",
+ "watch": "node src/watch.mjs",
+ "lint": "eslint src --ext .js,.jsx,.ts,.tsx",
+ "lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix"
+ },
+ "dependencies": {
+ "esbuild": "^0.17.19",
+ "esbuild-scss-modules-plugin": "^1.1.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "eslint": "^8.40.0",
+ "eslint-kit": "^8.5.0",
+ "prettier": "^2.8.8"
+ }
+}
diff --git a/Frontend/src/api/helper/backend_fetch.js b/Frontend/src/api/helper/backend_fetch.js
new file mode 100644
index 00000000..bbae250f
--- /dev/null
+++ b/Frontend/src/api/helper/backend_fetch.js
@@ -0,0 +1,19 @@
+export default async function backendFetch(route, method, body = {}) {
+ try {
+ let init = {}
+ if (method !== 'GET') {
+ init = {
+ method,
+ body,
+ }
+ }
+ const response = await fetch(`${process.env.API_URL}/${route}`, init)
+
+ if (response.ok) {
+ return await response.json()
+ }
+ return { error: response.statusText, statusCode: response.status }
+ } catch (error) {
+ return { error, statusCode: 500 }
+ }
+}
diff --git a/Frontend/src/api/registration/delete/delete_registration.js b/Frontend/src/api/registration/delete/delete_registration.js
new file mode 100644
index 00000000..46f32ffe
--- /dev/null
+++ b/Frontend/src/api/registration/delete/delete_registration.js
@@ -0,0 +1,9 @@
+import backendFetch from '../../helper/backend_fetch'
+
+export default async function deleteRegistration(competitorID, competitionID) {
+ const formData = new FormData()
+ formData.append('competitor_id', competitorID)
+ formData.append('competition_id', competitionID)
+
+ return backendFetch('/register', 'DELETE', formData)
+}
diff --git a/Frontend/src/api/registration/get/get_registrations.js b/Frontend/src/api/registration/get/get_registrations.js
new file mode 100644
index 00000000..459db2fd
--- /dev/null
+++ b/Frontend/src/api/registration/get/get_registrations.js
@@ -0,0 +1,5 @@
+import backendFetch from '../../helper/backend_fetch'
+
+export default async function getRegistrations(competitionID) {
+ return backendFetch(`/registrations?competition_id=${competitionID}`, 'GET')
+}
diff --git a/Frontend/src/api/registration/patch/update_registration.js b/Frontend/src/api/registration/patch/update_registration.js
new file mode 100644
index 00000000..9a64cecd
--- /dev/null
+++ b/Frontend/src/api/registration/patch/update_registration.js
@@ -0,0 +1,14 @@
+import backendFetch from '../../helper/backend_fetch'
+
+export default async function updateRegistration(
+ competitorID,
+ competitionID,
+ status
+) {
+ const formData = new FormData()
+ formData.append('competitor_id', competitorID)
+ formData.append('competition_id', competitionID)
+ formData.append('status', status)
+
+ return backendFetch('/register', 'PATCH', formData)
+}
diff --git a/Frontend/src/api/registration/post/submit_registration.js b/Frontend/src/api/registration/post/submit_registration.js
new file mode 100644
index 00000000..06893d53
--- /dev/null
+++ b/Frontend/src/api/registration/post/submit_registration.js
@@ -0,0 +1,14 @@
+import backendFetch from '../../helper/backend_fetch'
+
+export default async function submitEventRegistration(
+ competitorId,
+ competitionId,
+ events
+) {
+ const formData = new FormData()
+ formData.append('competitor_id', competitorId)
+ formData.append('competition_id', competitionId)
+ events.forEach((eventId) => formData.append('event_ids[]', eventId))
+
+ return backendFetch('/register', 'POST', formData)
+}
diff --git a/Frontend/src/build.js b/Frontend/src/build.js
new file mode 100644
index 00000000..3ab6c3f6
--- /dev/null
+++ b/Frontend/src/build.js
@@ -0,0 +1,21 @@
+const esbuild = require('esbuild')
+const process = require('process')
+
+esbuild
+ .build({
+ entryPoints: ['src/index.jsx'],
+ bundle: true,
+ outfile: 'dist/bundle.js',
+ jsxFactory: 'React.createElement',
+ jsxFragment: 'React.Fragment',
+ plugins: [
+ require('esbuild-scss-modules-plugin').ScssModulesPlugin({
+ inject: true,
+ minify: true,
+ }),
+ ],
+ define: {
+ 'process.env.API_URL': `"${process.env.API_URL}"`,
+ },
+ })
+ .catch(() => process.exit(1))
diff --git a/Frontend/src/index.jsx b/Frontend/src/index.jsx
new file mode 100644
index 00000000..f9e328cf
--- /dev/null
+++ b/Frontend/src/index.jsx
@@ -0,0 +1,9 @@
+import { createRoot } from 'react-dom/client'
+import App from './register/pages'
+
+// Clear the existing HTML content
+document.body.innerHTML = ''
+
+// Render your React component instead
+const root = createRoot(document.querySelector('#app'))
+root.render(App())
diff --git a/Frontend/src/register/components/EventSelection.jsx b/Frontend/src/register/components/EventSelection.jsx
new file mode 100644
index 00000000..6f9d923d
--- /dev/null
+++ b/Frontend/src/register/components/EventSelection.jsx
@@ -0,0 +1,34 @@
+import React from 'react'
+import styles from './panel.module.scss'
+
+function toggleElementFromArray(arr, element) {
+ const index = arr.indexOf(element)
+ if (index !== -1) {
+ arr.splice(index, 1)
+ } else {
+ arr.push(element)
+ }
+ return arr
+}
+
+const EVENTS = ['3x3', '4x4']
+
+export default function EventSelection({ events, setEvents }) {
+ return (
+
+ {EVENTS.map((wca_event) => (
+
+ ))}
+
+ )
+}
diff --git a/Frontend/src/register/components/RegistrationList.jsx b/Frontend/src/register/components/RegistrationList.jsx
new file mode 100644
index 00000000..5e78b7a6
--- /dev/null
+++ b/Frontend/src/register/components/RegistrationList.jsx
@@ -0,0 +1,101 @@
+import React, { useState } from 'react'
+import deleteRegistration from '../../api/registration/delete/delete_registration'
+import getRegistrations from '../../api/registration/get/get_registrations'
+import updateRegistration from '../../api/registration/patch/update_registration'
+import styles from './list.module.scss'
+import StatusDropdown from './StatusDropdown'
+
+function RegistrationRow({
+ competitorId,
+ eventIDs,
+ serverStatus,
+ competitionID,
+ setRegistrationList,
+ registrationList,
+}) {
+ const [status, setStatus] = useState(serverStatus)
+
+ return (
+
+ {competitorId} |
+ {eventIDs.join(',')} |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ )
+}
+
+export default function RegistrationList() {
+ const [competitionID, setCompetitionID] = useState('HessenOpen2023')
+ const [registrationList, setRegistrationList] = useState([])
+ return (
+
+
+
+
+
+
+ Competitor |
+ Events |
+ Status |
+ Apply Changes |
+ Delete |
+
+
+
+ {registrationList.map((registration) => {
+ return (
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/Frontend/src/register/components/RegistrationPanel.jsx b/Frontend/src/register/components/RegistrationPanel.jsx
new file mode 100644
index 00000000..34e96e33
--- /dev/null
+++ b/Frontend/src/register/components/RegistrationPanel.jsx
@@ -0,0 +1,41 @@
+import React, { useState } from 'react'
+import submitEventRegistration from '../../api/registration/post/submit_registration'
+import EventSelection from './EventSelection'
+import styles from './panel.module.scss'
+
+export default function RegistrationPanel() {
+ const [competitorID, setCompetitorID] = useState('2012ICKL01')
+ const [competitionID, setCompetitionID] = useState('HessenOpen2023')
+ const [events, setEvents] = useState([])
+
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/Frontend/src/register/components/StatusDropdown.jsx b/Frontend/src/register/components/StatusDropdown.jsx
new file mode 100644
index 00000000..67180800
--- /dev/null
+++ b/Frontend/src/register/components/StatusDropdown.jsx
@@ -0,0 +1,12 @@
+import React from 'react'
+
+export default function StatusDropdown({ status, setStatus }) {
+ const options = ['waiting', 'accepted']
+ return (
+
+ )
+}
diff --git a/Frontend/src/register/components/list.module.scss b/Frontend/src/register/components/list.module.scss
new file mode 100644
index 00000000..71e436b3
--- /dev/null
+++ b/Frontend/src/register/components/list.module.scss
@@ -0,0 +1,7 @@
+.list {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-top: 24%;
+ width: 50vw;
+}
\ No newline at end of file
diff --git a/Frontend/src/register/components/panel.module.scss b/Frontend/src/register/components/panel.module.scss
new file mode 100644
index 00000000..da532aed
--- /dev/null
+++ b/Frontend/src/register/components/panel.module.scss
@@ -0,0 +1,11 @@
+.panel{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-top: 24%;
+ width: 50vw;
+}
+
+.events{
+ display: flex;
+}
\ No newline at end of file
diff --git a/Frontend/src/register/pages/index.jsx b/Frontend/src/register/pages/index.jsx
new file mode 100644
index 00000000..17a1a8d9
--- /dev/null
+++ b/Frontend/src/register/pages/index.jsx
@@ -0,0 +1,13 @@
+import React from 'react'
+import RegistrationList from '../components/RegistrationList'
+import RegistrationPanel from '../components/RegistrationPanel'
+import styles from './index.module.scss'
+
+export default function App() {
+ return (
+
+
+
+
+ )
+}
diff --git a/Frontend/src/register/pages/index.module.scss b/Frontend/src/register/pages/index.module.scss
new file mode 100644
index 00000000..728d637d
--- /dev/null
+++ b/Frontend/src/register/pages/index.module.scss
@@ -0,0 +1,5 @@
+.container {
+ display: flex;
+ height: 100vh;
+ flex-direction: row;
+}
\ No newline at end of file
diff --git a/Frontend/src/watch.mjs b/Frontend/src/watch.mjs
new file mode 100644
index 00000000..8f0292af
--- /dev/null
+++ b/Frontend/src/watch.mjs
@@ -0,0 +1,29 @@
+import * as esbuild from 'esbuild';
+import {ScssModulesPlugin} from "esbuild-scss-modules-plugin";
+
+const context = await esbuild.context({
+ entryPoints: ['src/index.jsx'],
+ bundle: true,
+ outfile: 'dist/bundle.js',
+ jsxFactory: 'React.createElement',
+ jsxFragment: 'React.Fragment',
+ plugins: [
+ ScssModulesPlugin({
+ inject: true,
+ minify: true,
+ }),
+ ],
+ define: {
+ 'process.env.API_URL': '"http://localhost:3001"',
+ },
+})
+
+// Enable watch mode
+await context.watch()
+
+// Enable serve mode
+await context.serve({
+ port: 3000,
+ servedir: "."
+})
+
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index b152a110..b435bbeb 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -81,6 +81,20 @@ services:
networks:
- wca-registration
+ # Frontend
+ frontend:
+ build:
+ context: Frontend
+ ports:
+ - "3002:3000"
+ tty:
+ true
+ command: bash -c "npm run watch"
+ networks:
+ - wca-registration
+ volumes:
+ - ./Frontend/src:/app/src
+
volumes:
gems_volume_handler:
driver: local
diff --git a/infra/frontend/main.tf b/infra/frontend/main.tf
new file mode 100644
index 00000000..7f136f5d
--- /dev/null
+++ b/infra/frontend/main.tf
@@ -0,0 +1,20 @@
+resource "aws_s3_bucket" "this" {
+ bucket = var.bucket_name
+ tags = {
+ "Name" = var.bucket_name
+ }
+}
+
+module "cdn" {
+ source = "cloudposse/cloudfront-s3-cdn/aws"
+ # Cloud Posse recommends pinning every module to a specific version
+ # version = "x.x.x"
+
+ origin_bucket = aws_s3_bucket.this.id
+ s3_access_logging_enabled = false
+ logging_enabled = false
+
+ name = "cdn"
+ stage = "prod"
+ namespace = "wca-registration"
+}
diff --git a/infra/frontend/variables.tf b/infra/frontend/variables.tf
new file mode 100644
index 00000000..a40f9e2d
--- /dev/null
+++ b/infra/frontend/variables.tf
@@ -0,0 +1,4 @@
+variable "bucket_name" {
+ type = string
+ default = "assets.registration.worldcubeassociation.org"
+}
diff --git a/infra/main.tf b/infra/main.tf
index e82981c7..8811089e 100644
--- a/infra/main.tf
+++ b/infra/main.tf
@@ -42,3 +42,6 @@ module "worker" {
depends_on = [module.shared_resources]
}
+module "frontend" {
+ source = "./frontend"
+}