This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
name: Build and Push Lunchtime Images (Kaniko)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.sgruber.at
|
||||||
|
IMAGE_NAMESPACE: lunchtime
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Compute image tags and auth
|
||||||
|
id: meta
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=$(echo $GITHUB_SHA | cut -c1-7)
|
||||||
|
IMAGE_REPO="$REGISTRY/$IMAGE_NAMESPACE/lunchtime-web"
|
||||||
|
|
||||||
|
# Prepare Kaniko destination arguments
|
||||||
|
FRONTEND_DESTS="--destination $IMAGE_REPO:frontend-$SHORT_SHA"
|
||||||
|
BACKEND_DESTS="--destination $IMAGE_REPO:backend-$SHORT_SHA"
|
||||||
|
|
||||||
|
if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
|
||||||
|
FRONTEND_DESTS="$FRONTEND_DESTS --destination $IMAGE_REPO:frontend-${GITHUB_REF_NAME}"
|
||||||
|
BACKEND_DESTS="$BACKEND_DESTS --destination $IMAGE_REPO:backend-${GITHUB_REF_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$GITHUB_REF_NAME" == "main" || "$GITHUB_REF_NAME" == "master" ]]; then
|
||||||
|
FRONTEND_DESTS="$FRONTEND_DESTS --destination $IMAGE_REPO:frontend-latest"
|
||||||
|
BACKEND_DESTS="$BACKEND_DESTS --destination $IMAGE_REPO:backend-latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create the auth string
|
||||||
|
AUTH_B64=$(echo -n "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" | base64 -w 0)
|
||||||
|
CONFIG_JSON="{\"auths\":{\"$REGISTRY\":{\"auth\":\"$AUTH_B64\"}}}"
|
||||||
|
CONFIG_B64=$(echo -n "$CONFIG_JSON" | base64 -w 0)
|
||||||
|
|
||||||
|
echo "frontend_dests=$FRONTEND_DESTS" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "backend_dests=$BACKEND_DESTS" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "config_b64=$CONFIG_B64" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build and Push Frontend
|
||||||
|
uses: docker://gcr.io/kaniko-project/executor:debug
|
||||||
|
env:
|
||||||
|
DOCKER_CONFIG_B64: ${{ steps.meta.outputs.config_b64 }}
|
||||||
|
with:
|
||||||
|
entrypoint: /busybox/sh
|
||||||
|
args: >-
|
||||||
|
-c "mkdir -p /kaniko/.docker &&
|
||||||
|
echo $DOCKER_CONFIG_B64 | base64 -d > /kaniko/.docker/config.json &&
|
||||||
|
/kaniko/executor
|
||||||
|
--context=$GITHUB_WORKSPACE/src/frontend
|
||||||
|
--dockerfile=$GITHUB_WORKSPACE/src/frontend/Containerfile
|
||||||
|
${{ steps.meta.outputs.frontend_dests }}"
|
||||||
|
|
||||||
|
- name: Build and Push Backend
|
||||||
|
uses: docker://gcr.io/kaniko-project/executor:debug
|
||||||
|
env:
|
||||||
|
DOCKER_CONFIG_B64: ${{ steps.meta.outputs.config_b64 }}
|
||||||
|
with:
|
||||||
|
entrypoint: /busybox/sh
|
||||||
|
args: >-
|
||||||
|
-c "mkdir -p /kaniko/.docker &&
|
||||||
|
echo $DOCKER_CONFIG_B64 | base64 -d > /kaniko/.docker/config.json &&
|
||||||
|
/kaniko/executor
|
||||||
|
--context=$GITHUB_WORKSPACE/src/backend
|
||||||
|
--dockerfile=$GITHUB_WORKSPACE/src/backend/Containerfile
|
||||||
|
${{ steps.meta.outputs.backend_dests }}"
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
.data/
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Lunchtime
|
||||||
|
|
||||||
|
Lunchtime is a self-hosted lunch order app with a React frontend, a Python backend, and nginx for reverse proxying.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Copy and adjust `config.yaml` for your environment.
|
||||||
|
2. Create a writable data folder (for SQLite and uploads), for example `.data`.
|
||||||
|
3. Start the stack with Docker Compose.
|
||||||
|
|
||||||
|
Example `compose.yml` (inspired by `src/compose.yml`) using images from the Gitea registry:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: git.sgruber.at/lunchtime/lunchtime-api:latest
|
||||||
|
volumes:
|
||||||
|
- ./.data:/app/data
|
||||||
|
- ./config.yaml:/app/config.yaml:ro
|
||||||
|
environment:
|
||||||
|
- APP_ENV=production
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
- DB_PATH=/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: git.sgruber.at/lunchtime/lunchtime-web:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8080`.
|
||||||
|
|
||||||
|
## CI/CD Registry Publish
|
||||||
|
|
||||||
|
The workflow in `.gitea/workflows/build-and-push-images.yaml` builds and pushes:
|
||||||
|
|
||||||
|
- `git.sgruber.at/lunchtime/lunchtime-web`
|
||||||
|
- `git.sgruber.at/lunchtime/lunchtime-api`
|
||||||
|
|
||||||
|
It tags images with the commit SHA and also `latest` on `main`/`master`.
|
||||||
|
|
||||||
|
Required repository secrets:
|
||||||
|
|
||||||
|
- `GITEA_REGISTRY_USER`
|
||||||
|
- `GITEA_REGISTRY_TOKEN`
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Add admin view:
|
||||||
|
|
||||||
|
Users tab:
|
||||||
|
- Show registered users and their orders (tree view? table with expandable groupings?) meaning what they created and what they participated in
|
||||||
|
- Allow deleting users (and their orders), also add switch to make them admin (popconfirm)
|
||||||
|
- View and set user email and confirmed state
|
||||||
|
|
||||||
|
E-Mail configuration tab:
|
||||||
|
- View email configuration (without password)
|
||||||
|
- Ability to send test email
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
app:
|
||||||
|
public_base_url: "http://localhost:8080/"
|
||||||
|
|
||||||
|
email:
|
||||||
|
enabled: true
|
||||||
|
smtp_host: "mail.example.com"
|
||||||
|
smtp_port: 465
|
||||||
|
smtp_username: "lunchtime@example.com"
|
||||||
|
smtp_password: ""
|
||||||
|
from_address: "lunchtime@example.com"
|
||||||
|
use_tls: false
|
||||||
|
use_ssl: true
|
||||||
|
|
||||||
|
announcements:
|
||||||
|
# - type: info
|
||||||
|
# title: Info
|
||||||
|
# message: Welcome to Lunchtime!
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
upstream backend {
|
||||||
|
server backend:5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream frontend {
|
||||||
|
server frontend:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt ./requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data && chmod 755 /app/data && \
|
||||||
|
chmod +x /app/entrypoint.sh 2>/dev/null || true
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:5000/api/config || exit 1
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5000"]
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
data_dir = Path(os.getenv("DB_PATH", "/app/data"))
|
||||||
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
DB_PATH = data_dir / "burger_orders.db"
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection() -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS group_orders (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
admin_token TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
image_url TEXT,
|
||||||
|
closed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS submissions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
group_order_id TEXT NOT NULL,
|
||||||
|
submission_token TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
choices_json TEXT NOT NULL,
|
||||||
|
accepted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
paid INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(group_order_id) REFERENCES group_orders(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_order_tokens (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
group_order_id TEXT NOT NULL,
|
||||||
|
admin_token TEXT,
|
||||||
|
submission_token TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(user_id, group_order_id),
|
||||||
|
FOREIGN KEY(group_order_id) REFERENCES group_orders(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(admin_token) REFERENCES group_orders(admin_token) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY(submission_token) REFERENCES submissions(submission_token) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
email_confirmed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pending_email TEXT,
|
||||||
|
pending_email_old_confirmed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pending_email_new_confirmed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pending_user_id TEXT,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS account_confirmation_tokens (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
process_id TEXT,
|
||||||
|
email TEXT,
|
||||||
|
new_email TEXT,
|
||||||
|
new_user_id TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
consumed_at TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_account_confirmation_tokens_user_id
|
||||||
|
ON account_confirmation_tokens(user_id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_account_confirmation_tokens_process_id
|
||||||
|
ON account_confirmation_tokens(process_id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def row_to_submission(row: sqlite3.Row) -> dict:
|
||||||
|
choices: dict = {}
|
||||||
|
raw_choices = row["choices_json"]
|
||||||
|
if raw_choices:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_choices)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
choices = parsed
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
choices = {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"group_order_id": row["group_order_id"],
|
||||||
|
"submission_token": row["submission_token"],
|
||||||
|
"email": row["email"],
|
||||||
|
"choices": choices,
|
||||||
|
"accepted": bool(row["accepted"]),
|
||||||
|
"paid": bool(row["paid"]),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def row_to_my_order(row: sqlite3.Row) -> dict:
|
||||||
|
submission_choices: dict = {}
|
||||||
|
raw_choices = row["choices_json"]
|
||||||
|
if raw_choices:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_choices)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
submission_choices = parsed
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
submission_choices = {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"title": row["title"],
|
||||||
|
"description": row["description"],
|
||||||
|
"image_url": row["image_url"],
|
||||||
|
"closed": bool(row["closed"]),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"is_owner": bool(row["admin_token"]),
|
||||||
|
"is_participant": bool(row["submission_token"]),
|
||||||
|
"submission": {
|
||||||
|
"choices": submission_choices,
|
||||||
|
"accepted": bool(row["accepted"]) if row["accepted"] is not None else False,
|
||||||
|
"paid": bool(row["paid"]) if row["paid"] is not None else False,
|
||||||
|
},
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
|||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .common_service import clean_user_email, clean_user_id, now_iso
|
||||||
|
from ..db import row_to_submission
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_user_order_tokens(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
user_id: str,
|
||||||
|
order_id: str,
|
||||||
|
*,
|
||||||
|
admin_token: str | None = None,
|
||||||
|
submission_token: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
timestamp = now_iso()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_order_tokens (
|
||||||
|
user_id, group_order_id, admin_token, submission_token, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, group_order_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
admin_token = COALESCE(excluded.admin_token, user_order_tokens.admin_token),
|
||||||
|
submission_token = COALESCE(excluded.submission_token, user_order_tokens.submission_token),
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(user_id, order_id, admin_token, submission_token, timestamp, timestamp),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_order_tokens(conn: sqlite3.Connection, user_id: str, order_id: str) -> sqlite3.Row | None:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT user_id, group_order_id, admin_token, submission_token FROM user_order_tokens WHERE user_id = ? AND group_order_id = ?",
|
||||||
|
(user_id, order_id),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_user_profile_email(conn: sqlite3.Connection, user_id: str, email: str) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_profiles (user_id, email, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
email = excluded.email,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(user_id, email, now_iso()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_profile_email(conn: sqlite3.Connection, user_id: str) -> str | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT email FROM user_profiles WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return str(row["email"]).strip().lower() if row and row["email"] else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_profile(conn: sqlite3.Connection, user_id: str) -> sqlite3.Row | None:
|
||||||
|
return conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
email,
|
||||||
|
email_confirmed,
|
||||||
|
pending_email,
|
||||||
|
pending_email_old_confirmed,
|
||||||
|
pending_email_new_confirmed,
|
||||||
|
pending_user_id,
|
||||||
|
updated_at
|
||||||
|
FROM user_profiles
|
||||||
|
WHERE user_id = ?
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def create_confirmation_token(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
action: str,
|
||||||
|
email: str | None = None,
|
||||||
|
new_email: str | None = None,
|
||||||
|
new_user_id: str | None = None,
|
||||||
|
process_id: str | None = None,
|
||||||
|
expires_in_hours: int = 24,
|
||||||
|
) -> str:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
created_at = now_iso()
|
||||||
|
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO account_confirmation_tokens (
|
||||||
|
token, user_id, action, process_id, email, new_email, new_user_id, created_at, expires_at, consumed_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||||
|
""",
|
||||||
|
(token, user_id, action, process_id, email, new_email, new_user_id, created_at, expires_at),
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def consume_confirmation_token(conn: sqlite3.Connection, token: str) -> sqlite3.Row:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT token, user_id, action, process_id, email, new_email, new_user_id, created_at, expires_at, consumed_at
|
||||||
|
FROM account_confirmation_tokens
|
||||||
|
WHERE token = ?
|
||||||
|
""",
|
||||||
|
(token,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Confirmation token not found")
|
||||||
|
if row["consumed_at"]:
|
||||||
|
raise HTTPException(status_code=409, detail="Confirmation token already used")
|
||||||
|
|
||||||
|
expires_at = datetime.fromisoformat(str(row["expires_at"]))
|
||||||
|
if datetime.now(timezone.utc) > expires_at:
|
||||||
|
raise HTTPException(status_code=410, detail="Confirmation token has expired")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE account_confirmation_tokens SET consumed_at = ? WHERE token = ?",
|
||||||
|
(now_iso(), token),
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def choose_new_user_id(conn: sqlite3.Connection, requested_user_id: str | None) -> str:
|
||||||
|
if requested_user_id:
|
||||||
|
candidate = clean_user_id(requested_user_id)
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT user_id FROM user_profiles WHERE user_id = ?",
|
||||||
|
(candidate,),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="Requested user ID is already in use")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
candidate = str(uuid4())
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT user_id FROM user_profiles WHERE user_id = ?",
|
||||||
|
(candidate,),
|
||||||
|
).fetchone()
|
||||||
|
if not existing:
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
raise HTTPException(status_code=500, detail="Could not generate a unique user ID")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_user_id(conn: sqlite3.Connection, old_user_id: str, new_user_id: str) -> None:
|
||||||
|
exists = conn.execute(
|
||||||
|
"SELECT user_id FROM user_profiles WHERE user_id = ?",
|
||||||
|
(new_user_id,),
|
||||||
|
).fetchone()
|
||||||
|
if exists:
|
||||||
|
raise HTTPException(status_code=409, detail="New user ID already exists")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE user_profiles SET user_id = ?, pending_user_id = NULL, updated_at = ? WHERE user_id = ?",
|
||||||
|
(new_user_id, now_iso(), old_user_id),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE user_order_tokens SET user_id = ?, updated_at = ? WHERE user_id = ?",
|
||||||
|
(new_user_id, now_iso(), old_user_id),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE account_confirmation_tokens SET user_id = ? WHERE user_id = ? AND consumed_at IS NULL",
|
||||||
|
(new_user_id, old_user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_submission_email(conn: sqlite3.Connection, user_id: str, user_email_header: str | None) -> str:
|
||||||
|
header_email = clean_user_email(user_email_header)
|
||||||
|
if header_email:
|
||||||
|
upsert_user_profile_email(conn, user_id, header_email)
|
||||||
|
return header_email
|
||||||
|
|
||||||
|
saved_email = get_user_profile_email(conn, user_id)
|
||||||
|
if saved_email:
|
||||||
|
return saved_email
|
||||||
|
|
||||||
|
raise HTTPException(status_code=422, detail="No email associated with current user token")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_owner_access(conn: sqlite3.Connection, user_id: str, order_id: str) -> sqlite3.Row:
|
||||||
|
row = get_user_order_tokens(conn, user_id, order_id)
|
||||||
|
if not row or not row["admin_token"]:
|
||||||
|
raise HTTPException(status_code=404, detail="Admin order view not found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def build_submission_payload(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||||
|
return row_to_submission(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_order_creator_info(conn: sqlite3.Connection, order_id: str) -> tuple[str | None, str | None]:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT uot.user_id AS creator_user_id, up.email AS creator_email
|
||||||
|
FROM group_orders go
|
||||||
|
LEFT JOIN user_order_tokens uot
|
||||||
|
ON uot.group_order_id = go.id
|
||||||
|
AND uot.admin_token = go.admin_token
|
||||||
|
LEFT JOIN user_profiles up
|
||||||
|
ON up.user_id = uot.user_id
|
||||||
|
WHERE go.id = ?
|
||||||
|
ORDER BY uot.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(order_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
creator_user_id = str(row["creator_user_id"]).strip() if row["creator_user_id"] else None
|
||||||
|
creator_email = str(row["creator_email"]).strip().lower() if row["creator_email"] else None
|
||||||
|
return creator_user_id, creator_email
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import EmailStr, TypeAdapter
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def clean_optional(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
trimmed = value.strip()
|
||||||
|
return trimmed if trimmed else None
|
||||||
|
|
||||||
|
|
||||||
|
def clean_user_id(raw_user_id: str | None) -> str:
|
||||||
|
clean_value = (raw_user_id or "").strip()
|
||||||
|
if not clean_value:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing user ID")
|
||||||
|
if len(clean_value) > 200:
|
||||||
|
raise HTTPException(status_code=422, detail="User ID is too long")
|
||||||
|
return clean_value
|
||||||
|
|
||||||
|
|
||||||
|
def clean_user_email(raw_user_email: str | None) -> str | None:
|
||||||
|
clean_value = clean_optional(raw_user_email)
|
||||||
|
if not clean_value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
validated = TypeAdapter(EmailStr).validate_python(clean_value)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive against validator changes
|
||||||
|
raise HTTPException(status_code=422, detail="User email header is invalid") from exc
|
||||||
|
|
||||||
|
return str(validated).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def clean_image_url(value: str | None) -> str | None:
|
||||||
|
clean_url = clean_optional(value)
|
||||||
|
if not clean_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if clean_url.startswith("http"):
|
||||||
|
parsed = urlparse(clean_url)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise HTTPException(status_code=422, detail="image_url must be a valid http(s) URL or relative path")
|
||||||
|
|
||||||
|
return clean_url
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
CONFIG_PATH = BASE_DIR / "config.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_announcement(item: Any) -> dict[str, str] | None:
|
||||||
|
allowed_types = {"info", "warning", "error", "success"}
|
||||||
|
|
||||||
|
if isinstance(item, str):
|
||||||
|
message = item.strip()
|
||||||
|
if not message:
|
||||||
|
return None
|
||||||
|
return {"type": "info", "message": message}
|
||||||
|
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
message = str(item.get("message", "")).strip()
|
||||||
|
if not message:
|
||||||
|
return None
|
||||||
|
|
||||||
|
announcement_type = str(item.get("type", "info")).strip().lower()
|
||||||
|
if announcement_type not in allowed_types:
|
||||||
|
announcement_type = "info"
|
||||||
|
|
||||||
|
title = str(item.get("title", "")).strip()
|
||||||
|
normalized = {"type": announcement_type, "message": message}
|
||||||
|
if title:
|
||||||
|
normalized["title"] = title
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_announcements(items: Any) -> list[dict[str, str]]:
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalized: list[dict[str, str]] = []
|
||||||
|
for item in items:
|
||||||
|
clean_item = normalize_announcement(item)
|
||||||
|
if clean_item:
|
||||||
|
normalized.append(clean_item)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def load_raw_config() -> dict[str, Any]:
|
||||||
|
if not CONFIG_PATH.exists():
|
||||||
|
raise HTTPException(status_code=500, detail="config.yaml is missing")
|
||||||
|
|
||||||
|
with CONFIG_PATH.open("r", encoding="utf-8") as file:
|
||||||
|
return yaml.safe_load(file) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict[str, Any]:
|
||||||
|
data = load_raw_config()
|
||||||
|
return {
|
||||||
|
"announcements": normalize_announcements(data.get("announcements", [])),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_email_settings() -> dict[str, Any]:
|
||||||
|
data = load_raw_config()
|
||||||
|
email = data.get("email") if isinstance(data.get("email"), dict) else {}
|
||||||
|
app = data.get("app") if isinstance(data.get("app"), dict) else {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": bool(email.get("enabled", False)),
|
||||||
|
"smtp_host": str(email.get("smtp_host", "") or "").strip(),
|
||||||
|
"smtp_port": int(email.get("smtp_port", 587) or 587),
|
||||||
|
"smtp_username": str(email.get("smtp_username", "") or "").strip(),
|
||||||
|
"smtp_password": str(email.get("smtp_password", "") or ""),
|
||||||
|
"from_address": str(email.get("from_address", "") or "").strip(),
|
||||||
|
"use_tls": bool(email.get("use_tls", True)),
|
||||||
|
"use_ssl": bool(email.get("use_ssl", False)),
|
||||||
|
"confirmation_link_base": str(
|
||||||
|
email.get("confirmation_link_base", app.get("public_base_url", "")) or ""
|
||||||
|
).strip(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .config_service import get_email_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_confirmation_link(base_url: str, token: str) -> str:
|
||||||
|
if not base_url:
|
||||||
|
raise HTTPException(status_code=500, detail="Missing confirmation link base URL in config.yaml")
|
||||||
|
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise HTTPException(status_code=500, detail="confirmation_link_base must be a valid http(s) URL")
|
||||||
|
|
||||||
|
sep = "&" if "?" in base_url else "?"
|
||||||
|
return f"{base_url}{sep}{urlencode({'token': token})}"
|
||||||
|
|
||||||
|
|
||||||
|
def mask_email_for_log(value: str) -> str:
|
||||||
|
local, sep, domain = value.partition("@")
|
||||||
|
if not sep:
|
||||||
|
return "***"
|
||||||
|
if len(local) <= 1:
|
||||||
|
return f"*@{domain}"
|
||||||
|
return f"{local[0]}***@{domain}"
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_message(*, to_email: str, subject: str, body: str) -> None:
|
||||||
|
settings = get_email_settings()
|
||||||
|
masked_recipient = mask_email_for_log(to_email)
|
||||||
|
|
||||||
|
if not settings["enabled"]:
|
||||||
|
logger.warning("Email send skipped because email delivery is disabled")
|
||||||
|
raise HTTPException(status_code=503, detail="Email delivery is disabled")
|
||||||
|
|
||||||
|
required = ["smtp_host", "smtp_port", "from_address"]
|
||||||
|
missing = [name for name in required if not settings.get(name)]
|
||||||
|
if missing:
|
||||||
|
logger.error("Email send blocked by missing email settings: %s", ", ".join(missing))
|
||||||
|
raise HTTPException(status_code=500, detail=f"Missing email settings: {', '.join(missing)}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Sending email to=%s subject=%s smtp_host=%s smtp_port=%s tls=%s ssl=%s",
|
||||||
|
masked_recipient,
|
||||||
|
subject,
|
||||||
|
settings["smtp_host"],
|
||||||
|
settings["smtp_port"],
|
||||||
|
settings["use_tls"],
|
||||||
|
settings["use_ssl"],
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = settings["from_address"]
|
||||||
|
msg["To"] = to_email
|
||||||
|
msg["Subject"] = f"[Lunchtime] {subject}".strip()
|
||||||
|
msg.set_content(body)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if settings["use_ssl"]:
|
||||||
|
server = smtplib.SMTP_SSL(settings["smtp_host"], settings["smtp_port"], timeout=15)
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP(settings["smtp_host"], settings["smtp_port"], timeout=15)
|
||||||
|
|
||||||
|
with server:
|
||||||
|
if settings["use_tls"] and not settings["use_ssl"]:
|
||||||
|
server.starttls()
|
||||||
|
|
||||||
|
username = settings["smtp_username"]
|
||||||
|
password = settings["smtp_password"]
|
||||||
|
if username:
|
||||||
|
server.login(username, password)
|
||||||
|
|
||||||
|
server.send_message(msg)
|
||||||
|
|
||||||
|
logger.info("Email sent successfully to=%s subject=%s", masked_recipient, subject)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - external SMTP/network dependency
|
||||||
|
logger.exception(
|
||||||
|
"Email send failed to=%s subject=%s smtp_host=%s smtp_port=%s",
|
||||||
|
masked_recipient,
|
||||||
|
subject,
|
||||||
|
settings["smtp_host"],
|
||||||
|
settings["smtp_port"],
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=502, detail="Failed to send email") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def send_registration_email(user_id: str, email: str, token: str) -> None:
|
||||||
|
settings = get_email_settings()
|
||||||
|
link = build_confirmation_link(settings["confirmation_link_base"], token)
|
||||||
|
send_email_message(
|
||||||
|
to_email=email,
|
||||||
|
subject="Setup your Lunchtime account",
|
||||||
|
body=(
|
||||||
|
"Welcome to Lunchtime!\n\n"
|
||||||
|
"Click this link to confirm your email address:\n"
|
||||||
|
f"{link}\n\n"
|
||||||
|
"If this wasn't you, you can safely ignore this email."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_user_id_change_email(current_email: str, new_user_id: str, token: str) -> None:
|
||||||
|
settings = get_email_settings()
|
||||||
|
link = build_confirmation_link(settings["confirmation_link_base"], token)
|
||||||
|
send_email_message(
|
||||||
|
to_email=current_email,
|
||||||
|
subject="Confirm your user ID change",
|
||||||
|
body=(
|
||||||
|
"You requested a user ID change.\n\n"
|
||||||
|
"Confirm this change by clicking this link:\n"
|
||||||
|
f"{link}\n\n"
|
||||||
|
"If this wasn't you, you can safely ignore this email."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_change_new_email_confirmation(new_email: str, token: str) -> None:
|
||||||
|
settings = get_email_settings()
|
||||||
|
link = build_confirmation_link(settings["confirmation_link_base"], token)
|
||||||
|
send_email_message(
|
||||||
|
to_email=new_email,
|
||||||
|
subject="Confirm your new email address",
|
||||||
|
body=(
|
||||||
|
"You requested to change your account email address.\n\n"
|
||||||
|
"Confirm ownership of this new email by clicking this link:\n"
|
||||||
|
f"{link}\n\n"
|
||||||
|
"If this wasn't you, you can safely ignore this email."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_migration_email(email: str, user_id: str, token: str) -> None:
|
||||||
|
settings = get_email_settings()
|
||||||
|
link = build_confirmation_link(settings["confirmation_link_base"], token)
|
||||||
|
send_email_message(
|
||||||
|
to_email=email,
|
||||||
|
subject="Confirm account migration",
|
||||||
|
body=(
|
||||||
|
"You requested account migration on a new device.\n\n"
|
||||||
|
"Confirm migration by clicking this link:\n"
|
||||||
|
f"{link}\n\n"
|
||||||
|
"If this wasn't you, you can safely ignore this email."
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, UploadFile
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
IMAGES_DIR = BASE_DIR / "data" / "images"
|
||||||
|
CONFIGS_DIR = BASE_DIR / "data" / "configs"
|
||||||
|
DESCRIPTIONS_DIR = BASE_DIR / "data" / "descriptions"
|
||||||
|
|
||||||
|
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
DESCRIPTIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def load_order_config(admin_token: str) -> dict[str, Any]:
|
||||||
|
config_path = CONFIGS_DIR / f"{admin_token}.json"
|
||||||
|
if not config_path.exists():
|
||||||
|
return {
|
||||||
|
"categories": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with config_path.open("r", encoding="utf-8") as file:
|
||||||
|
return json.load(file)
|
||||||
|
except Exception:
|
||||||
|
return {"categories": []}
|
||||||
|
|
||||||
|
|
||||||
|
def save_order_config(admin_token: str, config: dict[str, Any]) -> None:
|
||||||
|
config_path = CONFIGS_DIR / f"{admin_token}.json"
|
||||||
|
try:
|
||||||
|
with config_path.open("w", encoding="utf-8") as file:
|
||||||
|
json.dump(config, file, indent=2, ensure_ascii=False)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to save configuration: {str(exc)}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_order_description(admin_token: str) -> str:
|
||||||
|
desc_path = DESCRIPTIONS_DIR / f"{admin_token}.md"
|
||||||
|
if not desc_path.exists():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with desc_path.open("r", encoding="utf-8") as file:
|
||||||
|
return file.read()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def save_order_description(admin_token: str, description: str) -> None:
|
||||||
|
desc_path = DESCRIPTIONS_DIR / f"{admin_token}.md"
|
||||||
|
try:
|
||||||
|
with desc_path.open("w", encoding="utf-8") as file:
|
||||||
|
file.write(description)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to save description: {str(exc)}")
|
||||||
|
|
||||||
|
|
||||||
|
def delete_order_description(admin_token: str) -> None:
|
||||||
|
desc_path = DESCRIPTIONS_DIR / f"{admin_token}.md"
|
||||||
|
try:
|
||||||
|
if desc_path.exists():
|
||||||
|
desc_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def delete_order_config(admin_token: str) -> None:
|
||||||
|
config_path = CONFIGS_DIR / f"{admin_token}.json"
|
||||||
|
try:
|
||||||
|
if config_path.exists():
|
||||||
|
config_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def delete_order_images(admin_token: str, image_url: str | None) -> None:
|
||||||
|
try:
|
||||||
|
if image_url and not image_url.startswith("http"):
|
||||||
|
file_path = IMAGES_DIR / image_url
|
||||||
|
if file_path.exists():
|
||||||
|
file_path.unlink()
|
||||||
|
|
||||||
|
order_images_dir = IMAGES_DIR / admin_token
|
||||||
|
if order_images_dir.exists():
|
||||||
|
shutil.rmtree(order_images_dir, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def save_uploaded_image(file: UploadFile, order_id: str, admin_token: str) -> str:
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(status_code=422, detail="No filename provided")
|
||||||
|
|
||||||
|
allowed_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||||
|
file_ext = Path(file.filename).suffix.lower()
|
||||||
|
|
||||||
|
if file_ext not in allowed_extensions:
|
||||||
|
raise HTTPException(status_code=422, detail=f"File type not allowed. Allowed: {', '.join(allowed_extensions)}")
|
||||||
|
|
||||||
|
order_images_dir = IMAGES_DIR / admin_token
|
||||||
|
order_images_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||||
|
new_filename = f"{timestamp}_{file.filename.replace(' ', '_')}"
|
||||||
|
file_path = order_images_dir / new_filename
|
||||||
|
|
||||||
|
try:
|
||||||
|
contents = file.file.read()
|
||||||
|
|
||||||
|
if not contents:
|
||||||
|
raise HTTPException(status_code=422, detail="Uploaded file is empty")
|
||||||
|
|
||||||
|
with open(file_path, "wb") as output_file:
|
||||||
|
output_file.write(contents)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to save image: {str(exc)}")
|
||||||
|
|
||||||
|
return f"{admin_token}/{new_filename}"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from ..db import get_connection
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_order_exists(order_id: str) -> sqlite3.Row:
|
||||||
|
with get_connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, admin_token, title, description, image_url, closed, created_at FROM group_orders WHERE id = ?",
|
||||||
|
(order_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_order_open(order: sqlite3.Row) -> None:
|
||||||
|
if bool(order["closed"]):
|
||||||
|
raise HTTPException(status_code=409, detail="Order is closed for submissions")
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
from math import isfinite
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from .common_service import clean_optional
|
||||||
|
|
||||||
|
def get_category_snippet(category: dict[str, Any]) -> str:
|
||||||
|
raw_snippet = str(category.get("formattedSnippet", "") or "").strip()
|
||||||
|
if not raw_snippet or "{name}" not in raw_snippet:
|
||||||
|
return "{label}: {name}"
|
||||||
|
return raw_snippet
|
||||||
|
|
||||||
|
|
||||||
|
def get_multiple_separator(category: dict[str, Any]) -> str:
|
||||||
|
raw_separator = category.get("multipleSeparator")
|
||||||
|
if isinstance(raw_separator, str):
|
||||||
|
return raw_separator
|
||||||
|
return ", "
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_submission_choices(choices: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
categories = config.get("categories") or []
|
||||||
|
normalized: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for category in categories:
|
||||||
|
category_id = str(category.get("id", "")).strip()
|
||||||
|
if not category_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_value = choices.get(category_id)
|
||||||
|
if bool(category.get("multiple")):
|
||||||
|
raw_values = raw_value if isinstance(raw_value, list) else []
|
||||||
|
normalized_values = sorted(
|
||||||
|
{
|
||||||
|
value
|
||||||
|
for value in [clean_optional(str(entry)) for entry in raw_values]
|
||||||
|
if value
|
||||||
|
}
|
||||||
|
)
|
||||||
|
normalized[category_id] = normalized_values
|
||||||
|
continue
|
||||||
|
|
||||||
|
single_raw = raw_value[0] if isinstance(raw_value, list) and raw_value else raw_value
|
||||||
|
normalized[category_id] = clean_optional(str(single_raw)) if single_raw is not None else None
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def validate_required_submission_choices(choices: dict[str, Any], config: dict[str, Any]) -> None:
|
||||||
|
categories = config.get("categories") or []
|
||||||
|
for category in categories:
|
||||||
|
if not bool(category.get("required")):
|
||||||
|
continue
|
||||||
|
|
||||||
|
category_id = str(category.get("id", "")).strip()
|
||||||
|
if not category_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = choices.get(category_id)
|
||||||
|
if isinstance(value, list):
|
||||||
|
if value:
|
||||||
|
continue
|
||||||
|
elif isinstance(value, str):
|
||||||
|
if value.strip():
|
||||||
|
continue
|
||||||
|
elif value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
label = str(category.get("label", category_id)).strip() or category_id
|
||||||
|
raise HTTPException(status_code=422, detail=f"{label} is required")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_category_item_display_name(item_name: str, category: dict[str, Any]) -> str:
|
||||||
|
for item in category.get("items") or []:
|
||||||
|
if str(item.get("name", "")) != item_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return item_name
|
||||||
|
|
||||||
|
return item_name
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_estimated_submission_total(choices: dict[str, Any], config: dict[str, Any]) -> float | None:
|
||||||
|
categories = config.get("categories") or []
|
||||||
|
total = 0.0
|
||||||
|
has_priced_items = False
|
||||||
|
|
||||||
|
for category in categories:
|
||||||
|
category_id = str(category.get("id", "")).strip()
|
||||||
|
if not category_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = choices.get(category_id)
|
||||||
|
items = category.get("items") or []
|
||||||
|
|
||||||
|
if bool(category.get("multiple")):
|
||||||
|
selected_values = value if isinstance(value, list) else []
|
||||||
|
for selected in selected_values:
|
||||||
|
selected_name = str(selected)
|
||||||
|
for item in items:
|
||||||
|
if str(item.get("name", "")) != selected_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
price = item.get("price")
|
||||||
|
if isinstance(price, (int, float)) and isfinite(float(price)):
|
||||||
|
total += float(price)
|
||||||
|
has_priced_items = True
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
selected_name = value.strip()
|
||||||
|
for item in items:
|
||||||
|
if str(item.get("name", "")) != selected_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
price = item.get("price")
|
||||||
|
if isinstance(price, (int, float)) and isfinite(float(price)):
|
||||||
|
total += float(price)
|
||||||
|
has_priced_items = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not has_priced_items:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def build_formatted_submission_string(choices: dict[str, Any], config: dict[str, Any]) -> str:
|
||||||
|
categories = config.get("categories") or []
|
||||||
|
parts: list[str] = []
|
||||||
|
|
||||||
|
for category in categories:
|
||||||
|
category_id = str(category.get("id", "")).strip()
|
||||||
|
if not category_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = choices.get(category_id)
|
||||||
|
category_part = ""
|
||||||
|
|
||||||
|
if bool(category.get("multiple")):
|
||||||
|
values = value if isinstance(value, list) else []
|
||||||
|
if values:
|
||||||
|
labels = [resolve_category_item_display_name(str(entry), category) for entry in values]
|
||||||
|
category_part = get_multiple_separator(category).join(labels)
|
||||||
|
elif isinstance(value, str) and value.strip():
|
||||||
|
category_part = resolve_category_item_display_name(value.strip(), category)
|
||||||
|
|
||||||
|
if category_part:
|
||||||
|
snippet = get_category_snippet(category)
|
||||||
|
label = str(category.get("label", category_id))
|
||||||
|
parts.append(snippet.replace("{name}", category_part).replace("{label}", label))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not bool(category.get("required")):
|
||||||
|
fallback = str(category.get("optionalFallback", "") or "").strip()
|
||||||
|
if fallback:
|
||||||
|
parts.append(fallback)
|
||||||
|
|
||||||
|
if parts:
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
return "No items selected"
|
||||||
|
|
||||||
|
|
||||||
|
def with_submission_display(submission: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
submission_with_display = dict(submission)
|
||||||
|
submission_with_display["formatted_string"] = build_formatted_submission_string(
|
||||||
|
submission_with_display.get("choices") or {},
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
submission_with_display["estimated_total"] = calculate_estimated_submission_total(
|
||||||
|
submission_with_display.get("choices") or {},
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
return submission_with_display
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"categories": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi==0.115.12
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
PyYAML==6.0.2
|
||||||
|
python-multipart==0.0.20
|
||||||
|
email-validator==2.2.0
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: lunchtime-nginx
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ../nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- burger-network
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: backend
|
||||||
|
dockerfile: Containerfile
|
||||||
|
container_name: lunchtime-backend
|
||||||
|
volumes:
|
||||||
|
- ../.data:/app/data
|
||||||
|
- ../config.yaml:/app/config.yaml:ro
|
||||||
|
environment:
|
||||||
|
- APP_ENV=production
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
- DB_PATH=/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
burger-network:
|
||||||
|
aliases:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: frontend
|
||||||
|
dockerfile: Containerfile
|
||||||
|
container_name: lunchtime-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
burger-network:
|
||||||
|
aliases:
|
||||||
|
- frontend
|
||||||
|
|
||||||
|
networks:
|
||||||
|
burger-network:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:20-slim AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN npm install -g http-server
|
||||||
|
|
||||||
|
COPY --from=build /app/dist ./
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -q -O- http://localhost:8000/index.html || exit 1
|
||||||
|
|
||||||
|
CMD ["http-server", ".", "-p", "8000", "-c-1", "--gzip", "-P", "http://localhost:8000?"]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" style="background-color: #323232;">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<title>Lunchtime</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2992
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "lunchtime-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 3000",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0 --port 3000"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.1.0",
|
||||||
|
"antd": "^5.27.3",
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
|
"marked": "^16.0.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-copy-to-clipboard": "^5.1.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.23",
|
||||||
|
"@types/react-dom": "^18.3.7",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 773 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"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"}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import React, {
|
||||||
|
memo,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useSyncExternalStore,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ConfigProvider,
|
||||||
|
Divider,
|
||||||
|
Layout,
|
||||||
|
Typography,
|
||||||
|
Result,
|
||||||
|
Space,
|
||||||
|
message,
|
||||||
|
theme as antdTheme,
|
||||||
|
} from "antd";
|
||||||
|
import Announcements from "./components/common/Announcements";
|
||||||
|
import TopNav from "./components/common/TopNav";
|
||||||
|
import WelcomeOnboardingModal from "./components/modals/WelcomeOnboardingModal";
|
||||||
|
import { navigateTo, parseRoute, subscribeToRouteChange } from "./lib/routing";
|
||||||
|
import { apiService } from "./lib/services";
|
||||||
|
import {
|
||||||
|
ensureUserId,
|
||||||
|
hasStoredUserId,
|
||||||
|
updateUserId,
|
||||||
|
} from "./lib/userIdentity";
|
||||||
|
import { THEME_MODE_KEY } from "./lib/constants";
|
||||||
|
import { getStoredValue, setStoredValue } from "./lib/storage";
|
||||||
|
import AdminView from "./views/AdminView";
|
||||||
|
import CreateOrderView from "./views/CreateOrderView";
|
||||||
|
import HomeView from "./views/HomeView";
|
||||||
|
import ParticipantView from "./views/ParticipantView";
|
||||||
|
|
||||||
|
const { defaultAlgorithm, darkAlgorithm } = antdTheme;
|
||||||
|
const { Content, Footer } = Layout;
|
||||||
|
const { Text, Link, Paragraph } = Typography;
|
||||||
|
|
||||||
|
type ThemeMode = "light" | "dark";
|
||||||
|
|
||||||
|
type AppAnnouncement = {
|
||||||
|
message?: string;
|
||||||
|
title?: string;
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const warmLightTokens = {
|
||||||
|
colorPrimary: "#c84f2a",
|
||||||
|
colorInfo: "#d97706",
|
||||||
|
colorSuccess: "#5f8f2d",
|
||||||
|
colorWarning: "#c87b16",
|
||||||
|
colorError: "#b63d26",
|
||||||
|
colorLink: "#a85a1c",
|
||||||
|
colorBgLayout: "#f8f1e6",
|
||||||
|
colorBgContainer: "#fffaf2",
|
||||||
|
colorBgElevated: "#fff6ea",
|
||||||
|
colorText: "#3c2c1f",
|
||||||
|
colorTextSecondary: "#6a5643",
|
||||||
|
colorBorder: "#e7d3ba",
|
||||||
|
borderRadius: 12,
|
||||||
|
wireframe: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const warmDarkTokens = {
|
||||||
|
colorPrimary: "#e28743",
|
||||||
|
colorInfo: "#f59e0b",
|
||||||
|
colorSuccess: "#9bc067",
|
||||||
|
colorWarning: "#e9ad4c",
|
||||||
|
colorError: "#df6a4f",
|
||||||
|
colorLink: "#f2a65a",
|
||||||
|
colorBgLayout: "#21160f",
|
||||||
|
colorBgContainer: "#2a1d14",
|
||||||
|
colorBgElevated: "#322419",
|
||||||
|
colorText: "#f4e8d7",
|
||||||
|
colorTextSecondary: "#d9c3ab",
|
||||||
|
colorBorder: "#5c422d",
|
||||||
|
borderRadius: 12,
|
||||||
|
wireframe: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const warmComponentTokens = {
|
||||||
|
Button: {
|
||||||
|
borderRadius: 999,
|
||||||
|
controlHeight: 40,
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
borderRadiusLG: 16,
|
||||||
|
headerBg: "transparent",
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
InputNumber: {
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
Select: {
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
Layout: {
|
||||||
|
bodyBg: "transparent",
|
||||||
|
headerBg: "transparent",
|
||||||
|
siderBg: "transparent",
|
||||||
|
triggerBg: "transparent",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function RouteOutlet({ isLocked }: { isLocked: boolean }) {
|
||||||
|
if (isLocked) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = useSyncExternalStore(
|
||||||
|
subscribeToRouteChange,
|
||||||
|
() => window.location.pathname,
|
||||||
|
() => "/",
|
||||||
|
);
|
||||||
|
const route = useMemo(() => parseRoute(pathname), [pathname]);
|
||||||
|
|
||||||
|
if (route.type === "home") {
|
||||||
|
return <HomeView />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.type === "create") {
|
||||||
|
return <CreateOrderView />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.type === "order") {
|
||||||
|
return <ParticipantView orderId={route.orderId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.type === "admin") {
|
||||||
|
return <AdminView orderId={route.orderId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="404"
|
||||||
|
title="Page not found"
|
||||||
|
subTitle="The page you are looking for does not exist."
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
navigateTo("/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back Home
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MemoTopNav = memo(TopNav);
|
||||||
|
const MemoAnnouncements = memo(Announcements);
|
||||||
|
|
||||||
|
function AppContent({
|
||||||
|
themeMode,
|
||||||
|
setThemeMode,
|
||||||
|
announcements,
|
||||||
|
isLocked,
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
onUserEmailChange,
|
||||||
|
}: {
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
setThemeMode: (mode: ThemeMode) => void;
|
||||||
|
announcements: AppAnnouncement[];
|
||||||
|
isLocked: boolean;
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
|
||||||
|
}) {
|
||||||
|
const appBackground =
|
||||||
|
themeMode === "dark"
|
||||||
|
? "radial-gradient(1200px 600px at -10% -10%, #5b3118 0%, rgba(91, 49, 24, 0) 60%), radial-gradient(900px 480px at 110% 0%, #4b2b1a 0%, rgba(75, 43, 26, 0) 55%), #21160f"
|
||||||
|
: "radial-gradient(1200px 600px at -10% -10%, #f8d5a7 0%, rgba(248, 213, 167, 0) 60%), radial-gradient(900px 480px at 110% 0%, #f3c389 0%, rgba(243, 195, 137, 0) 55%), #f8f1e6";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ minHeight: "100vh", background: appBackground }}>
|
||||||
|
<Content
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "24px 16px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
style={{ width: "100%", maxWidth: 980 }}
|
||||||
|
size={16}
|
||||||
|
>
|
||||||
|
<MemoTopNav
|
||||||
|
themeMode={themeMode}
|
||||||
|
onThemeChange={setThemeMode}
|
||||||
|
onHome={() => {
|
||||||
|
navigateTo("/");
|
||||||
|
}}
|
||||||
|
userEmail={userEmail}
|
||||||
|
onUserEmailChange={onUserEmailChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MemoAnnouncements announcements={announcements} />
|
||||||
|
<RouteOutlet isLocked={isLocked} key={userId} />
|
||||||
|
</Space>
|
||||||
|
</Content>
|
||||||
|
<Footer
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px 16px",
|
||||||
|
textAlign: "center",
|
||||||
|
background: "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: "100%", maxWidth: 980, margin: "0 auto" }}>
|
||||||
|
<Divider style={{ margin: "8px 0 12px" }} />
|
||||||
|
<Paragraph type="secondary">
|
||||||
|
<Link href="mailto:lunchtime@sgruber.at">Feedback</Link> •{" "}
|
||||||
|
<Link href="https://git.sgruber.at/lunchtime" target="_blank">
|
||||||
|
Source Code
|
||||||
|
</Link>
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
</Footer>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialThemeMode(): ThemeMode {
|
||||||
|
const stored = getStoredValue(THEME_MODE_KEY);
|
||||||
|
if (stored === "light" || stored === "dark") {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [userId, setUserId] = useState(() => ensureUserId());
|
||||||
|
const [isOnboardingOpen, setIsOnboardingOpen] = useState(
|
||||||
|
() => !hasStoredUserId(),
|
||||||
|
);
|
||||||
|
const [themeMode, setThemeMode] = useState<ThemeMode>(() =>
|
||||||
|
getInitialThemeMode(),
|
||||||
|
);
|
||||||
|
const [userEmail, setUserEmail] = useState("");
|
||||||
|
|
||||||
|
const [announcements, setAnnouncements] = useState<AppAnnouncement[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute("data-theme-mode", themeMode);
|
||||||
|
setStoredValue(THEME_MODE_KEY, themeMode);
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
async function loadUserProfileEmail() {
|
||||||
|
if (!userId) {
|
||||||
|
setUserEmail("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await apiService.profile.getMine();
|
||||||
|
if (mounted) {
|
||||||
|
setUserEmail(
|
||||||
|
String(profile?.email || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
if (mounted) {
|
||||||
|
setUserEmail("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadUserProfileEmail();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const config = (await apiService.config.get()) as {
|
||||||
|
announcements?: AppAnnouncement[];
|
||||||
|
};
|
||||||
|
if (mounted) {
|
||||||
|
setAnnouncements(config.announcements || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silently fail if config cannot be loaded
|
||||||
|
console.error("Failed to load config:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function handleConfirmationFromLink() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const token = String(params.get("token") || "").trim();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiService.account.confirm(token);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.user_id) {
|
||||||
|
const normalized = updateUserId(result.user_id);
|
||||||
|
setUserId(normalized);
|
||||||
|
setIsOnboardingOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.action === "email_change_new_confirm") {
|
||||||
|
message.success("New email address confirmed and updated.");
|
||||||
|
} else {
|
||||||
|
message.success("Account confirmation succeeded.");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (!cancelled) {
|
||||||
|
message.error(error?.message || "Could not confirm account action.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
params.delete("token");
|
||||||
|
const nextQuery = params.toString();
|
||||||
|
const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ""}${window.location.hash}`;
|
||||||
|
window.history.replaceState({}, "", nextUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleConfirmationFromLink();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const themeConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
algorithm: themeMode === "dark" ? darkAlgorithm : defaultAlgorithm,
|
||||||
|
token: themeMode === "dark" ? warmDarkTokens : warmLightTokens,
|
||||||
|
components: warmComponentTokens,
|
||||||
|
}),
|
||||||
|
[themeMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUserEmailChange = async (nextUserEmail: string) => {
|
||||||
|
await apiService.account.requestEmailChange(nextUserEmail);
|
||||||
|
message.info("Check your new email to confirm this change.");
|
||||||
|
return userEmail;
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeCreateAccount = async (email: string) => {
|
||||||
|
try {
|
||||||
|
await apiService.account.register(email);
|
||||||
|
message.success("Registration email sent. Open the link in your inbox to finish setup.");
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || "Could not create account.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkAccountExists = async (email: string) => {
|
||||||
|
const result = await apiService.account.lookupByEmail(email);
|
||||||
|
return !!result.exists;
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeMigrateAccount = async (email: string) => {
|
||||||
|
try {
|
||||||
|
await apiService.account.requestMigration(email);
|
||||||
|
message.success("Migration email sent. Open the link in your inbox to complete migration.");
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || "Could not migrate account.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAppLocked = isOnboardingOpen || !userId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider theme={themeConfig}>
|
||||||
|
<AppContent
|
||||||
|
themeMode={themeMode}
|
||||||
|
setThemeMode={setThemeMode}
|
||||||
|
announcements={announcements}
|
||||||
|
isLocked={isAppLocked}
|
||||||
|
userId={userId}
|
||||||
|
userEmail={userEmail}
|
||||||
|
onUserEmailChange={handleUserEmailChange}
|
||||||
|
/>
|
||||||
|
<WelcomeOnboardingModal
|
||||||
|
open={isOnboardingOpen}
|
||||||
|
themeMode={themeMode}
|
||||||
|
onThemeChange={setThemeMode}
|
||||||
|
onCheckAccountExists={checkAccountExists}
|
||||||
|
onCreateAccount={completeCreateAccount}
|
||||||
|
onMigrateAccount={completeMigrateAccount}
|
||||||
|
/>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { UserOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Card, Flex, Popover, Space, Tooltip, Typography } from "antd";
|
||||||
|
import AccountSettingsPopoverContent from "./AccountSettingsPopoverContent";
|
||||||
|
import ThemeModeToggle from "./utils/ThemeModeToggle";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function TopNav({
|
||||||
|
themeMode,
|
||||||
|
onThemeChange,
|
||||||
|
onHome,
|
||||||
|
userEmail,
|
||||||
|
onUserEmailChange,
|
||||||
|
}: {
|
||||||
|
themeMode: "light" | "dark";
|
||||||
|
onThemeChange: (mode: "light" | "dark") => void;
|
||||||
|
onHome: () => void;
|
||||||
|
userEmail: string;
|
||||||
|
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
|
||||||
|
}) {
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<Flex justify="space-between" align="center" wrap="wrap" gap={12}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
onClick={onHome}
|
||||||
|
aria-label="Go to home"
|
||||||
|
style={{ paddingInline: 0 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 18 }} strong>
|
||||||
|
Lunchtime
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Space size={8} align="center">
|
||||||
|
<ThemeModeToggle
|
||||||
|
themeMode={themeMode}
|
||||||
|
onThemeChange={onThemeChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomRight"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={setIsPopoverOpen}
|
||||||
|
content={
|
||||||
|
<AccountSettingsPopoverContent
|
||||||
|
userEmail={userEmail}
|
||||||
|
onUserEmailChange={onUserEmailChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tooltip title="Account settings">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
aria-label="User settings"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Popover>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Button, Flex, Form, Input, Modal, Space, Typography } from "antd";
|
||||||
|
import ThemeModeToggle from "./utils/ThemeModeToggle";
|
||||||
|
|
||||||
|
type ThemeMode = "light" | "dark";
|
||||||
|
type EmailLookupState = "idle" | "checking" | "exists" | "new";
|
||||||
|
|
||||||
|
export default function WelcomeOnboardingModal({
|
||||||
|
open,
|
||||||
|
themeMode,
|
||||||
|
onThemeChange,
|
||||||
|
onCheckAccountExists,
|
||||||
|
onCreateAccount,
|
||||||
|
onMigrateAccount,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
onThemeChange: (mode: ThemeMode) => void;
|
||||||
|
onCheckAccountExists: (email: string) => Promise<boolean>;
|
||||||
|
onCreateAccount: (email: string) => void | Promise<void>;
|
||||||
|
onMigrateAccount: (email: string) => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [form] = Form.useForm<{ email: string }>();
|
||||||
|
const [lookupState, setLookupState] = useState<EmailLookupState>("idle");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const lookupTimerRef = useRef<number | null>(null);
|
||||||
|
const lookupRequestIdRef = useRef(0);
|
||||||
|
|
||||||
|
const normalizeEmail = (email: string) => email.trim().toLowerCase();
|
||||||
|
|
||||||
|
const isValidEmail = (email: string) =>
|
||||||
|
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
|
||||||
|
const resolveAccountExists = async (email: string) => {
|
||||||
|
const normalized = normalizeEmail(email);
|
||||||
|
if (!normalized || !isValidEmail(normalized)) {
|
||||||
|
setLookupState("idle");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++lookupRequestIdRef.current;
|
||||||
|
setLookupState("checking");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exists = await onCheckAccountExists(normalized);
|
||||||
|
if (requestId !== lookupRequestIdRef.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLookupState(exists ? "exists" : "new");
|
||||||
|
return exists;
|
||||||
|
} catch {
|
||||||
|
if (requestId === lookupRequestIdRef.current) {
|
||||||
|
setLookupState("idle");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.resetFields();
|
||||||
|
setLookupState("idle");
|
||||||
|
setIsSubmitting(false);
|
||||||
|
lookupRequestIdRef.current += 1;
|
||||||
|
if (lookupTimerRef.current) {
|
||||||
|
window.clearTimeout(lookupTimerRef.current);
|
||||||
|
lookupTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [form, open]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (lookupTimerRef.current) {
|
||||||
|
window.clearTimeout(lookupTimerRef.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttonLabel = useMemo(() => {
|
||||||
|
if (lookupState === "exists") {
|
||||||
|
return "Migrate account";
|
||||||
|
}
|
||||||
|
if (lookupState === "checking") {
|
||||||
|
return "Checking account...";
|
||||||
|
}
|
||||||
|
if (lookupState === "new") {
|
||||||
|
return "Create new account";
|
||||||
|
}
|
||||||
|
return "Continue";
|
||||||
|
}, [lookupState]);
|
||||||
|
|
||||||
|
const helperText = useMemo(() => {
|
||||||
|
if (lookupState === "exists") {
|
||||||
|
return "An account was found for this email. We'll send a migration confirmation link.";
|
||||||
|
}
|
||||||
|
if (lookupState === "new") {
|
||||||
|
return "No account found for this email. We'll create a new account.";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}, [lookupState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
maskClosable={false}
|
||||||
|
keyboard={false}
|
||||||
|
closable={false}
|
||||||
|
footer={null}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={14} style={{ width: "100%" }}>
|
||||||
|
<Flex align="center" justify="space-between" style={{ width: "100%" }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
Welcome to Lunchtime
|
||||||
|
</Typography.Title>
|
||||||
|
<ThemeModeToggle themeMode={themeMode} onThemeChange={onThemeChange} />
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Typography.Paragraph style={{ marginBottom: 6 }}>
|
||||||
|
Enter your account email to continue. We'll automatically detect whether to create a new account or migrate an existing one.
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onValuesChange={(_changedValues, values) => {
|
||||||
|
const currentEmail = String(values.email || "");
|
||||||
|
if (lookupTimerRef.current) {
|
||||||
|
window.clearTimeout(lookupTimerRef.current);
|
||||||
|
lookupTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
lookupTimerRef.current = window.setTimeout(() => {
|
||||||
|
void resolveAccountExists(currentEmail);
|
||||||
|
}, 350);
|
||||||
|
}}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
const normalizedEmail = normalizeEmail(values.email);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
let exists = lookupState === "exists";
|
||||||
|
if (lookupState !== "exists" && lookupState !== "new") {
|
||||||
|
const checked = await resolveAccountExists(normalizedEmail);
|
||||||
|
exists = checked === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
await onMigrateAccount(normalizedEmail);
|
||||||
|
} else {
|
||||||
|
await onCreateAccount(normalizedEmail);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "Email cannot be empty." },
|
||||||
|
{ type: "email", message: "Enter a valid email address." },
|
||||||
|
]}
|
||||||
|
extra={helperText || undefined}
|
||||||
|
>
|
||||||
|
<Input placeholder="alex@example.com" autoFocus maxLength={320} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
block
|
||||||
|
loading={isSubmitting || lookupState === "checking"}
|
||||||
|
>
|
||||||
|
{buttonLabel}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { CheckOutlined } from "@ant-design/icons";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Flex,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from "antd";
|
||||||
|
|
||||||
|
export default function AccountSettingsPopoverContent({
|
||||||
|
userEmail,
|
||||||
|
onUserEmailChange,
|
||||||
|
}: {
|
||||||
|
userEmail: string;
|
||||||
|
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
|
||||||
|
}) {
|
||||||
|
const [form] = Form.useForm<{ email: string }>();
|
||||||
|
const watchedEmail = Form.useWatch("email", form) || "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setFieldsValue({ email: userEmail });
|
||||||
|
}, [form, userEmail]);
|
||||||
|
|
||||||
|
const hasChanges = watchedEmail.trim().toLowerCase() !== userEmail;
|
||||||
|
|
||||||
|
const saveAccountSettings = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
|
||||||
|
const nextEmail = values.email.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (nextEmail !== userEmail) {
|
||||||
|
await onUserEmailChange(nextEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success("Account settings saved");
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.errorFields) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.error(error?.message || "Could not save account settings.");
|
||||||
|
// Validation errors are shown by Form.Item rules.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const discardAccountChanges = () => {
|
||||||
|
form.setFieldsValue({ email: userEmail });
|
||||||
|
message.info("Changes discarded");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={10} style={{ maxWidth: 340 }}>
|
||||||
|
<Typography.Title level={4}>Account Settings</Typography.Title>
|
||||||
|
|
||||||
|
<Form form={form} layout="vertical" onFinish={saveAccountSettings}>
|
||||||
|
<Form.Item
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "Email cannot be empty." },
|
||||||
|
{ type: "email", message: "Enter a valid email address." },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="alex@example.com" maxLength={320} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{watchedEmail.trim().toLowerCase() !== userEmail && (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="A confirmation email will be sent to the new address before the change is applied."
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Flex gap={8}>
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
type="primary"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
htmlType="submit"
|
||||||
|
block
|
||||||
|
disabled={!hasChanges}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
onClick={discardAccountChanges}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Form>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import React, { memo, useMemo, useState } from "react";
|
||||||
|
import { Alert, Space } from "antd";
|
||||||
|
|
||||||
|
const ALERT_TYPES = new Set(["success", "info", "warning", "error"]);
|
||||||
|
|
||||||
|
type Announcement = {
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toAlertType(rawType: unknown) {
|
||||||
|
const normalized = String(rawType || "info").trim().toLowerCase();
|
||||||
|
|
||||||
|
if (ALERT_TYPES.has(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "warn") {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "danger" || normalized === "critical") {
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(function Announcements({ announcements }: { announcements?: Announcement[] }) {
|
||||||
|
const [closedIds, setClosedIds] = useState(() => new Set<string>());
|
||||||
|
|
||||||
|
const normalizedAnnouncements = useMemo(
|
||||||
|
() =>
|
||||||
|
(announcements || [])
|
||||||
|
.map((announcement, idx) => {
|
||||||
|
if (!announcement || typeof announcement !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = String(announcement.message || "").trim();
|
||||||
|
if (!message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = toAlertType(announcement.type);
|
||||||
|
const title = String(announcement.title || "").trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${type}-${title}-${message}-${idx}`,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean),
|
||||||
|
[announcements],
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleAnnouncements = useMemo(
|
||||||
|
() => normalizedAnnouncements.filter((announcement) => !closedIds.has((announcement as any).id)),
|
||||||
|
[closedIds, normalizedAnnouncements],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (visibleAnnouncements.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||||
|
{visibleAnnouncements.map((announcement: any) => (
|
||||||
|
<Alert
|
||||||
|
key={announcement.id}
|
||||||
|
type={announcement.type}
|
||||||
|
message={announcement.title || announcement.type.charAt(0).toUpperCase() + announcement.type.slice(1)}
|
||||||
|
description={announcement.message}
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
onClose={() => {
|
||||||
|
setClosedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(announcement.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { UserOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Card, Flex, Popover, Space, Tooltip, Typography } from "antd";
|
||||||
|
import AccountSettingsPopoverContent from "../account/AccountSettingsPopoverContent";
|
||||||
|
import ThemeModeToggle from "../utils/ThemeModeToggle";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function TopNav({
|
||||||
|
themeMode,
|
||||||
|
onThemeChange,
|
||||||
|
onHome,
|
||||||
|
userEmail,
|
||||||
|
onUserEmailChange,
|
||||||
|
}: {
|
||||||
|
themeMode: "light" | "dark";
|
||||||
|
onThemeChange: (mode: "light" | "dark") => void;
|
||||||
|
onHome: () => void;
|
||||||
|
userEmail: string;
|
||||||
|
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
|
||||||
|
}) {
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<Flex justify="space-between" align="center" wrap="wrap" gap={12}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
onClick={onHome}
|
||||||
|
aria-label="Go to home"
|
||||||
|
style={{ paddingInline: 0 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 18 }} strong>
|
||||||
|
Lunchtime
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Space size={8} align="center">
|
||||||
|
<ThemeModeToggle
|
||||||
|
themeMode={themeMode}
|
||||||
|
onThemeChange={onThemeChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomRight"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={setIsPopoverOpen}
|
||||||
|
content={
|
||||||
|
<AccountSettingsPopoverContent
|
||||||
|
userEmail={userEmail}
|
||||||
|
onUserEmailChange={onUserEmailChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tooltip title="Account settings">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
aria-label="User settings"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Popover>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { Button, message, Space } from "antd";
|
||||||
|
import { ExportOutlined, ImportOutlined } from "@ant-design/icons";
|
||||||
|
import type { OrderFormConfig } from "./OrderFormConfigBuilder";
|
||||||
|
import ExportSelectionModal, { type ExportSelectionState } from "../modals/ExportSelectionModal";
|
||||||
|
|
||||||
|
type MenuConfigImportExportProps = {
|
||||||
|
config?: OrderFormConfig | null;
|
||||||
|
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
||||||
|
showImport?: boolean;
|
||||||
|
showExport?: boolean;
|
||||||
|
buttonType?: "link" | "text" | "default" | "primary" | "dashed";
|
||||||
|
fileNameBase?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeConfig(value: unknown): OrderFormConfig {
|
||||||
|
if (value && typeof value === "object" && Array.isArray((value as any).categories)) {
|
||||||
|
return { categories: (value as any).categories };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { categories: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MenuConfigImportExport({
|
||||||
|
config,
|
||||||
|
onImportConfig,
|
||||||
|
showImport = true,
|
||||||
|
showExport = true,
|
||||||
|
buttonType = "link",
|
||||||
|
fileNameBase = "order",
|
||||||
|
}: MenuConfigImportExportProps) {
|
||||||
|
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||||
|
const [exportSelection, setExportSelection] = useState<ExportSelectionState>({
|
||||||
|
title: true,
|
||||||
|
menu: true,
|
||||||
|
description: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleImportJson = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
try {
|
||||||
|
const content = e.target?.result;
|
||||||
|
if (typeof content !== "string") {
|
||||||
|
message.error("Failed to read import file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(content) as {
|
||||||
|
menu?: unknown;
|
||||||
|
categories?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextConfig = normalizeConfig(
|
||||||
|
Object.prototype.hasOwnProperty.call(parsed, "menu")
|
||||||
|
? parsed.menu
|
||||||
|
: parsed,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!Array.isArray(nextConfig.categories)) {
|
||||||
|
message.error("Invalid menu configuration in import file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onImportConfig?.(nextConfig);
|
||||||
|
message.success("Imported menu configuration");
|
||||||
|
} catch (_error) {
|
||||||
|
message.error("Failed to import JSON file");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file);
|
||||||
|
|
||||||
|
if (importFileInputRef.current) {
|
||||||
|
importFileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportJson = () => {
|
||||||
|
if (!exportSelection.title && !exportSelection.menu && !exportSelection.description) {
|
||||||
|
message.warning("Select at least one section to export");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
menu?: OrderFormConfig;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (exportSelection.title) {
|
||||||
|
payload.title = fileNameBase || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exportSelection.description) {
|
||||||
|
payload.description = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exportSelection.menu) {
|
||||||
|
payload.menu = normalizeConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeBase = String(fileNameBase || "order")
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${safeBase || "order"}-menu.json`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setIsExportModalOpen(false);
|
||||||
|
message.success("Exported menu configuration");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openExport = () => {
|
||||||
|
setIsExportModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space size="small">
|
||||||
|
{showImport && (
|
||||||
|
<Button
|
||||||
|
type={buttonType}
|
||||||
|
icon={<ImportOutlined />}
|
||||||
|
onClick={() => importFileInputRef.current?.click()}
|
||||||
|
disabled={!onImportConfig}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showExport && (
|
||||||
|
<Button type={buttonType} icon={<ExportOutlined />} onClick={openExport}>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={importFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
onChange={handleImportJson}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExportSelectionModal
|
||||||
|
open={isExportModalOpen}
|
||||||
|
title="Export Order Data"
|
||||||
|
description="Select which sections of the order you want to include in the export:"
|
||||||
|
options={[
|
||||||
|
{ key: "title", label: "Title" },
|
||||||
|
{ key: "menu", label: "Menu" },
|
||||||
|
{ key: "description", label: "Description" },
|
||||||
|
]}
|
||||||
|
selected={exportSelection}
|
||||||
|
onCancel={() => setIsExportModalOpen(false)}
|
||||||
|
onConfirm={handleExportJson}
|
||||||
|
onSelectedChange={setExportSelection}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Collapse,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
List,
|
||||||
|
Space,
|
||||||
|
Tooltip,
|
||||||
|
InputNumber,
|
||||||
|
message,
|
||||||
|
Popconfirm,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
buildExampleFormattedOrderString,
|
||||||
|
DEFAULT_CATEGORY_SNIPPET,
|
||||||
|
DEFAULT_MULTIPLE_SEPARATOR,
|
||||||
|
ORDER_FORMAT_VALUE_PLACEHOLDER,
|
||||||
|
} from "../../lib/orderFormatting";
|
||||||
|
import type {
|
||||||
|
OrderFormItem,
|
||||||
|
OrderFormCategory,
|
||||||
|
OrderFormConfig,
|
||||||
|
} from "../../lib/types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
UpOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import MenuConfigImportExport from "./MenuConfigImportExport";
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface OrderFormConfigBuilderProps {
|
||||||
|
config: OrderFormConfig;
|
||||||
|
onChange: (config: OrderFormConfig) => void;
|
||||||
|
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
||||||
|
editable?: boolean;
|
||||||
|
showFormatPreview?: boolean;
|
||||||
|
showImportExport?: boolean;
|
||||||
|
categoriesExpandedByDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export types for backward compatibility
|
||||||
|
export type { OrderFormItem, OrderFormCategory, OrderFormConfig };
|
||||||
|
|
||||||
|
export default function OrderFormConfigBuilder({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
onImportConfig,
|
||||||
|
editable = true,
|
||||||
|
showFormatPreview = true,
|
||||||
|
showImportExport = editable,
|
||||||
|
categoriesExpandedByDefault = true,
|
||||||
|
}: OrderFormConfigBuilderProps) {
|
||||||
|
const [form] = Form.useForm<OrderFormConfig>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setFieldsValue({ categories: config.categories ?? [] });
|
||||||
|
}, [config, form]);
|
||||||
|
|
||||||
|
const handleValuesChange = (_: unknown, allValues: OrderFormConfig) => {
|
||||||
|
onChange({ categories: allValues.categories ?? [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewCategory = (): OrderFormCategory => {
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
id: `cat_${now}`,
|
||||||
|
label: "New Category",
|
||||||
|
required: false,
|
||||||
|
multiple: false,
|
||||||
|
custom: false,
|
||||||
|
formattedSnippet: DEFAULT_CATEGORY_SNIPPET,
|
||||||
|
multipleSeparator: DEFAULT_MULTIPLE_SEPARATOR,
|
||||||
|
optionalFallback: "",
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewItem = (): OrderFormItem => {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
price: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDuplicatedItem = (item?: OrderFormItem): OrderFormItem => {
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
name: item?.name ? `${item.name}_copy` : `item_${now}`,
|
||||||
|
price: item?.price,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
initialValues={{ categories: config.categories ?? [] }}
|
||||||
|
onValuesChange={handleValuesChange}
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={!editable}
|
||||||
|
>
|
||||||
|
<Form.List name="categories">
|
||||||
|
{(categoryFields, categoryOps) => (
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }} size="middle">
|
||||||
|
{showImportExport && (
|
||||||
|
<MenuConfigImportExport
|
||||||
|
config={config}
|
||||||
|
onImportConfig={
|
||||||
|
editable
|
||||||
|
? async (nextConfig) => {
|
||||||
|
form.setFieldsValue({ categories: nextConfig.categories ?? [] });
|
||||||
|
onChange({ categories: nextConfig.categories ?? [] });
|
||||||
|
await onImportConfig?.(nextConfig);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
fileNameBase="order"
|
||||||
|
buttonType="default"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }} size="small">
|
||||||
|
{categoryFields.map((categoryField) => (
|
||||||
|
<Collapse
|
||||||
|
key={categoryField.key}
|
||||||
|
bordered={false}
|
||||||
|
defaultActiveKey={
|
||||||
|
categoriesExpandedByDefault
|
||||||
|
? [String(categoryField.key)]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: String(categoryField.key),
|
||||||
|
label: (
|
||||||
|
<Space size="small" wrap>
|
||||||
|
<Form.Item
|
||||||
|
name={[categoryField.name, "label"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: "Category label is required",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="Category name"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 220 }}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
extra: editable ? (
|
||||||
|
<Space
|
||||||
|
size="small"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Tooltip title="Move category up">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<UpOutlined />}
|
||||||
|
disabled={categoryField.name === 0}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
categoryOps.move(
|
||||||
|
categoryField.name,
|
||||||
|
categoryField.name - 1,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Move category down">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<DownOutlined />}
|
||||||
|
disabled={
|
||||||
|
categoryField.name === categoryFields.length - 1
|
||||||
|
}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
categoryOps.move(
|
||||||
|
categoryField.name,
|
||||||
|
categoryField.name + 1,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm
|
||||||
|
title="Delete category?"
|
||||||
|
description="This will remove the category and all its items."
|
||||||
|
okText="Delete"
|
||||||
|
cancelText="Cancel"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={(event) => {
|
||||||
|
event?.stopPropagation?.();
|
||||||
|
categoryOps.remove(categoryField.name);
|
||||||
|
}}
|
||||||
|
onCancel={(event) => event?.stopPropagation?.()}
|
||||||
|
>
|
||||||
|
<Tooltip title="Delete category">
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
) : null,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
style={{ display: "none" }}
|
||||||
|
name={[categoryField.name, "id"]}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
{/* Flags Section */}
|
||||||
|
<div>
|
||||||
|
<Title level={5} style={{ marginBottom: 12 }}>
|
||||||
|
Options
|
||||||
|
</Title>
|
||||||
|
<Space
|
||||||
|
size={0}
|
||||||
|
direction="vertical"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name={[categoryField.name, "required"]}
|
||||||
|
valuePropName="checked"
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Checkbox>
|
||||||
|
Require at least one selection
|
||||||
|
</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name={[categoryField.name, "multiple"]}
|
||||||
|
valuePropName="checked"
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Checkbox>Allow multiple entries</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name={[categoryField.name, "custom"]}
|
||||||
|
valuePropName="checked"
|
||||||
|
initialValue={false}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Checkbox>Allow custom entries</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: "12px 0" }} />
|
||||||
|
|
||||||
|
{/* Format Section */}
|
||||||
|
<div>
|
||||||
|
<Title level={5} style={{ marginBottom: 12 }}>
|
||||||
|
Format
|
||||||
|
</Title>
|
||||||
|
<Form.Item
|
||||||
|
label="Format string"
|
||||||
|
name={[categoryField.name, "formattedSnippet"]}
|
||||||
|
extra="Use {name} where selected name(s) should appear. You can also use {label}."
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: "Format is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validator: (_rule, value) => {
|
||||||
|
const snippet = String(
|
||||||
|
value || "",
|
||||||
|
).trim();
|
||||||
|
if (
|
||||||
|
!snippet.includes(
|
||||||
|
ORDER_FORMAT_VALUE_PLACEHOLDER,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(
|
||||||
|
`Snippet must include {name}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
<Input placeholder="Example: {label}: {name}" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Separator & Fallback Section */}
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(prevValues, nextValues) => {
|
||||||
|
const prevCategory =
|
||||||
|
prevValues?.categories?.[categoryField.name];
|
||||||
|
const nextCategory =
|
||||||
|
nextValues?.categories?.[categoryField.name];
|
||||||
|
return (
|
||||||
|
prevCategory?.multiple !==
|
||||||
|
nextCategory?.multiple ||
|
||||||
|
prevCategory?.required !==
|
||||||
|
nextCategory?.required
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
const isMultiple = !!getFieldValue([
|
||||||
|
"categories",
|
||||||
|
categoryField.name,
|
||||||
|
"multiple",
|
||||||
|
]);
|
||||||
|
const isRequired = !!getFieldValue([
|
||||||
|
"categories",
|
||||||
|
categoryField.name,
|
||||||
|
"required",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isMultiple && isRequired) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isMultiple && (
|
||||||
|
<Form.Item
|
||||||
|
label="Multiple entries separator"
|
||||||
|
name={[
|
||||||
|
categoryField.name,
|
||||||
|
"multipleSeparator",
|
||||||
|
]}
|
||||||
|
extra="Text used between selected entries."
|
||||||
|
initialValue=", "
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
<Input allowClear placeholder=", " />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
{!isRequired && (
|
||||||
|
<Form.Item
|
||||||
|
label="Fallback text (optional)"
|
||||||
|
name={[
|
||||||
|
categoryField.name,
|
||||||
|
"optionalFallback",
|
||||||
|
]}
|
||||||
|
extra="Used when no selection is made and this category is not required."
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="Example: No drink selected"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Divider style={{ margin: "12px 0" }} />
|
||||||
|
|
||||||
|
{/* Items Section */}
|
||||||
|
<Title level={5} style={{ marginBottom: 12 }}>
|
||||||
|
Items
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Form.List name={[categoryField.name, "items"]}>
|
||||||
|
{(itemFields, itemOps) => (
|
||||||
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
dataSource={itemFields}
|
||||||
|
locale={{ emptyText: "No items yet" }}
|
||||||
|
renderItem={(itemField) => (
|
||||||
|
<List.Item
|
||||||
|
key={itemField.key}
|
||||||
|
actions={
|
||||||
|
editable
|
||||||
|
? [
|
||||||
|
<Tooltip
|
||||||
|
key="up"
|
||||||
|
title="Move item up"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<UpOutlined />}
|
||||||
|
disabled={
|
||||||
|
itemField.name === 0
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
itemOps.move(
|
||||||
|
itemField.name,
|
||||||
|
itemField.name - 1,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>,
|
||||||
|
<Tooltip
|
||||||
|
key="down"
|
||||||
|
title="Move item down"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<DownOutlined />}
|
||||||
|
disabled={
|
||||||
|
itemField.name ===
|
||||||
|
itemFields.length - 1
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
itemOps.move(
|
||||||
|
itemField.name,
|
||||||
|
itemField.name + 1,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>,
|
||||||
|
<Tooltip
|
||||||
|
key="duplicate"
|
||||||
|
title="Duplicate item"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const itemValue =
|
||||||
|
form.getFieldValue([
|
||||||
|
"categories",
|
||||||
|
categoryField.name,
|
||||||
|
"items",
|
||||||
|
itemField.name,
|
||||||
|
]) as
|
||||||
|
| OrderFormItem
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
itemOps.add(
|
||||||
|
buildDuplicatedItem(
|
||||||
|
itemValue,
|
||||||
|
),
|
||||||
|
itemField.name + 1,
|
||||||
|
);
|
||||||
|
message.success(
|
||||||
|
"Item duplicated",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>,
|
||||||
|
<Tooltip
|
||||||
|
key="delete"
|
||||||
|
title="Delete item"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
itemOps.remove(
|
||||||
|
itemField.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tooltip>,
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space size="middle" wrap>
|
||||||
|
<Form.Item
|
||||||
|
label="Name"
|
||||||
|
name={[itemField.name, "name"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: "Name is required",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="Item name"
|
||||||
|
style={{ width: 180 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Price"
|
||||||
|
name={[itemField.name, "price"]}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
placeholder="0.00"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
min={0}
|
||||||
|
step={0.5}
|
||||||
|
precision={2}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
block
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
itemOps.add(createNewItem())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add Item
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => categoryOps.add(createNewCategory())}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Add Category
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFormatPreview && (
|
||||||
|
<Form.Item noStyle shouldUpdate={true}>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
const categories = getFieldValue("categories") || [];
|
||||||
|
const exampleOutput = buildExampleFormattedOrderString({ categories });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
message="Example order message"
|
||||||
|
description={
|
||||||
|
<Text code copyable>
|
||||||
|
{exampleOutput}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Checkbox, Modal, Space, Typography } from "antd";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export type ExportSelectionState = Record<string, boolean>;
|
||||||
|
|
||||||
|
export type ExportSelectionOption = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ExportSelectionModal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
okText = "Export",
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
onSelectedChange,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
options: ExportSelectionOption[];
|
||||||
|
selected: ExportSelectionState;
|
||||||
|
okText?: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onSelectedChange: (next: ExportSelectionState) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
open={open}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={onConfirm}
|
||||||
|
okText={okText}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
{description ? <Text>{description}</Text> : null}
|
||||||
|
|
||||||
|
{options.map((option) => (
|
||||||
|
<Checkbox
|
||||||
|
key={option.key}
|
||||||
|
checked={!!selected[option.key]}
|
||||||
|
onChange={(event) =>
|
||||||
|
onSelectedChange({
|
||||||
|
...selected,
|
||||||
|
[option.key]: event.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Button, Flex, Form, Input, Modal, Space, Typography } from "antd";
|
||||||
|
import ThemeModeToggle from "../utils/ThemeModeToggle";
|
||||||
|
|
||||||
|
type ThemeMode = "light" | "dark";
|
||||||
|
type EmailLookupState = "idle" | "checking" | "exists" | "new";
|
||||||
|
|
||||||
|
export default function WelcomeOnboardingModal({
|
||||||
|
open,
|
||||||
|
themeMode,
|
||||||
|
onThemeChange,
|
||||||
|
onCheckAccountExists,
|
||||||
|
onCreateAccount,
|
||||||
|
onMigrateAccount,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
onThemeChange: (mode: ThemeMode) => void;
|
||||||
|
onCheckAccountExists: (email: string) => Promise<boolean>;
|
||||||
|
onCreateAccount: (email: string) => void | Promise<void>;
|
||||||
|
onMigrateAccount: (email: string) => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [form] = Form.useForm<{ email: string }>();
|
||||||
|
const [lookupState, setLookupState] = useState<EmailLookupState>("idle");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const lookupTimerRef = useRef<number | null>(null);
|
||||||
|
const lookupRequestIdRef = useRef(0);
|
||||||
|
|
||||||
|
const normalizeEmail = (email: string) => email.trim().toLowerCase();
|
||||||
|
|
||||||
|
const isValidEmail = (email: string) =>
|
||||||
|
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
|
||||||
|
const resolveAccountExists = async (email: string) => {
|
||||||
|
const normalized = normalizeEmail(email);
|
||||||
|
if (!normalized || !isValidEmail(normalized)) {
|
||||||
|
setLookupState("idle");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++lookupRequestIdRef.current;
|
||||||
|
setLookupState("checking");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exists = await onCheckAccountExists(normalized);
|
||||||
|
if (requestId !== lookupRequestIdRef.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLookupState(exists ? "exists" : "new");
|
||||||
|
return exists;
|
||||||
|
} catch {
|
||||||
|
if (requestId === lookupRequestIdRef.current) {
|
||||||
|
setLookupState("idle");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.resetFields();
|
||||||
|
setLookupState("idle");
|
||||||
|
setIsSubmitting(false);
|
||||||
|
lookupRequestIdRef.current += 1;
|
||||||
|
if (lookupTimerRef.current) {
|
||||||
|
window.clearTimeout(lookupTimerRef.current);
|
||||||
|
lookupTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [form, open]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (lookupTimerRef.current) {
|
||||||
|
window.clearTimeout(lookupTimerRef.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttonLabel = useMemo(() => {
|
||||||
|
if (lookupState === "exists") {
|
||||||
|
return "Migrate account";
|
||||||
|
}
|
||||||
|
if (lookupState === "checking") {
|
||||||
|
return "Checking account...";
|
||||||
|
}
|
||||||
|
if (lookupState === "new") {
|
||||||
|
return "Create new account";
|
||||||
|
}
|
||||||
|
return "Continue";
|
||||||
|
}, [lookupState]);
|
||||||
|
|
||||||
|
const helperText = useMemo(() => {
|
||||||
|
if (lookupState === "exists") {
|
||||||
|
return "An account was found for this email. We'll send a migration confirmation link.";
|
||||||
|
}
|
||||||
|
if (lookupState === "new") {
|
||||||
|
return "No account found for this email. We'll create a new account.";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}, [lookupState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
maskClosable={false}
|
||||||
|
keyboard={false}
|
||||||
|
closable={false}
|
||||||
|
footer={null}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={14} style={{ width: "100%" }}>
|
||||||
|
<Flex align="center" justify="space-between" style={{ width: "100%" }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
Welcome to Lunchtime
|
||||||
|
</Typography.Title>
|
||||||
|
<ThemeModeToggle themeMode={themeMode} onThemeChange={onThemeChange} />
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Typography.Paragraph style={{ marginBottom: 6 }}>
|
||||||
|
Enter your account email to continue. We'll automatically detect whether to create a new account or migrate an existing one.
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onValuesChange={(_changedValues, values) => {
|
||||||
|
const currentEmail = String(values.email || "");
|
||||||
|
if (lookupTimerRef.current) {
|
||||||
|
window.clearTimeout(lookupTimerRef.current);
|
||||||
|
lookupTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
lookupTimerRef.current = window.setTimeout(() => {
|
||||||
|
void resolveAccountExists(currentEmail);
|
||||||
|
}, 350);
|
||||||
|
}}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
const normalizedEmail = normalizeEmail(values.email);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
let exists = lookupState === "exists";
|
||||||
|
if (lookupState !== "exists" && lookupState !== "new") {
|
||||||
|
const checked = await resolveAccountExists(normalizedEmail);
|
||||||
|
exists = checked === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
await onMigrateAccount(normalizedEmail);
|
||||||
|
} else {
|
||||||
|
await onCreateAccount(normalizedEmail);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "Email cannot be empty." },
|
||||||
|
{ type: "email", message: "Enter a valid email address." },
|
||||||
|
]}
|
||||||
|
extra={helperText || undefined}
|
||||||
|
>
|
||||||
|
<Input placeholder="alex@example.com" autoFocus maxLength={320} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
block
|
||||||
|
loading={isSubmitting || lookupState === "checking"}
|
||||||
|
>
|
||||||
|
{buttonLabel}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import ReadOnlyOrderOverviewCard from "./ReadOnlyOrderOverviewCard";
|
||||||
|
|
||||||
|
export default ReadOnlyOrderOverviewCard;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card, Col, Empty, Flex, Image, Row, Space, Tabs, Typography } from "antd";
|
||||||
|
import { markdownToHtml } from "../../lib/markdown";
|
||||||
|
import MenuConfigImportExport from "../forms/MenuConfigImportExport";
|
||||||
|
import OrderFormConfigBuilder, {
|
||||||
|
OrderFormConfig,
|
||||||
|
} from "../forms/OrderFormConfigBuilder";
|
||||||
|
|
||||||
|
type Order = {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
image_url?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
creator_user_id?: string | null;
|
||||||
|
creator_email?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTimestamp(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageUrl(imageUrl: string | null | undefined): string {
|
||||||
|
if (!imageUrl) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageUrl.startsWith("http")) {
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/api/images/${imageUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReadOnlyOrderOverviewCard({
|
||||||
|
order,
|
||||||
|
config,
|
||||||
|
onImportConfig,
|
||||||
|
}: {
|
||||||
|
order: Order | null;
|
||||||
|
config?: OrderFormConfig | null;
|
||||||
|
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
if (!order) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDescription = order.description || "";
|
||||||
|
const currentImageUrl = order.image_url || "";
|
||||||
|
const currentConfig: OrderFormConfig = {
|
||||||
|
categories: Array.isArray(config?.categories) ? config.categories : [],
|
||||||
|
};
|
||||||
|
const creatorLabel =
|
||||||
|
order.creator_email || order.creator_user_id || "Unknown";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Flex vertical gap={0}>
|
||||||
|
{order.title}
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12, fontWeight: "normal" }}>
|
||||||
|
by {creatorLabel} ({formatTimestamp(order.created_at)})
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<MenuConfigImportExport
|
||||||
|
config={currentConfig}
|
||||||
|
showImport={false}
|
||||||
|
onImportConfig={onImportConfig}
|
||||||
|
fileNameBase={order.title || "order"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="description"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: "description",
|
||||||
|
label: "Description",
|
||||||
|
children: (
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={order.image_url ? 14 : 24}>
|
||||||
|
{!!currentDescription ? (
|
||||||
|
<Typography.Paragraph>
|
||||||
|
<span
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: markdownToHtml(currentDescription),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="No description provided"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
{order.image_url && (
|
||||||
|
<Col xs={24} md={10}>
|
||||||
|
{currentImageUrl ? (
|
||||||
|
<Image
|
||||||
|
src={getImageUrl(currentImageUrl)}
|
||||||
|
alt={`${order.title} image`}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: "zoom-in",
|
||||||
|
}}
|
||||||
|
preview={{
|
||||||
|
getContainer: () => document.body,
|
||||||
|
zIndex: 3000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="No image provided"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "menu",
|
||||||
|
label: "Menu",
|
||||||
|
children: (
|
||||||
|
<OrderFormConfigBuilder
|
||||||
|
editable={false}
|
||||||
|
showFormatPreview={false}
|
||||||
|
showImportExport={false}
|
||||||
|
config={currentConfig}
|
||||||
|
categoriesExpandedByDefault={false}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></Tabs>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ErrorResult from "./ErrorResult";
|
||||||
|
import LoadingSkeleton from "./LoadingSkeleton";
|
||||||
|
|
||||||
|
export default function AsyncContent({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
errorTitle = "Something went wrong",
|
||||||
|
errorSubtitle,
|
||||||
|
loadingRows = 4,
|
||||||
|
loadingSections = 2,
|
||||||
|
isEmpty = false,
|
||||||
|
emptyState = null,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
loading: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
onRetry?: () => void;
|
||||||
|
errorTitle?: string;
|
||||||
|
errorSubtitle?: string;
|
||||||
|
loadingRows?: number;
|
||||||
|
loadingSections?: number;
|
||||||
|
isEmpty?: boolean;
|
||||||
|
emptyState?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSkeleton rows={loadingRows} sections={loadingSections} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ErrorResult
|
||||||
|
title={errorTitle}
|
||||||
|
subtitle={errorSubtitle || error}
|
||||||
|
onRetry={onRetry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return emptyState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button, Result } from "antd";
|
||||||
|
|
||||||
|
export default function ErrorResult({
|
||||||
|
title = "Something went wrong",
|
||||||
|
subtitle = "Please try again in a moment.",
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title={title}
|
||||||
|
subTitle={subtitle}
|
||||||
|
extra={
|
||||||
|
onRetry ? (
|
||||||
|
<Button type="primary" onClick={onRetry}>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card, Skeleton, Space } from "antd";
|
||||||
|
|
||||||
|
export default function LoadingSkeleton({ rows = 4, sections = 2 }: { rows?: number; sections?: number }) {
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }} size={16}>
|
||||||
|
{Array.from({ length: sections }).map((_, index) => (
|
||||||
|
<Card key={`loading-skeleton-${index}`}>
|
||||||
|
<Skeleton active title paragraph={{ rows }} />
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { MoonOutlined, SunOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Tooltip } from "antd";
|
||||||
|
|
||||||
|
type ThemeMode = "light" | "dark";
|
||||||
|
|
||||||
|
export default function ThemeModeToggle({
|
||||||
|
themeMode,
|
||||||
|
onThemeChange,
|
||||||
|
}: {
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
onThemeChange: (mode: ThemeMode) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip title="Toggle theme">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
aria-label="Toggle light and dark theme"
|
||||||
|
icon={themeMode === "dark" ? <SunOutlined /> : <MoonOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
onThemeChange(themeMode === "dark" ? "light" : "dark")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
List,
|
||||||
|
Popconfirm,
|
||||||
|
Typography,
|
||||||
|
} from "antd";
|
||||||
|
|
||||||
|
export default function AdminControlCenterCard({
|
||||||
|
isClosed,
|
||||||
|
updatingOrderStatus,
|
||||||
|
deletingOrder,
|
||||||
|
onToggleClosed,
|
||||||
|
onDeleteOrder,
|
||||||
|
}: {
|
||||||
|
isClosed: boolean;
|
||||||
|
updatingOrderStatus: boolean;
|
||||||
|
deletingOrder: boolean;
|
||||||
|
onToggleClosed: (nextClosed: boolean) => void;
|
||||||
|
onDeleteOrder: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card title="Control center">
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={[{ key: "availability", isClosed }, { key: "delete" }]}
|
||||||
|
renderItem={(item: { key: string; isClosed?: boolean }) => (
|
||||||
|
<List.Item
|
||||||
|
style={{ paddingInline: 0 }}
|
||||||
|
actions={
|
||||||
|
item.key === "availability"
|
||||||
|
? [
|
||||||
|
<Button
|
||||||
|
key="toggle-order-status"
|
||||||
|
type="default"
|
||||||
|
danger={!item.isClosed}
|
||||||
|
icon={item.isClosed ? <CheckCircleOutlined /> : <StopOutlined />}
|
||||||
|
loading={updatingOrderStatus}
|
||||||
|
onClick={() => onToggleClosed(!item.isClosed)}
|
||||||
|
>
|
||||||
|
{item.isClosed ? "Reopen order" : "Close order"}
|
||||||
|
</Button>,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
<Popconfirm
|
||||||
|
key="delete-order-confirm"
|
||||||
|
title="Delete this order?"
|
||||||
|
description="This will permanently remove the order and its submissions."
|
||||||
|
okText="Delete"
|
||||||
|
cancelText="Cancel"
|
||||||
|
okButtonProps={{ danger: true, loading: deletingOrder }}
|
||||||
|
onConfirm={onDeleteOrder}
|
||||||
|
>
|
||||||
|
<Button type="primary" danger loading={deletingOrder}>
|
||||||
|
Delete order
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={
|
||||||
|
item.key === "availability" ? (
|
||||||
|
<Typography.Text strong>Order availability</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text strong>Order deletion</Typography.Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
item.key === "availability" ? (
|
||||||
|
<Typography.Text type={item.isClosed ? "warning" : "secondary"}>
|
||||||
|
{item.isClosed
|
||||||
|
? "Order is currently closed to new submissions."
|
||||||
|
: "Order is currently open for participant submissions."}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="danger">
|
||||||
|
Deleting an order is permanent and cannot be undone.
|
||||||
|
</Typography.Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Alert, Typography } from "antd";
|
||||||
|
import { navigateTo } from "../../lib/routing";
|
||||||
|
|
||||||
|
const { Text, Link } = Typography;
|
||||||
|
|
||||||
|
export default function AdminParticipantShareAlert({
|
||||||
|
orderId,
|
||||||
|
}: {
|
||||||
|
orderId: string;
|
||||||
|
}) {
|
||||||
|
const participantUrl = `${window.location.origin}/order/${orderId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message={
|
||||||
|
<Text>
|
||||||
|
<Text strong>Participant page:</Text>{" "}
|
||||||
|
Share{" "}
|
||||||
|
<Link
|
||||||
|
href={`/order/${orderId}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
navigateTo(`/order/${orderId}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
this page
|
||||||
|
</Link>{" "}
|
||||||
|
so people can submit their order:{" "}
|
||||||
|
<Text code copyable={{ text: participantUrl }}>
|
||||||
|
{participantUrl}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||||
|
import {
|
||||||
|
CopyOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Input,
|
||||||
|
Pagination,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "antd";
|
||||||
|
import { formatEstimatedTotal, stripPriceDecorations } from "../../lib/orderFormatting";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
function getEstimatedTotalText(rawValue: unknown): string | null {
|
||||||
|
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
|
||||||
|
return formatEstimatedTotal(rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawValue === "string") {
|
||||||
|
const parsed = Number(rawValue);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return formatEstimatedTotal(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminSubmissionsCard({
|
||||||
|
searchQuery,
|
||||||
|
selectedSubmissionState,
|
||||||
|
onSearchQueryChange,
|
||||||
|
onSelectedSubmissionStateChange,
|
||||||
|
onReload,
|
||||||
|
copyText,
|
||||||
|
onCopy,
|
||||||
|
selectedRowKeys,
|
||||||
|
onSelectedRowKeysChange,
|
||||||
|
pagedSubmissions,
|
||||||
|
deletingId,
|
||||||
|
savingStatusKey,
|
||||||
|
onDeleteSubmission,
|
||||||
|
onUpdateSubmissionStatus,
|
||||||
|
totalEstimatedText,
|
||||||
|
selectedCount,
|
||||||
|
submissionsPage,
|
||||||
|
pageSize,
|
||||||
|
totalCount,
|
||||||
|
onSubmissionsPageChange,
|
||||||
|
}: {
|
||||||
|
searchQuery: string;
|
||||||
|
selectedSubmissionState: "all" | "pending" | "unpaid" | "paid";
|
||||||
|
onSearchQueryChange: (value: string) => void;
|
||||||
|
onSelectedSubmissionStateChange: (value: "all" | "pending" | "unpaid" | "paid") => void;
|
||||||
|
onReload: () => void;
|
||||||
|
copyText: string;
|
||||||
|
onCopy: (text: string, didCopy: boolean) => void;
|
||||||
|
selectedRowKeys: Array<string | number>;
|
||||||
|
onSelectedRowKeysChange: (keys: Array<string | number>) => void;
|
||||||
|
pagedSubmissions: any[];
|
||||||
|
deletingId: string | number | null;
|
||||||
|
savingStatusKey: string | null;
|
||||||
|
onDeleteSubmission: (id: string | number) => void;
|
||||||
|
onUpdateSubmissionStatus: (submission: any, changes: { accepted?: boolean; paid?: boolean }) => void;
|
||||||
|
totalEstimatedText: string | null;
|
||||||
|
selectedCount: number;
|
||||||
|
submissionsPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
onSubmissionsPageChange: (page: number) => void;
|
||||||
|
}) {
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: "Submission",
|
||||||
|
dataIndex: "formatted_string",
|
||||||
|
key: "submission",
|
||||||
|
render: (value: string, record: any) => {
|
||||||
|
const cleanValue = stripPriceDecorations(String(value || "")).trim();
|
||||||
|
const estimateText = getEstimatedTotalText(record.estimated_total);
|
||||||
|
const emailText = String(record.email || "").trim() || "-";
|
||||||
|
const secondaryText = `${emailText} • ${estimateText || "-"}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text code copyable={{ text: cleanValue }}>{cleanValue || "-"}</Text>
|
||||||
|
<Tooltip title="Rough estimate based on menu prices. Final amount may differ.">
|
||||||
|
<Text type="secondary">{secondaryText}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Accepted",
|
||||||
|
key: "accepted",
|
||||||
|
width: 120,
|
||||||
|
render: (_: any, record: any) => {
|
||||||
|
const isSaving = savingStatusKey === `${record.id}:accepted`;
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={!!record.accepted}
|
||||||
|
loading={isSaving}
|
||||||
|
onChange={(checked) => onUpdateSubmissionStatus(record, { accepted: checked })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Paid",
|
||||||
|
key: "paid",
|
||||||
|
width: 100,
|
||||||
|
render: (_: any, record: any) => {
|
||||||
|
const isSaving = savingStatusKey === `${record.id}:paid`;
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={!!record.paid}
|
||||||
|
loading={isSaving}
|
||||||
|
onChange={(checked) => onUpdateSubmissionStatus(record, { paid: checked })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Actions",
|
||||||
|
key: "actions",
|
||||||
|
width: 1,
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Popconfirm
|
||||||
|
title="Delete this submission?"
|
||||||
|
description="This cannot be undone."
|
||||||
|
okText="Delete"
|
||||||
|
okButtonProps={{ danger: true, loading: deletingId === record.id }}
|
||||||
|
onConfirm={() => onDeleteSubmission(record.id)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
loading={deletingId === record.id}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title="Submissions"
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
selectedRowKeys.length > 0
|
||||||
|
? "Copy selected submissions as list to clipboard"
|
||||||
|
: "Copy submissions as list to clipboard"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CopyToClipboard text={copyText} onCopy={onCopy}>
|
||||||
|
<Button icon={<CopyOutlined />}>
|
||||||
|
{selectedRowKeys.length > 0 ? "Copy (selected)" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
</CopyToClipboard>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Reload latest order details">
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={onReload} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space style={{ marginBottom: 12 }} wrap>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="Filter by email or order text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => onSearchQueryChange(event.target.value)}
|
||||||
|
style={{ minWidth: 260 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={selectedSubmissionState}
|
||||||
|
style={{ minWidth: 180 }}
|
||||||
|
options={[
|
||||||
|
{ label: "All statuses", value: "all" },
|
||||||
|
{ label: "Pending", value: "pending" },
|
||||||
|
{ label: "Unpaid", value: "unpaid" },
|
||||||
|
{ label: "Paid", value: "paid" },
|
||||||
|
]}
|
||||||
|
onChange={(value) => onSelectedSubmissionStateChange(value)}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={pagedSubmissions}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (nextSelectedRowKeys) =>
|
||||||
|
onSelectedRowKeysChange(nextSelectedRowKeys as Array<string | number>),
|
||||||
|
}}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: true }}
|
||||||
|
/>
|
||||||
|
<Flex
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
gap={12}
|
||||||
|
wrap
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
>
|
||||||
|
{totalEstimatedText ? (
|
||||||
|
<Tooltip title="Rough estimate based on menu prices. Final amount may differ.">
|
||||||
|
<Text type="secondary">
|
||||||
|
Total {selectedCount > 0 ? "(selected)" : ""}: {totalEstimatedText}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<Pagination
|
||||||
|
current={submissionsPage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={totalCount}
|
||||||
|
showSizeChanger={false}
|
||||||
|
hideOnSinglePage
|
||||||
|
onChange={onSubmissionsPageChange}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
ExportOutlined,
|
||||||
|
ImportOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { Button, Popconfirm, Space } from "antd";
|
||||||
|
|
||||||
|
export default function CreateOrderHeaderActions({
|
||||||
|
importFileInputRef,
|
||||||
|
onImportJson,
|
||||||
|
onOpenExport,
|
||||||
|
}: {
|
||||||
|
importFileInputRef: React.RefObject<HTMLInputElement>;
|
||||||
|
onImportJson: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onOpenExport: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Space size="small">
|
||||||
|
<Popconfirm
|
||||||
|
title="Import order data?"
|
||||||
|
description="Import will overwrite all current content in this form."
|
||||||
|
okText="Import"
|
||||||
|
cancelText="Cancel"
|
||||||
|
onConfirm={() => importFileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Button type="link" icon={<ImportOutlined />}>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Button type="link" icon={<ExportOutlined />} onClick={onOpenExport}>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={importFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
onChange={onImportJson}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Pagination, Select, Space } from "antd";
|
||||||
|
|
||||||
|
const ownerTypeFilters = [
|
||||||
|
{ label: "All", value: "" },
|
||||||
|
{ label: "Owner", value: "owner" },
|
||||||
|
{ label: "Participant", value: "participant" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stateTypeFilters = [
|
||||||
|
{ label: "All", value: "" },
|
||||||
|
{ label: "Pending", value: "pending" },
|
||||||
|
{ label: "Unpaid", value: "unpaid" },
|
||||||
|
{ label: "Paid", value: "paid" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HomeOrdersFilters({
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
selectedOwnerType,
|
||||||
|
selectedState,
|
||||||
|
onOwnerTypeChange,
|
||||||
|
onStateChange,
|
||||||
|
onPageChange,
|
||||||
|
}: {
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
selectedOwnerType: string;
|
||||||
|
selectedState: string;
|
||||||
|
onOwnerTypeChange: (value: string) => void;
|
||||||
|
onStateChange: (value: string) => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space size={12} wrap>
|
||||||
|
<Select
|
||||||
|
value={selectedOwnerType}
|
||||||
|
onChange={onOwnerTypeChange}
|
||||||
|
options={ownerTypeFilters}
|
||||||
|
style={{ minWidth: 170 }}
|
||||||
|
placeholder="Owner type"
|
||||||
|
aria-label="Owner type"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={selectedState}
|
||||||
|
onChange={onStateChange}
|
||||||
|
options={stateTypeFilters}
|
||||||
|
style={{ minWidth: 160 }}
|
||||||
|
placeholder="State type"
|
||||||
|
aria-label="State type"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Pagination
|
||||||
|
current={currentPage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
showSizeChanger={false}
|
||||||
|
hideOnSinglePage
|
||||||
|
onChange={onPageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { EditOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Space, Table, Tag, Tooltip, Typography } from "antd";
|
||||||
|
import { navigateTo } from "../../lib/routing";
|
||||||
|
import {
|
||||||
|
formatEstimatedTotal,
|
||||||
|
stripPriceDecorations,
|
||||||
|
} from "../../lib/orderFormatting";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
function getOrderState(order: any): "pending" | "unpaid" | "paid" | "" {
|
||||||
|
if (!order?.is_participant) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.submission?.paid) {
|
||||||
|
return "paid";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.submission?.accepted) {
|
||||||
|
return "unpaid";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEstimatedTotalText(rawValue: unknown): string | null {
|
||||||
|
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
|
||||||
|
return formatEstimatedTotal(rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawValue === "string") {
|
||||||
|
const parsed = Number(rawValue);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return formatEstimatedTotal(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomeOrdersTable({
|
||||||
|
orders,
|
||||||
|
selectedState,
|
||||||
|
selectedOwnerType,
|
||||||
|
onPageFromTable,
|
||||||
|
}: {
|
||||||
|
orders: any[];
|
||||||
|
selectedState: string;
|
||||||
|
selectedOwnerType: string;
|
||||||
|
onPageFromTable: (page: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
dataSource={orders}
|
||||||
|
showHeader={false}
|
||||||
|
onChange={(tablePagination) =>
|
||||||
|
onPageFromTable(Number(tablePagination?.current || 1))
|
||||||
|
}
|
||||||
|
pagination={false}
|
||||||
|
locale={{
|
||||||
|
emptyText:
|
||||||
|
selectedState || selectedOwnerType
|
||||||
|
? "No orders match your filters."
|
||||||
|
: "Once you create an order or submit to one, it will appear here.",
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: "Order",
|
||||||
|
dataIndex: "title",
|
||||||
|
key: "title",
|
||||||
|
render: (_value: unknown, item: any) => {
|
||||||
|
const state = getOrderState(item);
|
||||||
|
const rawFormatted = String(
|
||||||
|
item.submission?.formatted_string || "",
|
||||||
|
);
|
||||||
|
const formatted = stripPriceDecorations(rawFormatted).trim();
|
||||||
|
const estimateText = getEstimatedTotalText(
|
||||||
|
item.submission?.estimated_total,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Space size={8} align="center">
|
||||||
|
<Text strong>{item.title || item.id}</Text>
|
||||||
|
{item.is_owner ? <Tag color="geekblue">Owner</Tag> : null}
|
||||||
|
{state === "paid" ? <Tag color="green">Paid</Tag> : null}
|
||||||
|
{state === "unpaid" ? (
|
||||||
|
<Tag color="volcano">Unpaid</Tag>
|
||||||
|
) : null}
|
||||||
|
{state === "pending" ? <Tag color="blue">Pending</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
<Text type="secondary">
|
||||||
|
{item.created_at
|
||||||
|
? new Date(item.created_at).toLocaleString()
|
||||||
|
: "Unknown creation time"}
|
||||||
|
{item.is_participant && formatted && ` • ${formatted}`}
|
||||||
|
{item.is_participant && estimateText && (
|
||||||
|
<>
|
||||||
|
{" • "}
|
||||||
|
<Tooltip title="Rough estimate based on menu prices. Final amount may differ.">
|
||||||
|
{estimateText}
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "",
|
||||||
|
key: "actions",
|
||||||
|
width: 110,
|
||||||
|
render: (_value: unknown, item: any) =>
|
||||||
|
item.is_owner ? (
|
||||||
|
<Button
|
||||||
|
key="admin-edit-btn"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
navigateTo(`/order/${item.id}/admin`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onRow={(item: any) => ({
|
||||||
|
onClick: () => navigateTo(`/order/${item.id}`),
|
||||||
|
style: { cursor: "pointer" },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Col, Form, Select } from "antd";
|
||||||
|
import { OrderFormCategory } from "../forms/OrderFormConfigBuilder";
|
||||||
|
|
||||||
|
function cleanOptional(value: unknown) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCategoryOptions(category: OrderFormCategory) {
|
||||||
|
return category.items.map((item) => {
|
||||||
|
const hasPrice = Number.isFinite(item.price);
|
||||||
|
return {
|
||||||
|
value: item.name,
|
||||||
|
label: hasPrice ? `${item.name} (€${item.price!.toFixed(2)})` : item.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryRules(category: OrderFormCategory) {
|
||||||
|
if (!category.required) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
validator: (_: unknown, value: unknown) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const cleaned = value.map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
|
return cleaned.length > 0
|
||||||
|
? Promise.resolve()
|
||||||
|
: Promise.reject(new Error(`${category.label} is required`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleValue = cleanOptional(value);
|
||||||
|
return singleValue
|
||||||
|
? Promise.resolve()
|
||||||
|
: Promise.reject(new Error(`${category.label} is required`));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ParticipantCategoryFields({
|
||||||
|
categories,
|
||||||
|
isClosed,
|
||||||
|
}: {
|
||||||
|
categories: OrderFormCategory[];
|
||||||
|
isClosed: boolean;
|
||||||
|
}) {
|
||||||
|
return categories.map((category) => {
|
||||||
|
const options = toCategoryOptions(category);
|
||||||
|
const selectPlaceholder = `Select ${category.label.toLowerCase()}`;
|
||||||
|
const rules = getCategoryRules(category);
|
||||||
|
const extra = category.custom
|
||||||
|
? `Your ${category.label.toLowerCase()} of choice. (Custom entries allowed)`
|
||||||
|
: `Your ${category.label.toLowerCase()} of choice.`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col xs={24} md={12} key={category.id}>
|
||||||
|
<Form.Item extra={extra} label={category.label} name={category.id} rules={rules}>
|
||||||
|
<Select
|
||||||
|
mode={category.multiple ? "multiple" : undefined}
|
||||||
|
allowClear={!category.required}
|
||||||
|
showSearch
|
||||||
|
placeholder={selectPlaceholder}
|
||||||
|
options={options}
|
||||||
|
disabled={isClosed}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Alert, Space, Tooltip, Typography } from "antd";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function ParticipantDraftSummaryAlert({
|
||||||
|
hasExistingSubmission,
|
||||||
|
hasActiveChanges,
|
||||||
|
savedOrderString,
|
||||||
|
savedEstimatedTotal,
|
||||||
|
draftOrderString,
|
||||||
|
draftEstimatedTotal,
|
||||||
|
}: {
|
||||||
|
hasExistingSubmission: boolean;
|
||||||
|
hasActiveChanges: boolean;
|
||||||
|
savedOrderString: string;
|
||||||
|
savedEstimatedTotal: string | null;
|
||||||
|
draftOrderString: string;
|
||||||
|
draftEstimatedTotal: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
type={hasExistingSubmission && hasActiveChanges ? "warning" : "info"}
|
||||||
|
message={
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
{hasExistingSubmission && (
|
||||||
|
<Text>
|
||||||
|
Current order: <Text code>{savedOrderString}</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{hasExistingSubmission && savedEstimatedTotal && (
|
||||||
|
<Tooltip title="Rough estimate based on configured item prices. Final amount may differ.">
|
||||||
|
<Text type="secondary">Current estimated total: {savedEstimatedTotal}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{(!hasExistingSubmission || hasActiveChanges) && (
|
||||||
|
<Text>
|
||||||
|
Order preview: <Text code>{draftOrderString}</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{(!hasExistingSubmission || hasActiveChanges) && draftEstimatedTotal && (
|
||||||
|
<Tooltip title="Rough estimate based on configured item prices. Final amount may differ.">
|
||||||
|
<Text type="secondary">Preview estimated total: {draftEstimatedTotal}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Checkbox, Result, Space, Tooltip, Typography } from "antd";
|
||||||
|
import type { Submission } from "../../lib/types";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
function formatTimestamp(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date(value);
|
||||||
|
if (Number.isNaN(timestamp.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timestamp.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ParticipantSubmissionSummary({
|
||||||
|
submission,
|
||||||
|
savedOrderString,
|
||||||
|
savedEstimatedTotal,
|
||||||
|
isClosed,
|
||||||
|
}: {
|
||||||
|
submission: Submission | null;
|
||||||
|
savedOrderString: string;
|
||||||
|
savedEstimatedTotal: string | null;
|
||||||
|
isClosed: boolean;
|
||||||
|
}) {
|
||||||
|
if (!submission) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="info"
|
||||||
|
title="No order submitted"
|
||||||
|
subTitle={
|
||||||
|
isClosed
|
||||||
|
? "This order is now closed, and no submission was recorded for you."
|
||||||
|
: "You have not submitted an order yet. Fill in the form below when you're ready."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="success"
|
||||||
|
title="Order submitted"
|
||||||
|
subTitle={
|
||||||
|
<Space direction="vertical">
|
||||||
|
<Tooltip title="Your submitted order">
|
||||||
|
<Text code copyable>
|
||||||
|
{savedOrderString}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
{savedEstimatedTotal && (
|
||||||
|
<Tooltip title="Rough estimate based on configured item prices. Final amount may differ.">
|
||||||
|
<Text type="secondary">Estimated total: {savedEstimatedTotal}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{!isClosed && <Text>Review or adjust at any time while the order is open</Text>}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={[
|
||||||
|
<Space key="submission-status" direction="vertical" size={4}>
|
||||||
|
<Text strong>Submission status</Text>
|
||||||
|
<Space size={20} wrap>
|
||||||
|
<Checkbox checked={submission.accepted} style={{ pointerEvents: "none" }}>
|
||||||
|
Accepted
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox checked={submission.paid} style={{ pointerEvents: "none" }}>
|
||||||
|
Paid
|
||||||
|
</Checkbox>
|
||||||
|
</Space>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
Updated {formatTimestamp(submission.updated_at)}
|
||||||
|
</Text>
|
||||||
|
</Space>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { UseApiRequestOptions } from "../lib/types";
|
||||||
|
|
||||||
|
export function useApiRequest<T = any>(requestFactory: () => Promise<T>, options: UseApiRequestOptions<T> = {}) {
|
||||||
|
const {
|
||||||
|
immediate = true,
|
||||||
|
deps = [],
|
||||||
|
initialData = null,
|
||||||
|
onError,
|
||||||
|
onSuccess,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const [data, setData] = useState<T | null>(initialData);
|
||||||
|
const [loading, setLoading] = useState(immediate);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
|
||||||
|
const run = useCallback(async () => {
|
||||||
|
const currentRequestId = ++requestIdRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await requestFactory();
|
||||||
|
if (currentRequestId !== requestIdRef.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(payload);
|
||||||
|
if (typeof onSuccess === "function") {
|
||||||
|
onSuccess(payload);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
} catch (requestError: unknown) {
|
||||||
|
if (currentRequestId !== requestIdRef.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = requestError instanceof Error ? requestError.message : "Request failed.";
|
||||||
|
setError(errorMessage);
|
||||||
|
if (typeof onError === "function") {
|
||||||
|
onError(requestError as Error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (currentRequestId === requestIdRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onError, onSuccess, requestFactory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!immediate) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
return () => {
|
||||||
|
requestIdRef.current += 1;
|
||||||
|
};
|
||||||
|
// deps are intentionally user-controlled so each consumer decides refresh triggers.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
setData,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
run,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ensureUserId } from "./userIdentity";
|
||||||
|
import type { ApiError } from "./types";
|
||||||
|
|
||||||
|
const API_BASE = "/api";
|
||||||
|
|
||||||
|
function createApiError(message: string, options: { status?: number; isNetworkError?: boolean } = {}): ApiError {
|
||||||
|
const error = new Error(message) as ApiError;
|
||||||
|
error.name = "ApiError";
|
||||||
|
error.status = options.status ?? null;
|
||||||
|
error.isNetworkError = !!options.isNetworkError;
|
||||||
|
error.isNotFound = error.status === 404;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNotFoundError(error: unknown) {
|
||||||
|
const maybeError = error as Partial<ApiError> | null;
|
||||||
|
return !!(maybeError && (maybeError.isNotFound || maybeError.status === 404));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch<T = any>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const fullUrl = url.startsWith("/api") ? API_BASE + url.slice(4) : url;
|
||||||
|
const userId = ensureUserId();
|
||||||
|
const authHeader = userId ? { "X-User-Id": userId } : {};
|
||||||
|
|
||||||
|
// Determine Content-Type: don't set it for FormData (let browser set it), use application/json for JSON
|
||||||
|
const isFormData = options.body instanceof FormData;
|
||||||
|
const contentTypeHeader = isFormData ? {} : { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(fullUrl, {
|
||||||
|
headers: {
|
||||||
|
...contentTypeHeader,
|
||||||
|
...authHeader,
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
throw createApiError("Network request failed. Please try again.", {
|
||||||
|
isNetworkError: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({} as { detail?: string }));
|
||||||
|
throw createApiError(payload.detail || `Request failed (${response.status})`, {
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response.status === 204 ? null : response.json()) as Promise<T>;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { apiService } from "./services";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const USER_TOKEN_KEY = "lunchtime_token";
|
||||||
|
export const THEME_MODE_KEY = "lunchtime_theme_mode";
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { marked } from "marked";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
|
export function markdownToHtml(markdownText: string | null | undefined) {
|
||||||
|
if (!markdownText) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const html = marked.parse(markdownText) as string;
|
||||||
|
return DOMPurify.sanitize(html);
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import type { OrderFormCategory, OrderFormConfig, OrderFormItem } from "./types";
|
||||||
|
|
||||||
|
export const ORDER_FORMAT_VALUE_PLACEHOLDER = "{name}";
|
||||||
|
export const ORDER_FORMAT_LABEL_PLACEHOLDER = "{label}";
|
||||||
|
export const DEFAULT_CATEGORY_SNIPPET = "{label}: {name}";
|
||||||
|
export const DEFAULT_MULTIPLE_SEPARATOR = ", ";
|
||||||
|
export const DEFAULT_EMPTY_ORDER_TEXT = "No items selected";
|
||||||
|
|
||||||
|
export type NormalizedOrderSelection = string | null | string[];
|
||||||
|
export type NormalizedOrderSelections = Record<string, NormalizedOrderSelection>;
|
||||||
|
|
||||||
|
type RawOrderFormValues = Record<string, unknown>;
|
||||||
|
|
||||||
|
function cleanOptional(value: unknown) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveItemLabel(name: string, item?: OrderFormItem) {
|
||||||
|
if (!item) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveItemPrice(item?: OrderFormItem) {
|
||||||
|
if (!item || !Number.isFinite(item.price)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(item.price);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMultipleSeparator(category: OrderFormCategory) {
|
||||||
|
if (typeof category.multipleSeparator === "string") {
|
||||||
|
return category.multipleSeparator;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_MULTIPLE_SEPARATOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategorySnippet(category: OrderFormCategory) {
|
||||||
|
const rawSnippet = String(category.formattedSnippet || "").trim();
|
||||||
|
if (!rawSnippet || !rawSnippet.includes(ORDER_FORMAT_VALUE_PLACEHOLDER)) {
|
||||||
|
return DEFAULT_CATEGORY_SNIPPET;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawSnippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCategorySnippet(category: OrderFormCategory, value: string) {
|
||||||
|
const snippet = getCategorySnippet(category);
|
||||||
|
return snippet
|
||||||
|
.split(ORDER_FORMAT_VALUE_PLACEHOLDER)
|
||||||
|
.join(value)
|
||||||
|
.split(ORDER_FORMAT_LABEL_PLACEHOLDER)
|
||||||
|
.join(category.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOrderSelections(
|
||||||
|
values: RawOrderFormValues,
|
||||||
|
config: OrderFormConfig,
|
||||||
|
): NormalizedOrderSelections {
|
||||||
|
const normalized: NormalizedOrderSelections = {};
|
||||||
|
|
||||||
|
for (const category of config.categories) {
|
||||||
|
const categoryValue = values[category.id];
|
||||||
|
|
||||||
|
if (category.multiple) {
|
||||||
|
normalized[category.id] = (Array.isArray(categoryValue) ? categoryValue : [])
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSingleValue = Array.isArray(categoryValue)
|
||||||
|
? categoryValue[0]
|
||||||
|
: categoryValue;
|
||||||
|
normalized[category.id] = cleanOptional(rawSingleValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFormattedOrderString(
|
||||||
|
values: NormalizedOrderSelections,
|
||||||
|
config: OrderFormConfig,
|
||||||
|
options?: {
|
||||||
|
categoryJoiner?: string;
|
||||||
|
emptyText?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
for (const category of config.categories) {
|
||||||
|
const categoryValue = values[category.id];
|
||||||
|
let categoryPart = "";
|
||||||
|
|
||||||
|
if (category.multiple) {
|
||||||
|
const selectedItems = Array.isArray(categoryValue) ? categoryValue : [];
|
||||||
|
if (selectedItems.length > 0) {
|
||||||
|
const labels = selectedItems.map((itemName) => {
|
||||||
|
const item = category.items.find((entry) => entry.name === itemName);
|
||||||
|
return resolveItemLabel(itemName, item);
|
||||||
|
});
|
||||||
|
categoryPart = labels.join(getMultipleSeparator(category));
|
||||||
|
}
|
||||||
|
} else if (typeof categoryValue === "string" && categoryValue.trim()) {
|
||||||
|
const item = category.items.find((entry) => entry.name === categoryValue);
|
||||||
|
categoryPart = resolveItemLabel(categoryValue, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryPart) {
|
||||||
|
parts.push(applyCategorySnippet(category, categoryPart));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!category.required) {
|
||||||
|
const fallback = String(category.optionalFallback || "").trim();
|
||||||
|
if (fallback) {
|
||||||
|
parts.push(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryJoiner = options?.categoryJoiner || " ";
|
||||||
|
const emptyText = options?.emptyText || DEFAULT_EMPTY_ORDER_TEXT;
|
||||||
|
return parts.length > 0 ? parts.join(categoryJoiner) : emptyText;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExampleFormattedOrderString(config: OrderFormConfig): string {
|
||||||
|
const sampleSelections: NormalizedOrderSelections = {};
|
||||||
|
|
||||||
|
for (const category of config.categories) {
|
||||||
|
if (category.multiple) {
|
||||||
|
sampleSelections[category.id] = category.items.slice(0, 3).map((item) => item.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstItem = category.items[0];
|
||||||
|
sampleSelections[category.id] = firstItem ? firstItem.name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildFormattedOrderString(sampleSelections, config, {
|
||||||
|
categoryJoiner: " ",
|
||||||
|
emptyText: "(Add items to see example)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateEstimatedOrderTotal(
|
||||||
|
values: NormalizedOrderSelections,
|
||||||
|
config: OrderFormConfig,
|
||||||
|
): number | null {
|
||||||
|
let total = 0;
|
||||||
|
let hasPricedItems = false;
|
||||||
|
|
||||||
|
for (const category of config.categories) {
|
||||||
|
const categoryValue = values[category.id];
|
||||||
|
|
||||||
|
if (category.multiple) {
|
||||||
|
const selectedItems = Array.isArray(categoryValue) ? categoryValue : [];
|
||||||
|
for (const itemName of selectedItems) {
|
||||||
|
const item = category.items.find((entry) => entry.name === itemName);
|
||||||
|
const price = resolveItemPrice(item);
|
||||||
|
if (price === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
total += price;
|
||||||
|
hasPricedItems = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof categoryValue !== "string" || !categoryValue.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = category.items.find((entry) => entry.name === categoryValue);
|
||||||
|
const price = resolveItemPrice(item);
|
||||||
|
if (price === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
total += price;
|
||||||
|
hasPricedItems = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPricedItems) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEstimatedTotal(total: number | null): string | null {
|
||||||
|
if (total === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `~ €${total.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripPriceDecorations(value: string): string {
|
||||||
|
return value.replace(/\s*\(€\s*\d+(?:[.,]\d{1,2})?\)/g, "");
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export type AppRoute =
|
||||||
|
| { type: "home" }
|
||||||
|
| { type: "create" }
|
||||||
|
| { type: "order"; orderId: string }
|
||||||
|
| { type: "admin"; orderId: string };
|
||||||
|
|
||||||
|
export function parseRoute(pathname: string): AppRoute {
|
||||||
|
if (pathname === "/create") {
|
||||||
|
return { type: "create" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminMatch = pathname.match(/^\/order\/([^/]+)\/admin$/);
|
||||||
|
if (adminMatch) {
|
||||||
|
return { type: "admin", orderId: adminMatch[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderMatch = pathname.match(/^\/order\/([^/]+)$/);
|
||||||
|
if (orderMatch) {
|
||||||
|
return { type: "order", orderId: orderMatch[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "home" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTE_CHANGE_EVENT = "app:routechange";
|
||||||
|
|
||||||
|
function notifyRouteChange() {
|
||||||
|
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigateTo(pathname: string) {
|
||||||
|
if (window.location.pathname === pathname) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.history.pushState({}, "", pathname);
|
||||||
|
notifyRouteChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceRoute(pathname: string) {
|
||||||
|
if (window.location.pathname === pathname) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.history.replaceState({}, "", pathname);
|
||||||
|
notifyRouteChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeToRouteChange(onRouteChange: () => void) {
|
||||||
|
const handleRouteChange = () => {
|
||||||
|
onRouteChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("popstate", handleRouteChange);
|
||||||
|
window.addEventListener(ROUTE_CHANGE_EVENT, handleRouteChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("popstate", handleRouteChange);
|
||||||
|
window.removeEventListener(ROUTE_CHANGE_EVENT, handleRouteChange);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { apiFetch } from "../api";
|
||||||
|
import type { RegisterAccountResult, ConfirmAccountResult, AccountLookupResult } from "../types";
|
||||||
|
|
||||||
|
export const accountService = {
|
||||||
|
lookupByEmail: (email: string) =>
|
||||||
|
apiFetch<AccountLookupResult>(`/api/account/lookup?email=${encodeURIComponent(email)}`),
|
||||||
|
register: (email: string) =>
|
||||||
|
apiFetch<RegisterAccountResult>("/api/account/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
}),
|
||||||
|
confirm: (token: string) =>
|
||||||
|
apiFetch<ConfirmAccountResult>(`/api/account/confirm?token=${encodeURIComponent(token)}`),
|
||||||
|
requestUserIdChange: (requestedUserId?: string) =>
|
||||||
|
apiFetch<RegisterAccountResult>("/api/me/user-id/change/request", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ requested_user_id: requestedUserId || null }),
|
||||||
|
}),
|
||||||
|
requestEmailChange: (newEmail: string) =>
|
||||||
|
apiFetch<RegisterAccountResult>("/api/me/email/change/request", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ new_email: newEmail }),
|
||||||
|
}),
|
||||||
|
requestMigration: (email: string) =>
|
||||||
|
apiFetch<RegisterAccountResult>("/api/account/migrate/request", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { apiFetch } from "../api";
|
||||||
|
import type { AppConfig } from "../types";
|
||||||
|
|
||||||
|
export const configService = {
|
||||||
|
get: () => apiFetch<AppConfig>("/api/config"),
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { accountService } from "./accountService";
|
||||||
|
import { configService } from "./configService";
|
||||||
|
import { ordersService } from "./ordersService";
|
||||||
|
import { profileService } from "./profileService";
|
||||||
|
import { submissionsService } from "./submissionsService";
|
||||||
|
|
||||||
|
export const apiService = {
|
||||||
|
account: accountService,
|
||||||
|
config: configService,
|
||||||
|
orders: ordersService,
|
||||||
|
profile: profileService,
|
||||||
|
submissions: submissionsService,
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { apiFetch } from "../api";
|
||||||
|
import type {
|
||||||
|
Order,
|
||||||
|
OrderFormConfig,
|
||||||
|
GetAdminViewResponse,
|
||||||
|
OrderAccessInfo,
|
||||||
|
GetOrdersResponse,
|
||||||
|
UploadImageResponse,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
type CreateOrderPayload = {
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
config: OrderFormConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateOrderDetailsPayload = {
|
||||||
|
description?: string | null;
|
||||||
|
image_url?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ordersService = {
|
||||||
|
create: (payload: CreateOrderPayload) =>
|
||||||
|
apiFetch<Order>("/api/orders", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
getMine: (params?: { skip?: number; limit?: number; state?: string; role?: string }) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.skip !== undefined) query.append("skip", params.skip.toString());
|
||||||
|
if (params?.limit !== undefined) query.append("limit", params.limit.toString());
|
||||||
|
if (params?.state) query.append("state", params.state);
|
||||||
|
if (params?.role) query.append("role", params.role);
|
||||||
|
|
||||||
|
const url = `/api/orders/me${query.toString() ? "?" + query.toString() : ""}`;
|
||||||
|
return apiFetch<GetOrdersResponse>(url);
|
||||||
|
},
|
||||||
|
getMyAccess: (orderId: string) => apiFetch<OrderAccessInfo>(`/api/orders/${orderId}/me`),
|
||||||
|
get: (orderId: string) => apiFetch<Order>(`/api/orders/${orderId}`),
|
||||||
|
getConfig: (orderId: string) => apiFetch<OrderFormConfig>(`/api/orders/${orderId}/config`),
|
||||||
|
getAdminView: (orderId: string) => apiFetch<GetAdminViewResponse>(`/api/orders/${orderId}/admin`),
|
||||||
|
delete: (orderId: string) =>
|
||||||
|
apiFetch<{ status: string }>(`/api/orders/${orderId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
updateClosedStatus: (orderId: string, closed: boolean) =>
|
||||||
|
apiFetch<Order>(`/api/orders/${orderId}/admin/status`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ closed }),
|
||||||
|
}),
|
||||||
|
updateDetails: (orderId: string, payload: UpdateOrderDetailsPayload) =>
|
||||||
|
apiFetch<Order>(`/api/orders/${orderId}/admin/description`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
updateConfig: (orderId: string, config: OrderFormConfig) =>
|
||||||
|
apiFetch<Order>(`/api/orders/${orderId}/admin/config`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
}),
|
||||||
|
uploadImage: (orderId: string, file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
return apiFetch<UploadImageResponse>(`/api/orders/${orderId}/admin/image`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteImage: (orderId: string) =>
|
||||||
|
apiFetch<{ status: string }>(`/api/orders/${orderId}/admin/image`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { apiFetch } from "../api";
|
||||||
|
import type { UserProfile } from "../types";
|
||||||
|
|
||||||
|
export const profileService = {
|
||||||
|
getMine: () => apiFetch<UserProfile>("/api/me/profile"),
|
||||||
|
updateMine: (email: string) =>
|
||||||
|
apiFetch<UserProfile>("/api/me/profile", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { apiFetch } from "../api";
|
||||||
|
import type { Submission, SubmissionInput, SubmissionStatusUpdate } from "../types";
|
||||||
|
|
||||||
|
export const submissionsService = {
|
||||||
|
create: (orderId: string, payload: SubmissionInput) =>
|
||||||
|
apiFetch<Submission>(`/api/orders/${orderId}/submissions/me`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
createMine: (orderId: string, payload: SubmissionInput) =>
|
||||||
|
apiFetch<Submission>(`/api/orders/${orderId}/submissions/me`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
getMine: (orderId: string) => apiFetch<Submission | null>(`/api/orders/${orderId}/submissions/me`),
|
||||||
|
update: (orderId: string, payload: SubmissionInput) =>
|
||||||
|
apiFetch<Submission>(`/api/orders/${orderId}/submissions/me`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
updateMine: (orderId: string, payload: SubmissionInput) =>
|
||||||
|
apiFetch<Submission>(`/api/orders/${orderId}/submissions/me`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
delete: (orderId: string) =>
|
||||||
|
apiFetch<{ status: string }>(`/api/orders/${orderId}/submissions/me`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
removeMine: (orderId: string) =>
|
||||||
|
apiFetch<{ status: string }>(`/api/orders/${orderId}/submissions/me`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
removeAsAdmin: (orderId: string, submissionId: string | number) =>
|
||||||
|
apiFetch<{ status: string }>(`/api/orders/${orderId}/admin/submissions/${submissionId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
updateStatusAsAdmin: (orderId: string, submissionId: string | number, payload: SubmissionStatusUpdate) =>
|
||||||
|
apiFetch<Submission>(`/api/orders/${orderId}/admin/submissions/${submissionId}/status`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const inMemoryFallback = new Map<string, string>();
|
||||||
|
|
||||||
|
function canUseLocalStorage() {
|
||||||
|
return typeof window !== "undefined" && !!window.localStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredValue(key?: string | null) {
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canUseLocalStorage()) {
|
||||||
|
try {
|
||||||
|
const value = window.localStorage.getItem(key);
|
||||||
|
if (value !== null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// Fall back to in-memory value when storage access is blocked.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inMemoryFallback.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredValue(key: string | null | undefined, value: unknown) {
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = String(value);
|
||||||
|
|
||||||
|
if (canUseLocalStorage()) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, normalized);
|
||||||
|
inMemoryFallback.delete(key);
|
||||||
|
return;
|
||||||
|
} catch (_error) {
|
||||||
|
// Keep app functional even when persistent storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inMemoryFallback.set(key, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeStoredValue(key?: string | null) {
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canUseLocalStorage()) {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore remove failures and still clear fallback state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inMemoryFallback.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredJSON<T = unknown>(key: string) {
|
||||||
|
const raw = getStoredValue(key);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch (_error) {
|
||||||
|
removeStoredValue(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredJSON(key: string, value: unknown) {
|
||||||
|
setStoredValue(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* Core Domain Models and API Types
|
||||||
|
* This file contains all TypeScript types and interfaces for the entire application
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== Account & Authentication ==================== */
|
||||||
|
|
||||||
|
export type RegisterAccountResult = {
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfirmAccountResult = {
|
||||||
|
status: string;
|
||||||
|
action: string;
|
||||||
|
user_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountLookupResult = {
|
||||||
|
exists: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserProfile = {
|
||||||
|
email: string | null;
|
||||||
|
email_confirmed?: boolean;
|
||||||
|
pending_email?: string | null;
|
||||||
|
pending_email_old_confirmed?: boolean;
|
||||||
|
pending_email_new_confirmed?: boolean;
|
||||||
|
pending_user_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppConfig = {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ==================== Order Models ==================== */
|
||||||
|
|
||||||
|
export type OrderFormItem = {
|
||||||
|
name: string;
|
||||||
|
price?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrderFormCategory = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
required: boolean;
|
||||||
|
multiple: boolean;
|
||||||
|
custom?: boolean;
|
||||||
|
formattedSnippet?: string;
|
||||||
|
multipleSeparator?: string;
|
||||||
|
optionalFallback?: string;
|
||||||
|
items: OrderFormItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrderFormConfig = {
|
||||||
|
categories: OrderFormCategory[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrderBase = {
|
||||||
|
id: string;
|
||||||
|
admin_token: string;
|
||||||
|
admin_user_id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
image_url?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
closed: boolean;
|
||||||
|
config: OrderFormConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Order = OrderBase & {
|
||||||
|
access?: {
|
||||||
|
is_owner: boolean;
|
||||||
|
is_participant: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrderAdminView = OrderBase & {
|
||||||
|
submissions: Submission[];
|
||||||
|
formatted_total?: string;
|
||||||
|
estimated_total?: number;
|
||||||
|
total_submitted: number;
|
||||||
|
stats?: {
|
||||||
|
pending: number;
|
||||||
|
unpaid: number;
|
||||||
|
paid: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrderAccessInfo = {
|
||||||
|
is_owner: boolean;
|
||||||
|
is_participant: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ==================== Submission Models ==================== */
|
||||||
|
|
||||||
|
export type SubmissionStatus = "pending" | "unpaid" | "paid";
|
||||||
|
|
||||||
|
export type OrderChoices = Record<string, string | string[] | null | undefined>;
|
||||||
|
|
||||||
|
export type Submission = {
|
||||||
|
id: string | number;
|
||||||
|
order_id?: string;
|
||||||
|
group_order_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
email?: string;
|
||||||
|
choices?: OrderChoices;
|
||||||
|
choices_json?: string | OrderChoices;
|
||||||
|
formatted_string?: string;
|
||||||
|
estimated_total?: number | string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
accepted: boolean;
|
||||||
|
paid: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubmissionInput = {
|
||||||
|
choices: OrderChoices;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubmissionStatusUpdate = {
|
||||||
|
accepted?: boolean;
|
||||||
|
paid?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ==================== API Response Models ==================== */
|
||||||
|
|
||||||
|
export type PaginationInfo = {
|
||||||
|
total: number;
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetOrdersResponse = {
|
||||||
|
orders: Order[];
|
||||||
|
pagination: PaginationInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetAdminViewResponse = {
|
||||||
|
order: Order;
|
||||||
|
submissions: Submission[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadImageResponse = {
|
||||||
|
status: string;
|
||||||
|
image_url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ==================== Normalized/Computed Models ==================== */
|
||||||
|
|
||||||
|
export type NormalizedOrderSelections = Record<string, string | string[] | null | undefined>;
|
||||||
|
|
||||||
|
export type FormattedOrderString = string;
|
||||||
|
|
||||||
|
/* ==================== Component Prop Types ==================== */
|
||||||
|
|
||||||
|
export type AsyncContentProps = {
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | string | null;
|
||||||
|
onRetry?: () => void | Promise<void>;
|
||||||
|
loadingRows?: number;
|
||||||
|
loadingSections?: number;
|
||||||
|
errorTitle?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoadingSkeletonProps = {
|
||||||
|
rows?: number;
|
||||||
|
sections?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ErrorResultProps = {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
size?: "default" | "large";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThemeModeToggleProps = {
|
||||||
|
value?: "light" | "dark" | string;
|
||||||
|
onChange?: (mode: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnnouncementsProps = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ==================== Hook Types ==================== */
|
||||||
|
|
||||||
|
export type UseApiRequestOptions<T> = {
|
||||||
|
immediate?: boolean;
|
||||||
|
deps?: unknown[];
|
||||||
|
initialData?: T | null;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
onSuccess?: (data: T) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseApiRequestResult<T> = {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
setData: (data: T | ((prev: T | null) => T | null)) => void;
|
||||||
|
run: () => Promise<T | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ==================== API Error Model ==================== */
|
||||||
|
|
||||||
|
export type ApiError = Error & {
|
||||||
|
status: number | null;
|
||||||
|
isNetworkError: boolean;
|
||||||
|
isNotFound: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ==================== Storage Models ==================== */
|
||||||
|
|
||||||
|
export type StorageUserIdentity = {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ==================== UI State Models ==================== */
|
||||||
|
|
||||||
|
export type SubmissionState = SubmissionStatus | "all";
|
||||||
|
|
||||||
|
export type OrderStateFilter = "all" | "open" | "closed";
|
||||||
|
|
||||||
|
export type OrderOwnerRoleFilter = "all" | "admin" | "participant";
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { USER_TOKEN_KEY } from "./constants";
|
||||||
|
import { getStoredValue, setStoredValue } from "./storage";
|
||||||
|
|
||||||
|
const emailByUserId = new Map<string, string>();
|
||||||
|
|
||||||
|
function generateGuid() {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback GUID-like value for older environments without randomUUID.
|
||||||
|
const randomHex = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).slice(1);
|
||||||
|
return `${randomHex()}${randomHex()}-${randomHex()}-${randomHex()}-${randomHex()}-${randomHex()}${randomHex()}${randomHex()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUserId(rawUserId: unknown) {
|
||||||
|
return String(rawUserId || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUserEmail(rawUserEmail: unknown) {
|
||||||
|
return String(rawUserEmail || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureUserId() {
|
||||||
|
return normalizeUserId(getStoredValue(USER_TOKEN_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasStoredUserId() {
|
||||||
|
return !!ensureUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUserId() {
|
||||||
|
const generated = generateGuid();
|
||||||
|
setStoredValue(USER_TOKEN_KEY, generated);
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserId(nextUserId: string) {
|
||||||
|
const normalized = normalizeUserId(nextUserId);
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("User ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
setStoredValue(USER_TOKEN_KEY, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserEmailForUserId(userId: string) {
|
||||||
|
const normalizedUserId = normalizeUserId(userId);
|
||||||
|
if (!normalizedUserId) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return emailByUserId.get(normalizedUserId) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserEmail(nextUserEmail: string, userId: string) {
|
||||||
|
const normalizedUserId = normalizeUserId(userId);
|
||||||
|
if (!normalizedUserId) {
|
||||||
|
throw new Error("User ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeUserEmail(nextUserEmail);
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("Email is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
emailByUserId.set(normalizedUserId, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regenerateUserId() {
|
||||||
|
return createUserId();
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "antd/dist/reset.css";
|
||||||
|
import "./styles.css";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Could not find root element");
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
:root {
|
||||||
|
--scrollbar-track: #efe3d4;
|
||||||
|
--scrollbar-thumb: #c8874e;
|
||||||
|
--scrollbar-thumb-hover: #a66631;
|
||||||
|
--scrollbar-thumb-active: #8c4f22;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme-mode="dark"] {
|
||||||
|
--scrollbar-track: #2f2016;
|
||||||
|
--scrollbar-thumb: #b7743f;
|
||||||
|
--scrollbar-thumb-hover: #d08a4f;
|
||||||
|
--scrollbar-thumb-active: #e8a261;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: var(--scrollbar-track);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, var(--scrollbar-thumb), var(--scrollbar-thumb-active));
|
||||||
|
border: 2px solid var(--scrollbar-track);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: linear-gradient(180deg, var(--scrollbar-thumb-hover), var(--scrollbar-thumb));
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:active {
|
||||||
|
background: var(--scrollbar-thumb-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-corner {
|
||||||
|
background: var(--scrollbar-track);
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Space,
|
||||||
|
message,
|
||||||
|
} from "antd";
|
||||||
|
import AsyncContent from "../components/utils/AsyncContent";
|
||||||
|
import ReadOnlyOrderOverviewCard from "../components/orders/ReadOnlyOrderOverviewCard";
|
||||||
|
import { useApiRequest } from "../hooks/useApiRequest";
|
||||||
|
import { apiService } from "../lib/services";
|
||||||
|
import { navigateTo } from "../lib/routing";
|
||||||
|
import type {
|
||||||
|
GetAdminViewResponse,
|
||||||
|
Order,
|
||||||
|
OrderFormConfig,
|
||||||
|
Submission,
|
||||||
|
SubmissionStatus,
|
||||||
|
} from "../lib/types";
|
||||||
|
import { formatEstimatedTotal, stripPriceDecorations } from "../lib/orderFormatting";
|
||||||
|
import AdminParticipantShareAlert from "../components/views/AdminParticipantShareAlert";
|
||||||
|
import AdminSubmissionsCard from "../components/views/AdminSubmissionsCard";
|
||||||
|
import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
|
||||||
|
|
||||||
|
type AdminViewData = Order & {
|
||||||
|
submissions: Submission[];
|
||||||
|
config: OrderFormConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getEstimatedTotalNumber(rawValue: unknown): number | null {
|
||||||
|
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
|
||||||
|
return rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawValue === "string") {
|
||||||
|
const parsed = Number(rawValue);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminView({ orderId }: { orderId: string }) {
|
||||||
|
const [deletingId, setDeletingId] = useState<string | number | null>(null);
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
|
||||||
|
const [savingStatusKey, setSavingStatusKey] = useState<string | null>(null);
|
||||||
|
const [updatingOrderStatus, setUpdatingOrderStatus] = useState(false);
|
||||||
|
const [deletingOrder, setDeletingOrder] = useState(false);
|
||||||
|
const [submissionsPage, setSubmissionsPage] = useState(1);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selectedSubmissionState, setSelectedSubmissionState] = useState<"all" | "pending" | "unpaid" | "paid">("all");
|
||||||
|
const SUBMISSIONS_PAGE_SIZE = 8;
|
||||||
|
|
||||||
|
const getSubmissionState = (submission: Submission): SubmissionStatus => {
|
||||||
|
if (submission?.paid) {
|
||||||
|
return "paid";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submission?.accepted) {
|
||||||
|
return "unpaid";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "pending";
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAdminData = useCallback(
|
||||||
|
async (): Promise<AdminViewData> => {
|
||||||
|
const [adminPayload, configPayload] = await Promise.all([
|
||||||
|
apiService.orders.getAdminView(orderId),
|
||||||
|
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const typedPayload = adminPayload as GetAdminViewResponse;
|
||||||
|
const orderPayload = typedPayload?.order || ({} as Order);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...orderPayload,
|
||||||
|
submissions: Array.isArray(typedPayload?.submissions)
|
||||||
|
? typedPayload.submissions
|
||||||
|
: [],
|
||||||
|
config: {
|
||||||
|
categories: Array.isArray((configPayload as OrderFormConfig)?.categories)
|
||||||
|
? (configPayload as OrderFormConfig).categories
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[orderId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
setData,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
run: loadAdmin,
|
||||||
|
} = useApiRequest(loadAdminData, {
|
||||||
|
deps: [loadAdminData],
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
},
|
||||||
|
onError: (requestError) => {
|
||||||
|
message.error(requestError?.message || "Admin view could not be loaded.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteAsAdmin = async (submissionId: string | number) => {
|
||||||
|
setDeletingId(submissionId);
|
||||||
|
try {
|
||||||
|
await apiService.submissions.removeAsAdmin(orderId, submissionId);
|
||||||
|
message.success("Submission deleted");
|
||||||
|
await loadAdmin();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSubmissionStatus = async (submission: any, changes: any) => {
|
||||||
|
const nextAccepted =
|
||||||
|
typeof changes.accepted === "boolean"
|
||||||
|
? changes.accepted
|
||||||
|
: !!submission.accepted;
|
||||||
|
const nextPaid =
|
||||||
|
typeof changes.paid === "boolean" ? changes.paid : !!submission.paid;
|
||||||
|
|
||||||
|
const statusKey = `${submission.id}:${Object.keys(changes).join(",")}`;
|
||||||
|
setSavingStatusKey(statusKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated: any = await apiService.submissions.updateStatusAsAdmin(
|
||||||
|
orderId,
|
||||||
|
submission.id,
|
||||||
|
{
|
||||||
|
accepted: nextAccepted,
|
||||||
|
paid: nextPaid,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
setData((previous: any) => {
|
||||||
|
if (!previous) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
submissions: previous.submissions.map((item: any) =>
|
||||||
|
item.id === updated.id ? updated : item,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setSavingStatusKey(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOrderClosedStatus = async (closed: boolean) => {
|
||||||
|
setUpdatingOrderStatus(true);
|
||||||
|
try {
|
||||||
|
const updatedOrder = await apiService.orders.updateClosedStatus(orderId, closed);
|
||||||
|
|
||||||
|
setData((previous: AdminViewData | null) => {
|
||||||
|
if (!previous) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
...updatedOrder,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success(closed ? "Order closed" : "Order reopened");
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setUpdatingOrderStatus(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteOrder = async () => {
|
||||||
|
setDeletingOrder(true);
|
||||||
|
try {
|
||||||
|
await apiService.orders.delete(orderId);
|
||||||
|
message.success("Order deleted");
|
||||||
|
navigateTo("/");
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setDeletingOrder(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageError = error || (!data ? "Admin view could not be loaded." : null);
|
||||||
|
|
||||||
|
const handleImportConfig = async (nextConfig: OrderFormConfig) => {
|
||||||
|
await apiService.orders.updateConfig(orderId, nextConfig);
|
||||||
|
setData((previous: any) => {
|
||||||
|
if (!previous) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
config: {
|
||||||
|
categories: Array.isArray(nextConfig.categories)
|
||||||
|
? nextConfig.categories
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
message.success("Menu configuration updated");
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPage = Math.max(1, Math.ceil(data.submissions.length / SUBMISSIONS_PAGE_SIZE));
|
||||||
|
if (submissionsPage > maxPage) {
|
||||||
|
setSubmissionsPage(maxPage);
|
||||||
|
}
|
||||||
|
}, [data, submissionsPage]);
|
||||||
|
|
||||||
|
const filteredSubmissions = useMemo(() => {
|
||||||
|
if (!data?.submissions) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||||
|
|
||||||
|
return data.submissions.filter((submission: any) => {
|
||||||
|
if (selectedSubmissionState !== "all") {
|
||||||
|
if (getSubmissionState(submission) !== selectedSubmissionState) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanOrderText = stripPriceDecorations(
|
||||||
|
String(submission.formatted_string || ""),
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
const haystack = [
|
||||||
|
String(submission.email || "").toLowerCase(),
|
||||||
|
cleanOrderText,
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
return haystack.includes(normalizedQuery);
|
||||||
|
});
|
||||||
|
}, [data?.submissions, searchQuery, selectedSubmissionState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const visibleIds = new Set(filteredSubmissions.map((submission: any) => submission.id));
|
||||||
|
setSelectedRowKeys((previous) => previous.filter((id) => visibleIds.has(id)));
|
||||||
|
setSubmissionsPage(1);
|
||||||
|
}, [searchQuery, selectedSubmissionState, filteredSubmissions]);
|
||||||
|
|
||||||
|
if (pageError || loading) {
|
||||||
|
return (
|
||||||
|
<AsyncContent
|
||||||
|
loading={loading}
|
||||||
|
error={pageError}
|
||||||
|
onRetry={loadAdmin}
|
||||||
|
errorTitle="Admin page unavailable"
|
||||||
|
loadingRows={4}
|
||||||
|
loadingSections={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = filteredSubmissions.filter((submission: any) =>
|
||||||
|
selectedRowKeys.includes(submission.id),
|
||||||
|
);
|
||||||
|
const rowsToCopy = selected.length > 0 ? selected : filteredSubmissions;
|
||||||
|
const copyText = rowsToCopy
|
||||||
|
.map((submission: any) =>
|
||||||
|
stripPriceDecorations(String(submission.formatted_string || "")).trim(),
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const totalEstimatedValue = rowsToCopy.reduce((sum: number, submission: any) => {
|
||||||
|
const value = getEstimatedTotalNumber(submission.estimated_total);
|
||||||
|
return value === null ? sum : sum + value;
|
||||||
|
}, 0);
|
||||||
|
const hasAnyEstimatedValue = rowsToCopy.some(
|
||||||
|
(submission: any) => getEstimatedTotalNumber(submission.estimated_total) !== null,
|
||||||
|
);
|
||||||
|
const totalEstimatedText = hasAnyEstimatedValue
|
||||||
|
? formatEstimatedTotal(totalEstimatedValue)
|
||||||
|
: null;
|
||||||
|
const pagedSubmissions = filteredSubmissions.slice(
|
||||||
|
(submissionsPage - 1) * SUBMISSIONS_PAGE_SIZE,
|
||||||
|
submissionsPage * SUBMISSIONS_PAGE_SIZE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const copySubmissionList = (_text: string, didCopy: boolean) => {
|
||||||
|
if (!copyText) {
|
||||||
|
message.warning("Nothing to copy.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didCopy === false) {
|
||||||
|
message.error("Could not copy to clipboard.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success(
|
||||||
|
`Copied ${rowsToCopy.length} ${rowsToCopy.length === 1 ? "entry" : "entries"}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }} size={16}>
|
||||||
|
<AdminParticipantShareAlert orderId={orderId} />
|
||||||
|
|
||||||
|
<ReadOnlyOrderOverviewCard
|
||||||
|
order={data}
|
||||||
|
config={data?.config}
|
||||||
|
onImportConfig={handleImportConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminSubmissionsCard
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
selectedSubmissionState={selectedSubmissionState}
|
||||||
|
onSearchQueryChange={setSearchQuery}
|
||||||
|
onSelectedSubmissionStateChange={setSelectedSubmissionState}
|
||||||
|
onReload={loadAdmin}
|
||||||
|
copyText={copyText}
|
||||||
|
onCopy={copySubmissionList}
|
||||||
|
selectedRowKeys={selectedRowKeys}
|
||||||
|
onSelectedRowKeysChange={setSelectedRowKeys}
|
||||||
|
pagedSubmissions={pagedSubmissions}
|
||||||
|
deletingId={deletingId}
|
||||||
|
savingStatusKey={savingStatusKey}
|
||||||
|
onDeleteSubmission={deleteAsAdmin}
|
||||||
|
onUpdateSubmissionStatus={updateSubmissionStatus}
|
||||||
|
totalEstimatedText={totalEstimatedText}
|
||||||
|
selectedCount={selected.length}
|
||||||
|
submissionsPage={submissionsPage}
|
||||||
|
pageSize={SUBMISSIONS_PAGE_SIZE}
|
||||||
|
totalCount={filteredSubmissions.length}
|
||||||
|
onSubmissionsPageChange={setSubmissionsPage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminControlCenterCard
|
||||||
|
isClosed={!!data?.closed}
|
||||||
|
updatingOrderStatus={updatingOrderStatus}
|
||||||
|
deletingOrder={deletingOrder}
|
||||||
|
onToggleClosed={updateOrderClosedStatus}
|
||||||
|
onDeleteOrder={deleteOrder}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
import React, { useState, useRef } from "react";
|
||||||
|
import {
|
||||||
|
PlusCircleOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Tooltip,
|
||||||
|
message,
|
||||||
|
Space,
|
||||||
|
Upload,
|
||||||
|
Tabs,
|
||||||
|
Typography,
|
||||||
|
} from "antd";
|
||||||
|
import type { UploadFile, UploadProps } from "antd";
|
||||||
|
import { apiService } from "../lib/services";
|
||||||
|
import { navigateTo } from "../lib/routing";
|
||||||
|
import { ORDER_FORMAT_VALUE_PLACEHOLDER } from "../lib/orderFormatting";
|
||||||
|
import type { OrderFormConfig } from "../lib/types";
|
||||||
|
import OrderFormConfigBuilder from "../components/forms/OrderFormConfigBuilder";
|
||||||
|
import CreateOrderHeaderActions from "../components/views/CreateOrderHeaderActions";
|
||||||
|
import ExportSelectionModal, { type ExportSelectionState } from "../components/modals/ExportSelectionModal";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
type CreateOrderValues = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: OrderFormConfig = {
|
||||||
|
categories: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateFormConfigRecursively(config: OrderFormConfig): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const seenCategoryLabels = new Set<string>();
|
||||||
|
|
||||||
|
if (!config || !Array.isArray(config.categories)) {
|
||||||
|
return ["Menu configuration is invalid"];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let categoryIndex = 0; categoryIndex < config.categories.length; categoryIndex += 1) {
|
||||||
|
const category = config.categories[categoryIndex];
|
||||||
|
const categoryPath = `Category ${categoryIndex + 1}`;
|
||||||
|
|
||||||
|
if (!category || typeof category !== "object") {
|
||||||
|
errors.push(`${categoryPath}: category is invalid`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryLabel = String(category.label || "").trim();
|
||||||
|
const categoryId = String(category.id || "").trim();
|
||||||
|
const formattedSnippet = String(category.formattedSnippet || "").trim();
|
||||||
|
|
||||||
|
if (!categoryLabel) {
|
||||||
|
errors.push(`${categoryPath}: label is required`);
|
||||||
|
} else {
|
||||||
|
const normalizedCategoryLabel = categoryLabel.toLowerCase();
|
||||||
|
if (seenCategoryLabels.has(normalizedCategoryLabel)) {
|
||||||
|
errors.push(`Duplicate category name \"${categoryLabel}\"`);
|
||||||
|
}
|
||||||
|
seenCategoryLabels.add(normalizedCategoryLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!categoryId) {
|
||||||
|
errors.push(`${categoryPath}: id is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formattedSnippet) {
|
||||||
|
errors.push(`${categoryPath}: formatted snippet is required`);
|
||||||
|
} else if (!formattedSnippet.includes(ORDER_FORMAT_VALUE_PLACEHOLDER)) {
|
||||||
|
errors.push(
|
||||||
|
`${categoryPath}: formatted snippet must include {name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(category.items)) {
|
||||||
|
errors.push(`${categoryPath}: items must be a list`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!category.custom && category.items.length === 0) {
|
||||||
|
errors.push(`${categoryPath}: add at least one item or enable custom entries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenNames = new Set<string>();
|
||||||
|
|
||||||
|
for (let itemIndex = 0; itemIndex < category.items.length; itemIndex += 1) {
|
||||||
|
const item = category.items[itemIndex];
|
||||||
|
const itemPath = `${categoryPath} > Item ${itemIndex + 1}`;
|
||||||
|
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
errors.push(`${itemPath}: item is invalid`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemName = String(item.name || "").trim();
|
||||||
|
if (!itemName) {
|
||||||
|
errors.push(`${itemPath}: name is required`);
|
||||||
|
} else {
|
||||||
|
const normalizedName = itemName.toLowerCase();
|
||||||
|
if (seenNames.has(normalizedName)) {
|
||||||
|
errors.push(`${categoryPath}: duplicate item name \"${itemName}\"`);
|
||||||
|
}
|
||||||
|
seenNames.add(normalizedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.price !== undefined && item.price !== null) {
|
||||||
|
const price = Number(item.price);
|
||||||
|
if (!Number.isFinite(price) || price < 0) {
|
||||||
|
errors.push(`${itemPath}: price must be a non-negative number`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set(errors));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateOrderView() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [form] = Form.useForm<CreateOrderValues>();
|
||||||
|
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||||
|
const [activeTabKey, setActiveTabKey] = useState<string>("details");
|
||||||
|
const [imageFileList, setImageFileList] = useState<UploadFile[]>([]);
|
||||||
|
const [formConfig, setFormConfig] = useState<OrderFormConfig>(DEFAULT_CONFIG);
|
||||||
|
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||||
|
const [exportOptions, setExportOptions] = useState<ExportSelectionState>({
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
menu: true,
|
||||||
|
});
|
||||||
|
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const beforeImageUpload: UploadProps["beforeUpload"] = (file) => {
|
||||||
|
const allowed = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
|
||||||
|
const fileExt = "." + (file.name.split(".").pop() || "").toLowerCase();
|
||||||
|
|
||||||
|
if (!allowed.has(fileExt)) {
|
||||||
|
message.error(
|
||||||
|
"File type not allowed. Allowed types: jpg, jpeg, png, gif, webp",
|
||||||
|
);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
message.error("File size must be less than 10MB");
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageFileList([file]);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNativeFile = (uploadFile?: UploadFile): File | null => {
|
||||||
|
if (!uploadFile) return null;
|
||||||
|
if (uploadFile.originFileObj instanceof File) {
|
||||||
|
return uploadFile.originFileObj;
|
||||||
|
}
|
||||||
|
if (uploadFile instanceof File) {
|
||||||
|
return uploadFile;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFinish = async (values: CreateOrderValues) => {
|
||||||
|
const configErrors = validateFormConfigRecursively(formConfig);
|
||||||
|
if (configErrors.length > 0) {
|
||||||
|
setValidationErrors(configErrors);
|
||||||
|
setActiveTabKey("form");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationErrors([]);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const order = await apiService.orders.create({
|
||||||
|
title: values.title,
|
||||||
|
description: values.description || "",
|
||||||
|
config: formConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedImage = getNativeFile(imageFileList[0]);
|
||||||
|
|
||||||
|
// Upload image if provided
|
||||||
|
if (selectedImage) {
|
||||||
|
try {
|
||||||
|
await apiService.orders.uploadImage(order.id, selectedImage);
|
||||||
|
message.success("Order created and image uploaded");
|
||||||
|
} catch (error: any) {
|
||||||
|
message.warning(
|
||||||
|
"Order created but image upload failed: " + error.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.success("Group order created");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save form configuration if provided
|
||||||
|
if (formConfig.categories.length > 0) {
|
||||||
|
try {
|
||||||
|
await apiService.orders.updateConfig(order.id, formConfig);
|
||||||
|
message.success("Form configuration saved");
|
||||||
|
} catch (error: any) {
|
||||||
|
message.warning(
|
||||||
|
"Order created but config save failed: " + error.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageFileList([]);
|
||||||
|
setFormConfig(DEFAULT_CONFIG);
|
||||||
|
|
||||||
|
navigateTo(`/order/${order.id}/admin`);
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFinishFailed = (errorInfo: {
|
||||||
|
errorFields: Array<{ errors?: string[] }>;
|
||||||
|
}) => {
|
||||||
|
const formMessages = errorInfo.errorFields
|
||||||
|
.flatMap((field) => field.errors || [])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const configErrors = validateFormConfigRecursively(formConfig);
|
||||||
|
if (configErrors.length > 0) {
|
||||||
|
setActiveTabKey("form");
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationErrors(Array.from(new Set([...formMessages, ...configErrors])));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateClick = async () => {
|
||||||
|
try {
|
||||||
|
await form.validateFields();
|
||||||
|
} catch (_error) {
|
||||||
|
// onFinishFailed handles form validation errors.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configErrors = validateFormConfigRecursively(formConfig);
|
||||||
|
if (configErrors.length > 0) {
|
||||||
|
setValidationErrors(configErrors);
|
||||||
|
setActiveTabKey("form");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.submit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportJson = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const content = e.target?.result;
|
||||||
|
if (typeof content !== "string") {
|
||||||
|
message.error("Failed to read import file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(content) as {
|
||||||
|
title?: unknown;
|
||||||
|
description?: unknown;
|
||||||
|
menu?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const importedSections: string[] = [];
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(parsed, "title")) {
|
||||||
|
form.setFieldValue(
|
||||||
|
"title",
|
||||||
|
typeof parsed.title === "string" ? parsed.title : "",
|
||||||
|
);
|
||||||
|
importedSections.push("title");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(parsed, "description")) {
|
||||||
|
form.setFieldValue(
|
||||||
|
"description",
|
||||||
|
typeof parsed.description === "string" ? parsed.description : "",
|
||||||
|
);
|
||||||
|
importedSections.push("description");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(parsed, "menu")) {
|
||||||
|
const menu = parsed.menu as Partial<OrderFormConfig> | null;
|
||||||
|
setFormConfig({
|
||||||
|
categories: Array.isArray(menu?.categories) ? menu.categories : [],
|
||||||
|
});
|
||||||
|
importedSections.push("menu");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importedSections.length === 0) {
|
||||||
|
message.warning("No supported sections found in import file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success(`Imported: ${importedSections.join(", ")}`);
|
||||||
|
} catch (error) {
|
||||||
|
message.error("Failed to import JSON file");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
|
||||||
|
// Reset input so same file can be selected again
|
||||||
|
if (importFileInputRef.current) {
|
||||||
|
importFileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportJson = () => {
|
||||||
|
const payload: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
menu?: OrderFormConfig;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (exportOptions.title) {
|
||||||
|
payload.title = form.getFieldValue("title") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exportOptions.description) {
|
||||||
|
payload.description = form.getFieldValue("description") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exportOptions.menu) {
|
||||||
|
payload.menu = formConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
message.warning("Select at least one section to export");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeTitle = (form.getFieldValue("title") || "order")
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${safeTitle || "order"}-export.json`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
setIsExportModalOpen(false);
|
||||||
|
message.success("Exported selected sections as JSON");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title="Create Order"
|
||||||
|
extra={
|
||||||
|
<CreateOrderHeaderActions
|
||||||
|
importFileInputRef={importFileInputRef}
|
||||||
|
onImportJson={handleImportJson}
|
||||||
|
onOpenExport={() => setIsExportModalOpen(true)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={onFinish}
|
||||||
|
onFinishFailed={onFinishFailed}
|
||||||
|
initialValues={undefined}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="Title"
|
||||||
|
name="title"
|
||||||
|
extra="The title of your group order."
|
||||||
|
rules={[{ required: true, message: "Title is required" }]}
|
||||||
|
>
|
||||||
|
<Input maxLength={200} placeholder="Wednesday Lunch" />
|
||||||
|
</Form.Item>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTabKey}
|
||||||
|
onChange={setActiveTabKey}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: "details",
|
||||||
|
label: "Details",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
label="Description"
|
||||||
|
name="description"
|
||||||
|
extra="Add notes about order deadline, notes, and pickup details."
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={6}
|
||||||
|
placeholder="You can use markdown to format the description."
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Image"
|
||||||
|
extra="Add an image alongside the description."
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
accept=".jpg,.jpeg,.png,.gif,.webp,image/*"
|
||||||
|
listType="picture"
|
||||||
|
maxCount={1}
|
||||||
|
beforeUpload={beforeImageUpload}
|
||||||
|
fileList={imageFileList}
|
||||||
|
onRemove={() => {
|
||||||
|
setImageFileList([]);
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />}>Upload</Button>
|
||||||
|
</Upload>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "form",
|
||||||
|
label: "Menu",
|
||||||
|
children: (
|
||||||
|
<OrderFormConfigBuilder
|
||||||
|
config={formConfig}
|
||||||
|
onChange={setFormConfig}
|
||||||
|
showImportExport={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{validationErrors.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Please fix the following issues:"
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
{validationErrors.map((errorMessage) => (
|
||||||
|
<Text key={errorMessage}>{errorMessage}</Text>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tooltip title="Create a new order">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleCreateClick}
|
||||||
|
icon={<PlusCircleOutlined />}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Create order
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
<ExportSelectionModal
|
||||||
|
open={isExportModalOpen}
|
||||||
|
title="Export Order Data"
|
||||||
|
description="Select which sections of the order you want to include in the export:"
|
||||||
|
options={[
|
||||||
|
{ key: "title", label: "Title" },
|
||||||
|
{ key: "description", label: "Description" },
|
||||||
|
{ key: "menu", label: "Menu" },
|
||||||
|
]}
|
||||||
|
selected={exportOptions}
|
||||||
|
onCancel={() => setIsExportModalOpen(false)}
|
||||||
|
onConfirm={handleExportJson}
|
||||||
|
onSelectedChange={setExportOptions}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Space,
|
||||||
|
message,
|
||||||
|
} from "antd";
|
||||||
|
import AsyncContent from "../components/utils/AsyncContent";
|
||||||
|
import { useApiRequest } from "../hooks/useApiRequest";
|
||||||
|
import { apiService } from "../lib/services";
|
||||||
|
import { navigateTo } from "../lib/routing";
|
||||||
|
import type { GetOrdersResponse } from "../lib/types";
|
||||||
|
import HomeOrdersFilters from "../components/views/HomeOrdersFilters";
|
||||||
|
import HomeOrdersTable from "../components/views/HomeOrdersTable";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
export default function HomeView() {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [selectedOwnerType, setSelectedOwnerType] = useState("");
|
||||||
|
const [selectedState, setSelectedState] = useState("");
|
||||||
|
|
||||||
|
const loadOrdersData = useCallback(async (): Promise<GetOrdersResponse> => {
|
||||||
|
const skip = (currentPage - 1) * PAGE_SIZE;
|
||||||
|
const queryParams = {
|
||||||
|
skip,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
...(selectedOwnerType && { role: selectedOwnerType }),
|
||||||
|
...(selectedState && { state: selectedState }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = await apiService.orders.getMine(queryParams);
|
||||||
|
return payload;
|
||||||
|
}, [currentPage, selectedOwnerType, selectedState]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: response,
|
||||||
|
loading: loadingOrders,
|
||||||
|
error: loadError,
|
||||||
|
run: loadOrders,
|
||||||
|
} = useApiRequest(loadOrdersData, {
|
||||||
|
deps: [loadOrdersData],
|
||||||
|
initialData: {
|
||||||
|
orders: [],
|
||||||
|
pagination: { total: 0, skip: 0, limit: PAGE_SIZE, hasMore: false },
|
||||||
|
},
|
||||||
|
onError: (requestError) => {
|
||||||
|
message.error(requestError?.message || "Unable to load saved orders.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const orders = response?.orders || [];
|
||||||
|
const pagination = response?.pagination || {
|
||||||
|
total: 0,
|
||||||
|
skip: 0,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
hasMore: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }} size={16}>
|
||||||
|
<Card
|
||||||
|
title="Your Orders"
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => navigateTo("/create")}
|
||||||
|
>
|
||||||
|
Create Order
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AsyncContent
|
||||||
|
loading={loadingOrders}
|
||||||
|
error={loadError}
|
||||||
|
onRetry={loadOrders}
|
||||||
|
errorTitle="Could not load your orders"
|
||||||
|
loadingRows={3}
|
||||||
|
loadingSections={1}
|
||||||
|
>
|
||||||
|
<HomeOrdersFilters
|
||||||
|
currentPage={currentPage}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
total={pagination.total}
|
||||||
|
selectedOwnerType={selectedOwnerType}
|
||||||
|
selectedState={selectedState}
|
||||||
|
onOwnerTypeChange={(value) => {
|
||||||
|
setSelectedOwnerType(value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
onStateChange={(value) => {
|
||||||
|
setSelectedState(value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
onPageChange={(page) => setCurrentPage(page)}
|
||||||
|
/>
|
||||||
|
<HomeOrdersTable
|
||||||
|
orders={orders}
|
||||||
|
selectedState={selectedState}
|
||||||
|
selectedOwnerType={selectedOwnerType}
|
||||||
|
onPageFromTable={(page) => setCurrentPage(page)}
|
||||||
|
/>
|
||||||
|
</AsyncContent>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { DeleteOutlined, SaveOutlined } from "@ant-design/icons";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Form,
|
||||||
|
Popconfirm,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from "antd";
|
||||||
|
import AsyncContent from "../components/utils/AsyncContent";
|
||||||
|
import { useApiRequest } from "../hooks/useApiRequest";
|
||||||
|
import { isNotFoundError } from "../lib/api";
|
||||||
|
import { navigateTo } from "../lib/routing";
|
||||||
|
import { apiService } from "../lib/services";
|
||||||
|
import LoadingSkeleton from "../components/utils/LoadingSkeleton";
|
||||||
|
import ReadOnlyOrderOverviewCard from "../components/orders/ReadOnlyOrderOverviewCard";
|
||||||
|
import {
|
||||||
|
buildFormattedOrderString,
|
||||||
|
calculateEstimatedOrderTotal,
|
||||||
|
formatEstimatedTotal,
|
||||||
|
type NormalizedOrderSelections,
|
||||||
|
normalizeOrderSelections,
|
||||||
|
} from "../lib/orderFormatting";
|
||||||
|
import type {
|
||||||
|
OrderFormConfig,
|
||||||
|
Order,
|
||||||
|
OrderAccessInfo,
|
||||||
|
Submission,
|
||||||
|
} from "../lib/types";
|
||||||
|
import ParticipantCategoryFields from "../components/views/ParticipantCategoryFields";
|
||||||
|
import ParticipantSubmissionSummary from "../components/views/ParticipantSubmissionSummary";
|
||||||
|
import ParticipantDraftSummaryAlert from "../components/views/ParticipantDraftSummaryAlert";
|
||||||
|
|
||||||
|
const { Text, Link } = Typography;
|
||||||
|
|
||||||
|
type SubmissionValues = {
|
||||||
|
choices: NormalizedOrderSelections;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormModel = Record<string, unknown>;
|
||||||
|
|
||||||
|
function serializeSelections(values: NormalizedOrderSelections) {
|
||||||
|
return JSON.stringify(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFormValues(values: NormalizedOrderSelections): FormModel {
|
||||||
|
const model: FormModel = {};
|
||||||
|
|
||||||
|
Object.entries(values).forEach(([key, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
model[key] = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model[key] = value ?? undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParticipantLoadData = {
|
||||||
|
order: Order;
|
||||||
|
config: OrderFormConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ParticipantView({ orderId }: { orderId: string }) {
|
||||||
|
const [form] = Form.useForm<FormModel>();
|
||||||
|
const watchedValues = Form.useWatch([], form);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [loadingSubmission, setLoadingSubmission] = useState(true);
|
||||||
|
const [isEditingSubmittedOrder, setIsEditingSubmittedOrder] = useState(false);
|
||||||
|
const [liveOrder, setLiveOrder] = useState<Order | null>(null);
|
||||||
|
const [existingSubmission, setExistingSubmission] =
|
||||||
|
useState<Submission | null>(null);
|
||||||
|
const [orderAccess, setOrderAccess] = useState<OrderAccessInfo | null>(null);
|
||||||
|
|
||||||
|
const loadParticipantData =
|
||||||
|
useCallback(async (): Promise<ParticipantLoadData> => {
|
||||||
|
const [order, config] = await Promise.all([
|
||||||
|
apiService.orders.get(orderId),
|
||||||
|
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { order, config };
|
||||||
|
}, [orderId]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
run: reload,
|
||||||
|
} = useApiRequest(loadParticipantData, {
|
||||||
|
deps: [loadParticipantData],
|
||||||
|
onError: (requestError) => {
|
||||||
|
message.error(requestError?.message || "Order could not be loaded.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = data?.order || null;
|
||||||
|
const config: OrderFormConfig = useMemo(
|
||||||
|
() => ({ categories: data?.config?.categories || [] }),
|
||||||
|
[data?.config?.categories],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pageError =
|
||||||
|
error || (!order || !config ? "Order could not be loaded." : null);
|
||||||
|
const isClosed = !!liveOrder?.closed;
|
||||||
|
const normalizedDraftValues = normalizeOrderSelections(
|
||||||
|
watchedValues || {},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
const draftOrderString = buildFormattedOrderString(
|
||||||
|
normalizedDraftValues,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
const draftEstimatedTotal = useMemo(
|
||||||
|
() =>
|
||||||
|
formatEstimatedTotal(
|
||||||
|
calculateEstimatedOrderTotal(normalizedDraftValues, config),
|
||||||
|
),
|
||||||
|
[normalizedDraftValues, config],
|
||||||
|
);
|
||||||
|
const parseSavedChoices = (
|
||||||
|
submission: Submission | null,
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
const rawChoices = submission?.choices_json ?? submission?.choices;
|
||||||
|
|
||||||
|
if (!rawChoices) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawChoices === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawChoices);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawChoices;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedSavedValues = useMemo(
|
||||||
|
() =>
|
||||||
|
normalizeOrderSelections(parseSavedChoices(existingSubmission), config),
|
||||||
|
[existingSubmission, config],
|
||||||
|
);
|
||||||
|
const savedOrderString = useMemo(
|
||||||
|
() => buildFormattedOrderString(normalizedSavedValues, config),
|
||||||
|
[normalizedSavedValues, config],
|
||||||
|
);
|
||||||
|
const savedEstimatedTotal = useMemo(
|
||||||
|
() =>
|
||||||
|
formatEstimatedTotal(
|
||||||
|
calculateEstimatedOrderTotal(normalizedSavedValues, config),
|
||||||
|
),
|
||||||
|
[normalizedSavedValues, config],
|
||||||
|
);
|
||||||
|
const hasActiveChanges =
|
||||||
|
!!existingSubmission &&
|
||||||
|
serializeSelections(normalizedDraftValues) !==
|
||||||
|
serializeSelections(normalizedSavedValues);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (order) {
|
||||||
|
setLiveOrder(order);
|
||||||
|
}
|
||||||
|
}, [order]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
async function loadExisting() {
|
||||||
|
try {
|
||||||
|
const [access, existing] = await Promise.all([
|
||||||
|
apiService.orders.getMyAccess(orderId),
|
||||||
|
apiService.submissions
|
||||||
|
.getMine(orderId)
|
||||||
|
.catch((submissionError: any) => {
|
||||||
|
if (isNotFoundError(submissionError)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw submissionError;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderAccess(access);
|
||||||
|
setExistingSubmission(existing);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setLoadingSubmission(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadExisting();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [form, orderId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!existingSubmission) {
|
||||||
|
setIsEditingSubmittedOrder(false);
|
||||||
|
}
|
||||||
|
}, [existingSubmission]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isClosed) {
|
||||||
|
setIsEditingSubmittedOrder(false);
|
||||||
|
}
|
||||||
|
}, [isClosed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!existingSubmission || !isEditingSubmittedOrder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setFieldsValue(toFormValues(normalizedSavedValues));
|
||||||
|
}, [
|
||||||
|
existingSubmission,
|
||||||
|
form,
|
||||||
|
isEditingSubmittedOrder,
|
||||||
|
normalizedSavedValues,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const refreshOrderStatus = async () => {
|
||||||
|
try {
|
||||||
|
const latestOrder: any = await apiService.orders.get(orderId);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLiveOrder((previous: any) => {
|
||||||
|
if (!previous) {
|
||||||
|
return latestOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed =
|
||||||
|
previous.closed !== latestOrder.closed ||
|
||||||
|
previous.updated_at !== latestOrder.updated_at;
|
||||||
|
|
||||||
|
return changed ? { ...previous, ...latestOrder } : previous;
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
// Keep current order state when polling fails.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalId = setInterval(refreshOrderStatus, 5000);
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [orderId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!existingSubmission) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const refreshSubmission = async () => {
|
||||||
|
try {
|
||||||
|
const latest: any = await apiService.submissions.getMine(orderId);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExistingSubmission((previous: any) => {
|
||||||
|
if (!previous) {
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed =
|
||||||
|
previous.updated_at !== latest.updated_at ||
|
||||||
|
previous.accepted !== latest.accepted ||
|
||||||
|
previous.paid !== latest.paid ||
|
||||||
|
JSON.stringify(parseSavedChoices(previous)) !==
|
||||||
|
JSON.stringify(parseSavedChoices(latest));
|
||||||
|
|
||||||
|
return changed ? latest : previous;
|
||||||
|
});
|
||||||
|
} catch (submissionError) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNotFoundError(submissionError)) {
|
||||||
|
setExistingSubmission(null);
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalId = setInterval(refreshSubmission, 5000);
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [existingSubmission, form, orderId]);
|
||||||
|
|
||||||
|
const onFinish = async (values: FormModel) => {
|
||||||
|
if (isClosed) {
|
||||||
|
message.warning("This order is closed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeOrderSelections(values, config);
|
||||||
|
const payload: SubmissionValues = {
|
||||||
|
choices: normalized,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (existingSubmission) {
|
||||||
|
const updated: any = await apiService.submissions.updateMine(
|
||||||
|
orderId,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
setExistingSubmission(updated);
|
||||||
|
setIsEditingSubmittedOrder(false);
|
||||||
|
form.resetFields();
|
||||||
|
message.success("Submission updated");
|
||||||
|
} else {
|
||||||
|
const created: any = await apiService.submissions.createMine(
|
||||||
|
orderId,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
setExistingSubmission(created);
|
||||||
|
setIsEditingSubmittedOrder(false);
|
||||||
|
form.resetFields();
|
||||||
|
message.success("Submission saved");
|
||||||
|
}
|
||||||
|
} catch (submissionError: any) {
|
||||||
|
message.error(submissionError.message || "Submission failed");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSubmission = async () => {
|
||||||
|
if (!existingSubmission) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isClosed) {
|
||||||
|
message.warning("This order is closed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await apiService.submissions.removeMine(orderId);
|
||||||
|
setExistingSubmission(null);
|
||||||
|
setIsEditingSubmittedOrder(false);
|
||||||
|
form.resetFields();
|
||||||
|
message.success("Submission deleted");
|
||||||
|
} catch (submissionError: any) {
|
||||||
|
message.error(submissionError.message || "Delete failed");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingSubmission) {
|
||||||
|
return <LoadingSkeleton rows={5} sections={2} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canShowSubmittedResult =
|
||||||
|
!!existingSubmission && !isEditingSubmittedOrder;
|
||||||
|
const showSummaryView = isClosed || canShowSubmittedResult;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncContent
|
||||||
|
loading={loading}
|
||||||
|
error={pageError}
|
||||||
|
onRetry={reload}
|
||||||
|
errorTitle="Order page unavailable"
|
||||||
|
loadingRows={5}
|
||||||
|
loadingSections={2}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }} size={16}>
|
||||||
|
{orderAccess?.is_owner && (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message={
|
||||||
|
<Text>
|
||||||
|
{"You're the owner of this order! "}
|
||||||
|
<Link
|
||||||
|
href={`/order/${orderId}/admin`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
navigateTo(`/order/${orderId}/admin`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit order
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isClosed && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="This order is closed. Submissions can no longer be changed."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReadOnlyOrderOverviewCard order={liveOrder} config={config} />
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Your order"
|
||||||
|
extra={
|
||||||
|
existingSubmission && !isClosed ? (
|
||||||
|
canShowSubmittedResult ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => setIsEditingSubmittedOrder(true)}
|
||||||
|
>
|
||||||
|
Edit Order
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditingSubmittedOrder(false);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Summary
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
{showSummaryView ? (
|
||||||
|
<ParticipantSubmissionSummary
|
||||||
|
submission={existingSubmission}
|
||||||
|
savedOrderString={savedOrderString}
|
||||||
|
savedEstimatedTotal={savedEstimatedTotal}
|
||||||
|
isClosed={isClosed}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||||
|
{config.categories.length === 0 ? (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="No form configuration available for this order."
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Row gutter={12}>
|
||||||
|
<ParticipantCategoryFields
|
||||||
|
categories={config.categories}
|
||||||
|
isClosed={isClosed}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.categories.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ParticipantDraftSummaryAlert
|
||||||
|
hasExistingSubmission={!!existingSubmission}
|
||||||
|
hasActiveChanges={hasActiveChanges}
|
||||||
|
savedOrderString={savedOrderString}
|
||||||
|
savedEstimatedTotal={savedEstimatedTotal}
|
||||||
|
draftOrderString={draftOrderString}
|
||||||
|
draftEstimatedTotal={draftEstimatedTotal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
existingSubmission
|
||||||
|
? "Save updates to your submission"
|
||||||
|
: "Submit your order"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={isClosed}
|
||||||
|
>
|
||||||
|
{existingSubmission ? "Update" : "Submit"}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{existingSubmission && (
|
||||||
|
<Popconfirm
|
||||||
|
title="Delete your current submission?"
|
||||||
|
description="This action cannot be undone."
|
||||||
|
okText="Delete"
|
||||||
|
cancelText="Cancel"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={deleteSubmission}
|
||||||
|
disabled={isClosed}
|
||||||
|
>
|
||||||
|
<Tooltip title="Delete your current submission">
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={isClosed}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
</AsyncContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowJs": false,
|
||||||
|
"strict": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user