commit c16d48ee3294bc1b3c4790bdfc1654e299d79d55 Author: Troll (Hermes Agent) Date: Fri Jul 31 19:47:52 2026 +0000 Initial prototype for theme song booth diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..262ec21 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +APP_SECRET_KEY=change-me-in-production +ADMIN_PASSWORD_HASH= +SMTP_HOST=mailroot8.namespro.ca +SMTP_PORT=465 +SMTP_USER=ai@hallsworth.ca +SMTP_PASS= +SMTP_FROM=ai@hallsworth.ca +ADMIN_ALERT_EMAIL=ai@hallsworth.ca +PUBLIC_BASE_URL=http://127.0.0.1:5000 +BOOTH_NAME=Trollgorithm Theme Songs +DATABASE=/app/data/booth.db +UPLOAD_FOLDER=/app/uploads +PRICE_PER_VERSION=10.00 +CURRENCY=CAD diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f6e052 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +__pycache__/ +*.pyc +.venv/ +data/ +uploads/ +*.db +*.mp3 +*.wav +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c61bfe4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +RUN useradd -m -u 1000 boothuser && mkdir -p /app/data /app/uploads && chown -R boothuser:boothuser /app +USER boothuser + +EXPOSE 8000 + +CMD ["gunicorn", "-b", "0.0.0.0:8000", "--access-logfile", "-", "app:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..72740b1 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Theme Song Booth + +Prototype web app for a convention booth where attendees request custom AI-generated theme songs. + +## Flow + +1. Customer fills out the public request form at `/request`. +2. Operator generates a Suno Custom Mode prompt via Hermes and saves it in the admin detail page. +3. Operator generates two song versions in Suno and uploads the MP3s in admin. +4. Operator clicks **Send Preview Link**. Customer receives an email with a private player page. +5. Customer listens to Version A and Version B, then approves one/both or requests changes. +6. Operator sees the approval alert, collects payment via Square reader, then clicks **Mark Paid & Deliver**. +7. Customer receives the approved MP3(s) as email attachments. + +## Local Development + +```bash +cd /home/jess/workspace/theme-song-booth +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +cp .env.example .env +# Edit .env and set APP_SECRET_KEY, ADMIN_PASSWORD_HASH, SMTP_PASS, PUBLIC_BASE_URL +.venv/bin/python init_db.py +.venv/bin/python -m flask --app app run --host=0.0.0.0 +``` + +Generate an admin password hash with: + +```bash +.venv/bin/python -c "from werkzeug.security import generate_password_hash; print(generate_password_hash('yourpassword'))" +``` + +## Deployment on Unraid + +1. Copy the project directory to your Unraid server or build it via git. +2. Create `/mnt/user/appdata/theme-song-booth/data` and `/mnt/user/appdata/theme-song-booth/uploads`. +3. Copy `.env.example` to `.env`, fill in real values, and place it next to `docker-compose.yml`. +4. Run `docker compose up -d --build`. +5. Initialize the database once: `docker compose exec booth python init_db.py`. +6. Point your chosen domain at the Unraid server's public IP and route it through your reverse proxy to `http://127.0.0.1:8000`. +7. Print the booth QR code pointing to `https://your-domain.example.com/request`. + +## Files + +- `app.py` — Flask application with public/admin routes and email logic. +- `models.py` — SQLite schema and helper functions. +- `config.py` — Configuration loaded from environment. +- `templates/` — Jinja2 HTML templates. +- `init_db.py` — Standalone script to create the SQLite database. +- `Dockerfile` / `docker-compose.yml` — Container packaging for Unraid. diff --git a/app.py b/app.py new file mode 100644 index 0000000..962b328 --- /dev/null +++ b/app.py @@ -0,0 +1,285 @@ +import os +import smtplib +import ssl +from email.message import EmailMessage +from pathlib import Path + +from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app +from werkzeug.security import check_password_hash, generate_password_hash +from werkzeug.utils import secure_filename + +from config import Config +from models import init_db, close_db, create_request, get_request_by_id, get_request_by_token, list_requests, update_request, now_utc + +app = Flask(__name__) +app.config.from_object(Config) +app.teardown_appcontext(close_db) + +STATUS_LABELS = { + 'pending': 'Pending', + 'prompt_ready': 'Prompt Ready', + 'songs_uploaded': 'Songs Uploaded — Awaiting Approval', + 'awaiting_payment': 'Awaiting Payment', + 'paid': 'Paid', + 'delivered': 'Delivered', +} + +# ---------------- helpers ---------------- + +def is_admin(): + return session.get('admin') is True + +def require_admin(): + if not is_admin(): + return redirect(url_for('admin_login')) + +def allowed_file(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS'] + +def upload_path(request_id): + p = Path(current_app.config['UPLOAD_FOLDER']) / str(request_id) + p.mkdir(parents=True, exist_ok=True) + return p + +def save_upload(request_id, file_obj, version): + if not file_obj or file_obj.filename == '': + return None + if not allowed_file(file_obj.filename): + flash('Only MP3 files are allowed.', 'error') + return None + p = upload_path(request_id) + filename = f'song_{version}.mp3' + file_obj.save(p / filename) + return str(p / filename) + +def send_email(to, subject, body, attachments=None): + cfg = current_app.config + if not cfg['SMTP_PASS']: + raise RuntimeError('SMTP_PASS is not configured') + + msg = EmailMessage() + msg['From'] = cfg['SMTP_FROM'] + msg['To'] = to + msg['Subject'] = subject + msg.set_content(body) + + if attachments: + for path, name in attachments: + with open(path, 'rb') as f: + data = f.read() + msg.add_attachment(data, maintype='audio', subtype='mpeg', filename=name) + + with smtplib.SMTP_SSL(cfg['SMTP_HOST'], cfg['SMTP_PORT'], context=ssl.create_default_context()) as server: + server.login(cfg['SMTP_USER'], cfg['SMTP_PASS']) + server.send_message(msg) + +# ---------------- public ---------------- + +@app.route('/') +def index(): + return redirect(url_for('request_form')) + +@app.route('/request', methods=['GET', 'POST']) +def request_form(): + if request.method == 'POST': + rid = create_request( + name=request.form.get('name', '').strip(), + email=request.form.get('email', '').strip(), + hobbies=request.form.get('hobbies', '').strip(), + notable_facts=request.form.get('notable_facts', '').strip(), + style_genre=request.form.get('style_genre', '').strip(), + extra_requests=request.form.get('extra_requests', '').strip(), + ) + flash('Your request has been submitted! Check your email soon.', 'success') + return redirect(url_for('thanks', rid=rid)) + return render_template('request.html') + +@app.route('/thanks/') +def thanks(rid): + req = get_request_by_id(rid) + if not req: + abort(404) + return render_template('thanks.html', req=req) + +@app.route('/play/') +def play(token): + req = get_request_by_token(token) + if not req: + abort(404) + return render_template('player.html', req=req) + +@app.route('/play//approve', methods=['POST']) +def approve(token): + req = get_request_by_token(token) + if not req: + abort(404) + choice = request.form.get('choice') + if choice not in ('a', 'b', 'both'): + flash('Invalid selection.', 'error') + return redirect(url_for('play', token=token)) + + update_request(req['id'], customer_approved=choice, status='awaiting_payment', approval_notified_at=now_utc()) + + # alert operator + alert_to = current_app.config['ADMIN_ALERT_EMAIL'] or current_app.config['SMTP_FROM'] + if alert_to: + admin_link = f"{current_app.config['PUBLIC_BASE_URL']}/admin/request/{req['id']}" + version_label = {'a': 'A', 'b': 'B', 'both': 'Both'}[choice] + body = f"{req['name']} ({req['email']}) approved: Version {version_label}.\n\nRequest #{req['id']}\nPayment is now due.\n\nOpen admin: {admin_link}" + try: + send_email(alert_to, f"{req['name']} approved their theme song", body) + except Exception as e: + flash(f'Approval saved, but operator alert failed: {e}', 'warning') + return redirect(url_for('play', token=token)) + + flash('Thanks! Please return to the booth to finalize payment.', 'success') + return redirect(url_for('play', token=token)) + +@app.route('/play//revise', methods=['POST']) +def revise(token): + req = get_request_by_token(token) + if not req: + abort(404) + note = request.form.get('revision_note', '').strip() + update_request(req['id'], revision_note=note, status='songs_uploaded') + + alert_to = current_app.config['ADMIN_ALERT_EMAIL'] or current_app.config['SMTP_FROM'] + if alert_to and note: + admin_link = f"{current_app.config['PUBLIC_BASE_URL']}/admin/request/{req['id']}" + body = f"{req['name']} ({req['email']}) requested changes for request #{req['id']}.\n\nNote:\n{note}\n\nOpen admin: {admin_link}" + try: + send_email(alert_to, f"{req['name']} requested changes", body) + except Exception as e: + flash(f'Revision saved, but operator alert failed: {e}', 'warning') + return redirect(url_for('play', token=token)) + + flash('Your feedback has been sent. We will regenerate and update you.', 'success') + return redirect(url_for('play', token=token)) + +@app.route('/audio//.mp3') +def audio(token, version): + req = get_request_by_token(token) + if not req: + abort(404) + if version not in ('a', 'b'): + abort(404) + field = f'song_{version}_path' + path = req.get(field) + if not path or not Path(path).exists(): + abort(404) + return send_from_directory(Path(path).parent, Path(path).name) + +# ---------------- admin ---------------- + +@app.route('/admin/login', methods=['GET', 'POST']) +def admin_login(): + if is_admin(): + return redirect(url_for('admin_dashboard')) + if request.method == 'POST': + pw_hash = current_app.config['ADMIN_PASSWORD_HASH'] + if not pw_hash: + flash('Admin password is not configured.', 'error') + elif check_password_hash(pw_hash, request.form.get('password', '')): + session['admin'] = True + return redirect(url_for('admin_dashboard')) + else: + flash('Invalid password.', 'error') + return render_template('admin/login.html') + +@app.route('/admin/logout') +def admin_logout(): + session.pop('admin', None) + return redirect(url_for('admin_login')) + +@app.route('/admin') +def admin_dashboard(): + redir = require_admin() + if redir: + return redir + status_filter = request.args.get('status') + requests = list_requests(status_filter) + return render_template('admin/dashboard.html', requests=requests, statuses=STATUS_LABELS, current_status=status_filter) + +@app.route('/admin/request/', methods=['GET', 'POST']) +def admin_request(rid): + redir = require_admin() + if redir: + return redir + req = get_request_by_id(rid) + if not req: + abort(404) + + if request.method == 'POST': + action = request.form.get('action') + + if action == 'save_prompt': + update_request(rid, + suno_style=request.form.get('suno_style', '').strip(), + suno_lyrics=request.form.get('suno_lyrics', '').strip(), + status='prompt_ready' + ) + flash('Prompt saved.', 'success') + + elif action == 'upload_songs': + a_path = save_upload(rid, request.files.get('song_a'), 'a') + b_path = save_upload(rid, request.files.get('song_b'), 'b') + fields = {} + if a_path: + fields['song_a_path'] = a_path + if b_path: + fields['song_b_path'] = b_path + if fields: + fields['status'] = 'songs_uploaded' + update_request(rid, **fields) + flash('Songs uploaded.', 'success') + + elif action == 'notify_customer': + if not (req['song_a_path'] and req['song_b_path']): + flash('Both songs must be uploaded first.', 'error') + else: + player_link = f"{current_app.config['PUBLIC_BASE_URL']}/play/{req['player_token']}" + body = f"Hi {req['name']},\n\nYour custom theme song has been created. Listen to both versions and let us know which one you want:\n\n{player_link}\n\n- Version A\n- Version B\n- Or both versions\n\nOnce you make your choice, we'll send you to the booth to finalize payment and deliver your files.\n\nThanks for stopping by!\n\n— {current_app.config['BOOTH_NAME']}" + try: + send_email(req['email'], 'Your custom theme song is ready — listen and pick your version', body) + update_request(rid, preview_sent_at=now_utc(), status='songs_uploaded') + flash('Preview email sent.', 'success') + except Exception as e: + flash(f'Failed to send preview email: {e}', 'error') + + elif action == 'mark_paid_deliver': + if req['customer_approved'] == 'none': + flash('Customer has not approved a version yet.', 'error') + else: + payment_ref = request.form.get('square_payment_ref', '').strip() + if not payment_ref: + flash('Square payment reference is required.', 'error') + return redirect(url_for('admin_request', rid=rid)) + + attachments = [] + if req['customer_approved'] in ('a', 'both') and req['song_a_path']: + attachments.append((req['song_a_path'], 'song_a.mp3')) + if req['customer_approved'] in ('b', 'both') and req['song_b_path']: + attachments.append((req['song_b_path'], 'song_b.mp3')) + + player_link = f"{current_app.config['PUBLIC_BASE_URL']}/play/{req['player_token']}" + body = f"Hi {req['name']},\n\nThanks for your payment! Your approved song is attached to this email.\n\nIf you selected both versions, you'll find two MP3 files.\n\nYou can also keep streaming them here: {player_link}\n\nEnjoy!\n\n— {current_app.config['BOOTH_NAME']}" + try: + send_email(req['email'], 'Your theme song files are here!', body, attachments=attachments) + update_request(rid, square_payment_ref=payment_ref, delivery_sent_at=now_utc(), status='delivered') + flash('Delivery email sent with MP3 attachments.', 'success') + except Exception as e: + flash(f'Failed to send delivery email: {e}', 'error') + + return redirect(url_for('admin_request', rid=rid)) + + return render_template('admin/request.html', req=req, statuses=STATUS_LABELS) + +# ---------------- init ---------------- + +@app.cli.command('init-db') +def init_db_command(): + init_db() + print('Database initialized.') + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0') diff --git a/config.py b/config.py new file mode 100644 index 0000000..efedc68 --- /dev/null +++ b/config.py @@ -0,0 +1,26 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +class Config: + SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'dev-secret-change-me') + DATABASE = os.environ.get('DATABASE', '/app/data/booth.db') + UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/app/uploads') + ALLOWED_EXTENSIONS = {'mp3'} + + SMTP_HOST = os.environ.get('SMTP_HOST', 'mailroot8.namespro.ca') + SMTP_PORT = int(os.environ.get('SMTP_PORT', '465')) + SMTP_USER = os.environ.get('SMTP_USER', 'ai@hallsworth.ca') + SMTP_PASS = os.environ.get('SMTP_PASS', '') + SMTP_FROM = os.environ.get('SMTP_FROM', 'ai@hallsworth.ca') + + ADMIN_PASSWORD_HASH = os.environ.get('ADMIN_PASSWORD_HASH', '') + ADMIN_ALERT_EMAIL = os.environ.get('ADMIN_ALERT_EMAIL', '') + + PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://127.0.0.1:5000') + BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs') + + # Price settings (informational, for receipt page) + PRICE_PER_VERSION = float(os.environ.get('PRICE_PER_VERSION', '10.00')) + CURRENCY = os.environ.get('CURRENCY', 'CAD') diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d4c6ed3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + booth: + build: . + container_name: theme-song-booth + restart: unless-stopped + env_file: .env + ports: + - "127.0.0.1:8000:8000" + volumes: + - /mnt/user/appdata/theme-song-booth/data:/app/data + - /mnt/user/appdata/theme-song-booth/uploads:/app/uploads diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..c6f8e27 --- /dev/null +++ b/init_db.py @@ -0,0 +1,12 @@ +import os +import sys + +# Ensure project root is importable +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app import app +from models import init_db + +with app.app_context(): + init_db() + print(f"Database initialized at {app.config['DATABASE']}") diff --git a/models.py b/models.py new file mode 100644 index 0000000..996a9a4 --- /dev/null +++ b/models.py @@ -0,0 +1,94 @@ +import sqlite3 +import secrets +from datetime import datetime, timezone +from flask import current_app, g + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + name TEXT NOT NULL, + email TEXT NOT NULL, + hobbies TEXT, + notable_facts TEXT, + style_genre TEXT, + extra_requests TEXT, + status TEXT DEFAULT 'pending', + suno_style TEXT, + suno_lyrics TEXT, + song_a_path TEXT, + song_b_path TEXT, + customer_approved TEXT DEFAULT 'none', + approval_notified_at TIMESTAMP, + preview_sent_at TIMESTAMP, + delivery_sent_at TIMESTAMP, + square_payment_ref TEXT, + admin_alert_email TEXT, + player_token TEXT NOT NULL UNIQUE, + revision_note TEXT +); + +CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status); +CREATE INDEX IF NOT EXISTS idx_requests_token ON requests(player_token); +""" + +def get_db(): + if 'db' not in g: + g.db = sqlite3.connect(current_app.config['DATABASE']) + g.db.row_factory = sqlite3.Row + return g.db + +def close_db(e=None): + db = g.pop('db', None) + if db is not None: + db.close() + +def init_db(): + db = sqlite3.connect(current_app.config['DATABASE']) + db.executescript(SCHEMA) + db.commit() + db.close() + +def new_token(): + return secrets.token_urlsafe(32) + +def now_utc(): + return datetime.now(timezone.utc).isoformat() + +def create_request(name, email, hobbies, notable_facts, style_genre, extra_requests): + db = get_db() + cur = db.execute( + """INSERT INTO requests + (name, email, hobbies, notable_facts, style_genre, extra_requests, player_token) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (name, email, hobbies, notable_facts, style_genre, extra_requests, new_token()) + ) + db.commit() + return cur.lastrowid + +def get_request_by_id(request_id): + db = get_db() + row = db.execute('SELECT * FROM requests WHERE id = ?', (request_id,)).fetchone() + return dict(row) if row else None + +def get_request_by_token(token): + db = get_db() + row = db.execute('SELECT * FROM requests WHERE player_token = ?', (token,)).fetchone() + return dict(row) if row else None + +def list_requests(status=None): + db = get_db() + if status: + rows = db.execute('SELECT * FROM requests WHERE status = ? ORDER BY created_at DESC', (status,)).fetchall() + else: + rows = db.execute('SELECT * FROM requests ORDER BY created_at DESC').fetchall() + return [dict(r) for r in rows] + +def update_request(request_id, **fields): + if not fields: + return + db = get_db() + cols = ', '.join(f'{k} = ?' for k in fields) + vals = list(fields.values()) + [request_id] + db.execute(f'UPDATE requests SET {cols} WHERE id = ?', vals) + db.commit() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..99b9283 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask +gunicorn +python-dotenv +werkzeug diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..0ba6430 --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,78 @@ + + + + + +Admin Dashboard + + + +
+Log out +

