Some rows (schema drift, cancelled requests, manual status edits) have customer_approved = NULL instead of the default 'none'. Templates called .upper() on it blindly, causing a Jinja2 UndefinedError / 500 when the customer player or admin pages loaded. Replace all .upper() calls with (value or 'none').upper() and add truthy checks before rendering the approved choice in status.html and dashboard.html.
432 lines
17 KiB
HTML
432 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Request #{{ req.id }} — Admin</title>
|
|
<style>
|
|
/*
|
|
Operator detail page for a single request.
|
|
Two-column layout:
|
|
left -> customer info + internal operator notes
|
|
right -> Suno prompt, song upload, customer notification,
|
|
payment reference, and file delivery.
|
|
*/
|
|
*{box-sizing:border-box;}
|
|
body{
|
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
|
background:#111827;
|
|
color:#f3f4f6;
|
|
margin:0;
|
|
padding:1rem;
|
|
line-height:1.5;
|
|
}
|
|
.container{max-width:1200px;margin:0 auto;}
|
|
h1,h2{color:#60a5fa;margin-top:0;}
|
|
a{color:#93c5fd;}
|
|
.section{
|
|
background:#1f2937;
|
|
padding:1rem;
|
|
border-radius:.5rem;
|
|
margin-bottom:1rem;
|
|
}
|
|
label{display:block;margin-top:.8rem;font-weight:600;}
|
|
input,textarea{
|
|
width:100%;
|
|
padding:.6rem;
|
|
border-radius:.5rem;
|
|
border:1px solid #374151;
|
|
background:#111827;
|
|
color:#f3f4f6;
|
|
box-sizing:border-box;
|
|
font:inherit;
|
|
}
|
|
textarea{min-height:100px;}
|
|
button{
|
|
padding:.7rem 1rem;
|
|
border:none;
|
|
border-radius:.5rem;
|
|
background:#3b82f6;
|
|
color:#fff;
|
|
font-weight:700;
|
|
cursor:pointer;
|
|
margin-top:.5rem;
|
|
}
|
|
button.secondary{background:#4b5563;}
|
|
button.danger{background:#dc2626;}
|
|
button.success{background:#10b981;}
|
|
button:disabled{background:#374151;color:#9ca3af;cursor:not-allowed;}
|
|
.actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.5rem;}
|
|
.flash{
|
|
padding:.8rem;
|
|
background:#064e3b;
|
|
border-radius:.5rem;
|
|
margin-bottom:1rem;
|
|
}
|
|
.flash.error{background:#450a0a;}
|
|
.status-badge{
|
|
display:inline-block;
|
|
padding:.25rem .6rem;
|
|
border-radius:9999px;
|
|
font-size:.8rem;
|
|
font-weight:600;
|
|
background:#374151;
|
|
color:#f3f4f6;
|
|
margin-right:.3rem;
|
|
}
|
|
.status-badge.ok{background:#10b981;color:#000;}
|
|
.status-badge.missing{background:#ef4444;color:#000;}
|
|
.status-badge.sent{background:#60a5fa;color:#000;}
|
|
.copy-hint{font-size:.85rem;color:#9ca3af;margin-top:.3rem;}
|
|
.revision-note{
|
|
margin-top:1rem;
|
|
background:#450a0a;
|
|
border:1px solid #7f1d1d;
|
|
border-radius:.5rem;
|
|
padding:1rem;
|
|
}
|
|
.revision-note h3{
|
|
margin-top:0;
|
|
color:#f87171;
|
|
}
|
|
.file-select{
|
|
background:#111827;
|
|
padding:.6rem;
|
|
border-radius:.5rem;
|
|
margin:.3rem 0;
|
|
}
|
|
.file-select input{
|
|
width:auto;
|
|
margin-right:.5rem;
|
|
}
|
|
.file-select label{
|
|
display:inline;
|
|
margin:0;
|
|
font-weight:400;
|
|
}
|
|
.old-rev{
|
|
font-size:.85rem;
|
|
color:#9ca3af;
|
|
margin-left:1.8rem;
|
|
}
|
|
.approved-box{font-size:1.2rem;font-weight:bold;color:#fbbf24;}
|
|
.status-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;}
|
|
.history-list{
|
|
margin:0;
|
|
padding:0;
|
|
list-style:none;
|
|
}
|
|
.history-list li{
|
|
border-bottom:1px solid #374151;
|
|
padding:.6rem 0;
|
|
}
|
|
.history-list li:last-child{border-bottom:none;}
|
|
.history-list .meta{
|
|
font-size:.8rem;
|
|
color:#9ca3af;
|
|
}
|
|
.history-list .note{
|
|
margin:.3rem 0 0 0;
|
|
}
|
|
|
|
/* Two-column layout */
|
|
.two-col{
|
|
display:grid;
|
|
grid-template-columns:1fr 1fr;
|
|
gap:1rem;
|
|
}
|
|
@media(max-width:900px){
|
|
.two-col{grid-template-columns:1fr;}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<p><a href="{{ url_for('admin_dashboard') }}">← Dashboard</a></p>
|
|
<h1>Request #{{ req.id }} — {{ req.name }}</h1>
|
|
<div class="status-row">
|
|
<p style="margin:0"><strong>Status:</strong> <span class="status-badge">{{ statuses[req.status] }}</span></p>
|
|
{% if req.status != 'cancelled' %}
|
|
<form method="POST" onsubmit="return confirm('Are you sure you want to cancel request #{{ req.id }}?')" style="margin:0">
|
|
<input type="hidden" name="action" value="cancel_request">
|
|
<button type="submit" class="danger" style="margin:0">Cancel Request</button>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
{% for category, message in messages %}
|
|
<div class="flash {{ category }}">{{ message }}</div>
|
|
{% endfor %}
|
|
{% endwith %}
|
|
|
|
<div class="two-col">
|
|
<!-- LEFT COLUMN -->
|
|
<div class="left">
|
|
<!-- Customer Info -->
|
|
<div class="section">
|
|
<h2>Customer Info</h2>
|
|
<p class="copy-hint">Operators can correct customer details here. Click Save when done.</p>
|
|
<form method="POST">
|
|
<input type="hidden" name="action" value="update_customer_info">
|
|
|
|
<label for="customer_email">Email address</label>
|
|
<input type="email" id="customer_email" name="email" value="{{ req.email }}" required>
|
|
|
|
<label for="customer_name">Name / persona</label>
|
|
<input type="text" id="customer_name" name="name" value="{{ req.name }}" required>
|
|
|
|
<label for="customer_hobbies">Hobbies & interests</label>
|
|
<textarea id="customer_hobbies" name="hobbies" rows="3">{{ req.hobbies or '' }}</textarea>
|
|
|
|
<label for="customer_notable_facts">Notable things</label>
|
|
<textarea id="customer_notable_facts" name="notable_facts" rows="3">{{ req.notable_facts or '' }}</textarea>
|
|
|
|
<label for="customer_style_genre">Style / genre / mood</label>
|
|
<input type="text" id="customer_style_genre" name="style_genre" value="{{ req.style_genre or '' }}">
|
|
|
|
<label for="customer_vocal_gender">Preferred singer voice / gender</label>
|
|
<input type="text" id="customer_vocal_gender" name="vocal_gender" value="{{ req.vocal_gender or '' }}">
|
|
|
|
<label for="customer_extra_requests">Anything else</label>
|
|
<textarea id="customer_extra_requests" name="extra_requests" rows="3">{{ req.extra_requests or '' }}</textarea>
|
|
|
|
<div class="actions">
|
|
<button type="submit" class="secondary">Save Customer Info</button>
|
|
</div>
|
|
</form>
|
|
|
|
<p style="margin-top:1rem"><strong>Request #:</strong> {{ req.id }}</p>
|
|
|
|
{% if req.revision_note %}
|
|
<div class="revision-note">
|
|
<h3>📝 Revisions Requested{% if req.revision_count %}<span style="float:right">Revision #{{ req.revision_count }}</span>{% endif %}</h3>
|
|
<p>{{ req.revision_note }}</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Revision History -->
|
|
<div class="section">
|
|
<h2>Revision History</h2>
|
|
{% if revision_history %}
|
|
<ul class="history-list">
|
|
{% for h in revision_history %}
|
|
<li>
|
|
<div class="meta">Revision #{{ h.revision_count }} — {{ h.created_at }}
|
|
{% if h.old_song_a_path or h.old_song_b_path %}
|
|
<br>Archived:
|
|
{% if h.old_song_a_path %}<br>A: {{ basename(h.old_song_a_path) }}{% endif %}
|
|
{% if h.old_song_b_path %}<br>B: {{ basename(h.old_song_b_path) }}{% endif %}
|
|
{% endif %}
|
|
</div>
|
|
<p class="note">{{ h.note or 'No note provided.' }}</p>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% else %}
|
|
<p class="copy-hint">No revisions recorded yet.</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Operator Notes -->
|
|
<div class="section">
|
|
<h2>Operator Notes</h2>
|
|
<p class="copy-hint">Internal notes for the booth team. Customers cannot see this.</p>
|
|
<form method="POST">
|
|
<input type="hidden" name="action" value="save_operator_notes">
|
|
<label for="operator_notes">Notes</label>
|
|
<textarea id="operator_notes" name="operator_notes" rows="4" placeholder="e.g. paid cash, VIP friend, follow up...">{{ req.operator_notes or '' }}</textarea>
|
|
<div class="actions">
|
|
<button type="submit" class="secondary">Save Notes</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Stems / Extras Link -->
|
|
<div class="section">
|
|
<h2>Stems / Extras Link</h2>
|
|
<p class="copy-hint">Paste a self-hosted file share link (e.g. from Pingvin Share). It will appear on the customer player page once delivered.</p>
|
|
<form method="POST">
|
|
<input type="hidden" name="action" value="save_stems_link">
|
|
<label for="stems_link">Share link</label>
|
|
<input type="url" id="stems_link" name="stems_link" value="{{ req.stems_link or '' }}" placeholder="https://files.dionysismedia.ca/share/...">
|
|
<div class="actions">
|
|
<button type="submit" class="secondary">Save Link</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- RIGHT COLUMN -->
|
|
<div class="right">
|
|
<!-- Generate Suno Prompt -->
|
|
<div class="section">
|
|
<h2>1. Generate & Save Suno Prompt</h2>
|
|
<button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button>
|
|
<p class="copy-hint">Paste Hermes' Title, Style, and Lyrics directly into the fields below, then save.</p>
|
|
|
|
<form method="POST">
|
|
<input type="hidden" name="action" value="save_prompt">
|
|
<label for="suno_title">Title</label>
|
|
<input type="text" id="suno_title" name="suno_title" value="{{ req.suno_title or '' }}">
|
|
<label for="suno_style">Style</label>
|
|
<textarea id="suno_style" name="suno_style">{{ req.suno_style or '' }}</textarea>
|
|
<label for="suno_lyrics">Lyrics (with metatags)</label>
|
|
<textarea id="suno_lyrics" name="suno_lyrics" rows="10">{{ req.suno_lyrics or '' }}</textarea>
|
|
<div class="actions">
|
|
<button type="button" class="secondary" onclick="copyToClipboard('suno_lyrics', 'Lyrics')">Copy Lyrics</button>
|
|
<button type="button" class="secondary" onclick="copyToClipboard('suno_style', 'Style')">Copy Style</button>
|
|
<button type="button" class="secondary" onclick="copyToClipboard('suno_title', 'Title')">Copy Title</button>
|
|
<button type="submit">Save Prompt</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Upload Songs -->
|
|
<div class="section">
|
|
<h2>2. Upload Songs</h2>
|
|
<p>
|
|
Version A:
|
|
{% if req.song_a_path and file_exists(req.song_a_path) %}
|
|
<span class="status-badge ok">✅ Uploaded</span> {{ basename(req.song_a_path) }}
|
|
{% else %}
|
|
<span class="status-badge missing">❌ Not uploaded</span>
|
|
{% endif %}
|
|
</p>
|
|
<p>
|
|
Version B:
|
|
{% if req.song_b_path and file_exists(req.song_b_path) %}
|
|
<span class="status-badge ok">✅ Uploaded</span> {{ basename(req.song_b_path) }}
|
|
{% else %}
|
|
<span class="status-badge missing">❌ Not uploaded</span>
|
|
{% endif %}
|
|
</p>
|
|
|
|
<form method="POST" enctype="multipart/form-data">
|
|
<input type="hidden" name="action" value="upload_songs">
|
|
<label for="song_a">Version A MP3</label>
|
|
<input type="file" id="song_a" name="song_a" accept="audio/mpeg,.mp3">
|
|
<label for="song_b">Version B MP3</label>
|
|
<input type="file" id="song_b" name="song_b" accept="audio/mpeg,.mp3">
|
|
<div class="actions">
|
|
<button type="submit">Upload Songs</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Notify Customer -->
|
|
<div class="section">
|
|
<h2>3. Notify Customer</h2>
|
|
<p>
|
|
Preview email:
|
|
{% if req.preview_sent_at %}
|
|
<span class="status-badge sent">✅ Sent</span> {{ req.preview_sent_at }}
|
|
{% else %}
|
|
<span class="status-badge missing">❌ Not sent yet</span>
|
|
{% endif %}
|
|
</p>
|
|
<p>Both songs must be uploaded first.</p>
|
|
<form method="POST">
|
|
<input type="hidden" name="action" value="notify_customer">
|
|
<button type="submit" {% if not (req.song_a_path and req.song_b_path) %}disabled{% endif %}>Send Preview Link</button>
|
|
</form>
|
|
|
|
{% if req.song_a_path and req.song_b_path %}
|
|
<p style="margin-top:.8rem">
|
|
Operator preview link:
|
|
<a href="{{ url_for('play', token=req.player_token) }}" target="_blank" rel="noopener noreferrer">Open customer player in new tab →</a>
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Payment & Delivery -->
|
|
<div class="section">
|
|
<h2>4. Payment & Delivery</h2>
|
|
<p>
|
|
Customer approved:
|
|
{% if req.customer_approved == 'none' %}
|
|
<span class="approved-box">Nothing yet</span>
|
|
{% else %}
|
|
<span class="approved-box">{{ (req.customer_approved or 'none').upper() }}</span>
|
|
{% endif %}
|
|
</p>
|
|
<p>
|
|
Delivery email:
|
|
{% if req.delivery_sent_at %}
|
|
<span class="status-badge sent">✅ Sent</span> {{ req.delivery_sent_at }}
|
|
{% else %}
|
|
<span class="status-badge missing">❌ Not sent yet</span>
|
|
{% endif %}
|
|
</p>
|
|
{% if req.square_payment_ref %}
|
|
<p><strong>Square Payment Reference:</strong> {{ req.square_payment_ref }}</p>
|
|
{% endif %}
|
|
<form method="POST">
|
|
<input type="hidden" name="action" value="mark_paid_deliver">
|
|
|
|
<p style="color:#93c5fd;font-weight:600">Select files to deliver:</p>
|
|
{% set all_files = [] %}
|
|
{% if req.song_a_path and file_exists(req.song_a_path) %}
|
|
{% set _ = all_files.append(req.song_a_path) %}
|
|
{% endif %}
|
|
{% if req.song_b_path and file_exists(req.song_b_path) %}
|
|
{% set _ = all_files.append(req.song_b_path) %}
|
|
{% endif %}
|
|
|
|
{% set files_to_show = all_files + extra_files %}
|
|
{% for fpath in files_to_show %}
|
|
<div class="file-select">
|
|
<input type="checkbox" id="file_{{ loop.index }}" name="deliver_file" value="{{ fpath }}"
|
|
{% if fpath in all_files %}checked{% endif %}
|
|
{% if req.customer_approved == 'none' %}disabled{% endif %}>
|
|
<label for="file_{{ loop.index }}"{% if req.customer_approved == 'none' %} style="opacity:.6"{% endif %}>{{ basename(fpath) }}</label>
|
|
{% if not (fpath == req.song_a_path or fpath == req.song_b_path) %}
|
|
<div class="old-rev">archived / revision file</div>
|
|
{% endif %}
|
|
</div>
|
|
{% else %}
|
|
<p>No files available. Upload songs first.</p>
|
|
{% endfor %}
|
|
|
|
<label for="square_payment_ref">Square Payment Reference</label>
|
|
<input type="text" id="square_payment_ref" name="square_payment_ref" placeholder="e.g. sq0idp-... or receipt number"
|
|
{% if req.customer_approved == 'none' %}disabled{% endif %}>
|
|
<div class="actions">
|
|
<button type="submit" class="success" {% if req.customer_approved == 'none' %}disabled{% endif %}>Mark Paid & Deliver</button>
|
|
</div>
|
|
{% if req.customer_approved == 'none' %}
|
|
<p style="margin-top:.5rem;color:#f87171;font-weight:600">⚠️ Customer must approve a version before you can mark paid or deliver.</p>
|
|
{% endif %}
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function copyPromptForHermes() {
|
|
const callbackUrl = {{ callback_url | tojson }};
|
|
const data = {
|
|
request_id: {{ req.id | tojson }},
|
|
email: {{ req.email | tojson }},
|
|
name: {{ req.name | tojson }},
|
|
hobbies: {{ req.hobbies | tojson }},
|
|
notable_facts: {{ req.notable_facts | tojson }},
|
|
style_genre: {{ req.style_genre | tojson }},
|
|
vocal_gender: {{ req.vocal_gender | tojson }},
|
|
extra_requests: {{ req.extra_requests | tojson }}
|
|
};
|
|
const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nGenerate a Suno Custom Mode prompt for this customer and POST it back to the Callback URL as JSON.\\n\\nCustomer data:\\nName: ${data.name}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${data.style_genre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\n\\nExpected JSON response format:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`;
|
|
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste into Hermes. Hermes will POST the generated prompt back to the Callback URL."));
|
|
}
|
|
|
|
function copyToClipboard(elementId, label) {
|
|
const el = document.getElementById(elementId);
|
|
el.select();
|
|
el.setSelectionRange(0, 99999);
|
|
navigator.clipboard.writeText(el.value).then(() => alert(label + " copied!"));
|
|
}
|
|
</script>
|
|
</div>
|
|
</body>
|
|
</html>
|