Files
shopify-ai-backup/chat/public/admin-feature-requests.html
2026-02-20 13:54:51 +00:00

540 lines
18 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Feature Requests - Admin Panel</title>
<link rel="stylesheet" href="/styles.css" />
<style>
body[data-page="feature-requests"] .admin-grid {
grid-template-columns: none !important;
gap: 12px !important;
}
body[data-page="feature-requests"] .admin-grid .admin-card {
width: 100% !important;
}
.fr-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
transition: border-color 0.2s;
}
.fr-card:hover {
border-color: rgba(0, 66, 37, 0.3);
}
.fr-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.fr-meta {
flex: 1;
}
.fr-title {
font-weight: 700;
font-size: 16px;
color: var(--text);
margin-bottom: 4px;
}
.fr-email {
font-size: 13px;
color: var(--muted);
}
.fr-votes {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: var(--accent);
background: rgba(0, 128, 96, 0.1);
padding: 4px 10px;
border-radius: 999px;
font-size: 13px;
}
.fr-description {
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
padding: 12px;
font-size: 14px;
line-height: 1.6;
color: var(--text);
white-space: pre-wrap;
margin-bottom: 12px;
}
.fr-status {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.fr-status.pending {
background: rgba(108, 117, 125, 0.1);
color: #6c757d;
}
.fr-status.planned {
background: rgba(0, 123, 255, 0.1);
color: #007bff;
}
.fr-status.in-progress {
background: rgba(255, 193, 7, 0.1);
color: #ffc107;
}
.fr-status.completed {
background: rgba(40, 167, 69, 0.1);
color: #28a745;
}
.fr-status.declined {
background: rgba(220, 53, 69, 0.1);
color: #dc3545;
}
.fr-date {
font-size: 12px;
color: var(--muted);
}
.fr-actions {
display: flex;
gap: 8px;
margin-top: 12px;
justify-content: flex-end;
flex-wrap: wrap;
}
.fr-admin-reply {
background: rgba(0, 128, 96, 0.05);
border: 1px solid rgba(0, 128, 96, 0.2);
border-radius: 8px;
padding: 12px;
margin-top: 12px;
}
.fr-admin-reply-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 6px;
}
.fr-admin-reply-text {
font-size: 14px;
line-height: 1.5;
color: var(--text);
white-space: pre-wrap;
}
.reply-form {
margin-top: 12px;
display: none;
}
.reply-form.show {
display: block;
}
.reply-form textarea {
width: 100%;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: white;
font-size: 14px;
min-height: 80px;
resize: vertical;
margin-bottom: 8px;
}
.reply-form textarea:focus {
outline: none;
border-color: var(--accent);
}
.status-select {
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: white;
font-size: 13px;
cursor: pointer;
}
.status-select:focus {
outline: none;
border-color: var(--accent);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--muted);
}
.empty-state svg {
width: 64px;
height: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.search-box {
margin-bottom: 16px;
}
.search-box input {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--border);
border-radius: 10px;
background: white;
font-size: 14px;
}
.search-box input:focus {
outline: none;
border-color: var(--accent);
}
.filter-box {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-box select {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: white;
font-size: 13px;
cursor: pointer;
}
.filter-box select:focus {
outline: none;
border-color: var(--accent);
}
</style>
<script src="/posthog.js"></script>
</head>
<body data-page="feature-requests">
<div class="sidebar-overlay"></div>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">A</div>
<div>
<div class="brand-title">Admin</div>
<div class="brand-sub">Site management</div>
</div>
<button id="close-sidebar" class="ghost" style="margin-left: auto; display: none;">&times;</button>
</div>
<div class="sidebar-section">
<div class="section-heading">Navigation</div>
<a class="ghost" href="/admin/build">Build models</a>
<a class="ghost" href="/admin/plan">Plan models</a>
<a class="ghost" href="/admin/plans">Plans</a>
<a class="ghost" href="/admin/accounts">Accounts</a>
<a class="ghost" href="/admin/affiliates">Affiliates</a>
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
<a class="ghost" href="/admin/tracking">Tracking</a>
<a class="ghost" href="/admin/resources">Resources</a>
<a class="ghost" href="/admin/system-tests">System Tests</a>
<a class="ghost" href="/admin/external-testing">External Testing</a>
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
<a class="ghost active" href="/admin/feature-requests">Feature Requests</a>
<a class="ghost" href="/admin/login">Login</a>
</div>
</aside>
<main class="main">
<div class="admin-shell">
<div class="topbar">
<button id="menu-toggle">
<span></span><span></span><span></span>
</button>
<div>
<div class="pill">Admin</div>
<div class="title" style="margin-top: 6px;">Feature Requests</div>
<div class="crumb">Manage and respond to feature requests</div>
</div>
<div class="admin-actions">
<button id="admin-refresh" class="ghost">Refresh</button>
<button id="admin-logout" class="primary">Logout</button>
</div>
</div>
<div class="admin-grid">
<div class="admin-card" style="grid-column: span 2;">
<header>
<h3>All Feature Requests</h3>
<div class="pill" id="fr-count">0</div>
</header>
<div class="search-box">
<input type="text" id="search-input" placeholder="Search feature requests..." />
</div>
<div class="filter-box">
<select id="status-filter">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="planned">Planned</option>
<option value="in-progress">In Progress</option>
<option value="completed">Completed</option>
<option value="declined">Declined</option>
</select>
<select id="sort-filter">
<option value="votes">Most Voted</option>
<option value="newest">Newest First</option>
<option value="oldest">Oldest First</option>
</select>
</div>
<div id="fr-list">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>
</svg>
<p>No feature requests yet</p>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/admin.js"></script>
<script>
(async function() {
const frList = document.getElementById('fr-list');
const frCount = document.getElementById('fr-count');
const searchInput = document.getElementById('search-input');
const statusFilter = document.getElementById('status-filter');
const sortFilter = document.getElementById('sort-filter');
let featureRequests = [];
async function loadFeatureRequests() {
try {
const response = await fetch('/api/admin/feature-requests');
const data = await response.json();
if (data.featureRequests) {
featureRequests = data.featureRequests;
renderFeatureRequests(featureRequests);
}
} catch (error) {
console.error('Failed to load feature requests:', error);
frList.innerHTML = '<div class="empty-state"><p>Failed to load feature requests</p></div>';
}
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function getStatusLabel(status) {
const labels = {
'pending': 'Pending',
'planned': 'Planned',
'in-progress': 'In Progress',
'completed': 'Completed',
'declined': 'Declined'
};
return labels[status] || status;
}
function renderFeatureRequests(frs) {
frCount.textContent = frs.length;
if (frs.length === 0) {
frList.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>
</svg>
<p>No feature requests found</p>
</div>
`;
return;
}
frList.innerHTML = frs.map(fr => `
<div class="fr-card" data-id="${fr.id}">
<div class="fr-header">
<div class="fr-meta">
<div class="fr-title">${escapeHtml(fr.title)}</div>
<div class="fr-email">${escapeHtml(fr.authorEmail)}</div>
</div>
<div style="text-align: right;">
<div class="fr-votes">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
${fr.votes} votes
</div>
</div>
</div>
<div class="fr-description">${escapeHtml(fr.description)}</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<span class="fr-status ${fr.status}">${getStatusLabel(fr.status)}</span>
<span class="fr-date" style="margin-left: 8px;">${formatDate(fr.createdAt)}</span>
</div>
</div>
${fr.adminReply ? `
<div class="fr-admin-reply">
<div class="fr-admin-reply-label">Admin Reply</div>
<div class="fr-admin-reply-text">${escapeHtml(fr.adminReply)}</div>
</div>
` : ''}
<div class="reply-form" id="reply-form-${fr.id}">
<textarea id="reply-text-${fr.id}" placeholder="Enter your reply...">${escapeHtml(fr.adminReply || '')}</textarea>
<div style="display: flex; gap: 8px; justify-content: flex-end;">
<button class="ghost cancel-reply-btn" data-id="${fr.id}">Cancel</button>
<button class="primary save-reply-btn" data-id="${fr.id}">Save Reply</button>
</div>
</div>
<div class="fr-actions">
<select class="status-select status-change-btn" data-id="${fr.id}">
<option value="pending" ${fr.status === 'pending' ? 'selected' : ''}>Pending</option>
<option value="planned" ${fr.status === 'planned' ? 'selected' : ''}>Planned</option>
<option value="in-progress" ${fr.status === 'in-progress' ? 'selected' : ''}>In Progress</option>
<option value="completed" ${fr.status === 'completed' ? 'selected' : ''}>Completed</option>
<option value="declined" ${fr.status === 'declined' ? 'selected' : ''}>Declined</option>
</select>
<button class="ghost reply-btn" data-id="${fr.id}">${fr.adminReply ? 'Edit Reply' : 'Add Reply'}</button>
<button class="danger delete-btn" data-id="${fr.id}">Delete</button>
</div>
</div>
`).join('');
// Status change
document.querySelectorAll('.status-change-btn').forEach(select => {
select.addEventListener('change', async (e) => {
const id = e.target.dataset.id;
const status = e.target.value;
try {
const response = await fetch(`/api/admin/feature-requests/${id}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (response.ok) {
await loadFeatureRequests();
} else {
const data = await response.json();
alert(data.error || 'Failed to update status');
}
} catch (error) {
console.error('Failed to update status:', error);
alert('Failed to update status');
}
});
});
// Reply buttons
document.querySelectorAll('.reply-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.target.dataset.id;
const form = document.getElementById(`reply-form-${id}`);
form.classList.add('show');
});
});
// Cancel reply
document.querySelectorAll('.cancel-reply-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.target.dataset.id;
const form = document.getElementById(`reply-form-${id}`);
form.classList.remove('show');
});
});
// Save reply
document.querySelectorAll('.save-reply-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const id = e.target.dataset.id;
const textarea = document.getElementById(`reply-text-${id}`);
const reply = textarea.value.trim();
try {
const response = await fetch(`/api/admin/feature-requests/${id}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reply })
});
if (response.ok) {
await loadFeatureRequests();
} else {
const data = await response.json();
alert(data.error || 'Failed to save reply');
}
} catch (error) {
console.error('Failed to save reply:', error);
alert('Failed to save reply');
}
});
});
// Delete buttons
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const id = e.target.dataset.id;
if (confirm('Are you sure you want to delete this feature request? This action cannot be undone.')) {
try {
const response = await fetch(`/api/admin/feature-requests/${id}`, { method: 'DELETE' });
if (response.ok) {
await loadFeatureRequests();
} else {
const data = await response.json();
alert(data.error || 'Failed to delete');
}
} catch (error) {
console.error('Failed to delete feature request:', error);
alert('Failed to delete feature request');
}
}
});
});
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function filterAndSort() {
let filtered = [...featureRequests];
// Search filter
const query = searchInput.value.toLowerCase();
if (query) {
filtered = filtered.filter(fr =>
fr.title.toLowerCase().includes(query) ||
fr.description.toLowerCase().includes(query) ||
fr.authorEmail.toLowerCase().includes(query)
);
}
// Status filter
const status = statusFilter.value;
if (status) {
filtered = filtered.filter(fr => fr.status === status);
}
// Sort
const sort = sortFilter.value;
if (sort === 'votes') {
filtered.sort((a, b) => b.votes - a.votes || new Date(b.createdAt) - new Date(a.createdAt));
} else if (sort === 'newest') {
filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
} else if (sort === 'oldest') {
filtered.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
}
renderFeatureRequests(filtered);
}
searchInput.addEventListener('input', filterAndSort);
statusFilter.addEventListener('change', filterAndSort);
sortFilter.addEventListener('change', filterAndSort);
// Initial load
loadFeatureRequests();
})();
</script>
</body>
</html>