Theme Song Booth — Admin Dashboard

+ +{% with messages = get_flashed_messages(with_categories=true) %} +{% for category, message in messages %} +
{{ message }}
+{% endfor %} +{% endwith %} + +
+All +{% for key,label in statuses.items() %} +{{ label }} +{% endfor %} +
+ + + + + + + + + + + + + + +{% for r in requests %} + + + + + + + + + +{% endfor %} +{% if not requests %} + +{% endif %} + +
IDNameEmailGenreStatusApprovedActions
#{{ r.id }}{{ r.name }}{{ r.email }}{{ r.style_genre or '-' }}{{ statuses[r.status] }}{% if r.customer_approved != 'none' %}{{ r.customer_approved.upper() }}{% else %}-{% endif %}Open
No requests found.
+
+ + diff --git a/templates/admin/login.html b/templates/admin/login.html new file mode 100644 index 0000000..ee29e81 --- /dev/null +++ b/templates/admin/login.html @@ -0,0 +1,30 @@ + + + + + +Admin Login + + + +
+

Booth Admin

+ + +{% with messages = get_flashed_messages() %} +{% if messages %} +
{{ messages[0] }}
+{% endif %} +{% endwith %} + +
+ + diff --git a/templates/admin/request.html b/templates/admin/request.html new file mode 100644 index 0000000..dec80d3 --- /dev/null +++ b/templates/admin/request.html @@ -0,0 +1,125 @@ + + + + + +Request #{{ req.id }} — Admin + + + +
+

← Dashboard

+

Request #{{ req.id }} — {{ req.name }}

+ +{% with messages = get_flashed_messages(with_categories=true) %} +{% for category, message in messages %} +
{{ message }}
+{% endfor %} +{% endwith %} + +
+

Customer Info

+
+
Email: {{ req.email }}
+
Status: {{ statuses[req.status] }}
+
+

Hobbies:
{{ req.hobbies or '-' }}

+

Notable facts:
{{ req.notable_facts or '-' }}

+

Style / genre:
{{ req.style_genre or '-' }}

+

Extra requests:
{{ req.extra_requests or '-' }}

+
+ +
+

1. Generate Suno Prompt

+ +

Paste the result from Hermes into the fields below, then click Save.

+
+ + + + + +
+ +
+
+
+ +
+

2. Upload Songs

+
+ + + + + +
+ +
+
+ +
+

3. Notify Customer

+

Both songs must be uploaded first.

+ + + + +
+ +
+

4. Payment & Delivery

+

Customer approved: +{% if req.customer_approved == 'none' %} +Nothing yet +{% else %} +{{ req.customer_approved.upper() }} +{% endif %} +

+
+ + + +
+ +
+
+
+ + +
+ + diff --git a/templates/player.html b/templates/player.html new file mode 100644 index 0000000..0a1691e --- /dev/null +++ b/templates/player.html @@ -0,0 +1,68 @@ + + + + + +Your Theme Song + + + +
+

🎧 Your Custom Theme Song

+

Hi {{ req.name }}! Listen to both versions and pick the one you want.

+ +
+

Version A

+ +
+ +
+

Version B

+ +
+ +{% if req.status in ['songs_uploaded','awaiting_payment','paid','delivered'] %} +
+ +
+ + + +
+
+ +
+ + +
+ +
+
+{% endif %} + +{% if req.status == 'awaiting_payment' %} +
Thanks for choosing {{ req.customer_approved.upper() }}! Please head to the booth to finalize payment and collect your files.
+{% endif %} + +{% if req.status == 'delivered' %} +
Delivered! ✅ Check your email for the MP3 attachment(s).
+{% endif %} + +
+ + diff --git a/templates/request.html b/templates/request.html new file mode 100644 index 0000000..f8ef427 --- /dev/null +++ b/templates/request.html @@ -0,0 +1,47 @@ + + + + + +Request Your Theme Song + + + +
+

🎵 Get Your Custom Theme Song

+

Tell us about yourself and we'll write a one-of-a-kind song for you.

+
+ + + + + + + + + + + + + + + + + + + +
+

Your info is only used to create and deliver your song.

+
+ + diff --git a/templates/thanks.html b/templates/thanks.html new file mode 100644 index 0000000..03c364a --- /dev/null +++ b/templates/thanks.html @@ -0,0 +1,22 @@ + + + + + +Request Received + + + +
+

✅ Request Received

+

Thanks, {{ req.name }}! We'll craft your song and email you a link when it's ready.

+

Your request number: #{{ req.id }}

+

Bring this number to the booth if you want to check on progress.

+
+ +