390 lines
14 KiB
HTML
390 lines
14 KiB
HTML
<!-- OnlyPrompt - Subscription tiers page: create and manage monthly creator tiers -->
|
|
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>OnlyPrompt - Subscription Tiers</title>
|
|
<link rel="stylesheet" href="../css/variables.css" />
|
|
<link rel="stylesheet" href="../css/base.css" />
|
|
<link rel="stylesheet" href="../css/sidebar.css" />
|
|
<link rel="stylesheet" href="../css/topbar.css" />
|
|
<link rel="stylesheet" href="../css/subscription-tiers.css" />
|
|
<script src="../js/profile-shared.js"></script>
|
|
<link
|
|
rel="stylesheet"
|
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
|
/>
|
|
</head>
|
|
<body>
|
|
<a class="skip-link" href="#main-content">Skip to main content</a>
|
|
<div class="layout">
|
|
<div id="sidebar-container"></div>
|
|
|
|
<div class="page-body">
|
|
<div id="topbar-container"></div>
|
|
|
|
<main class="tiers-main" id="main-content" tabindex="-1">
|
|
<header class="tiers-header">
|
|
<h1>Subscription Tiers</h1>
|
|
<p>Create monthly access levels for your paid prompts.</p>
|
|
</header>
|
|
|
|
<nav class="tiers-tabs" role="tablist" aria-label="Subscription tier sections">
|
|
<button type="button" class="tiers-tab active" data-tab="manage" id="manageTiersTab" role="tab" aria-selected="true" aria-controls="manage-tab-panel">
|
|
My Tiers
|
|
</button>
|
|
<button type="button" class="tiers-tab" data-tab="subscriptions" id="subscriptionsTiersTab" role="tab" aria-selected="false" aria-controls="subscriptions-tab-panel">
|
|
My Subscriptions
|
|
</button>
|
|
</nav>
|
|
|
|
<section class="tiers-layout" id="manage-tab-panel" role="tabpanel" aria-labelledby="manageTiersTab">
|
|
<article class="tier-panel">
|
|
<h2 id="tier-form-title">Create Tier</h2>
|
|
<form id="tier-form" class="tier-form">
|
|
<label>
|
|
Tier Name
|
|
<input
|
|
id="tier-name"
|
|
type="text"
|
|
placeholder="Supporter"
|
|
required
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
Level
|
|
<input id="tier-level" type="number" min="1" step="1" value="1" required />
|
|
</label>
|
|
|
|
<label>
|
|
Monthly Price
|
|
<input
|
|
id="tier-price"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
placeholder="4.99"
|
|
required
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
Description
|
|
<textarea
|
|
id="tier-description"
|
|
placeholder="Access to basic premium prompts."
|
|
></textarea>
|
|
</label>
|
|
|
|
<div class="tier-form-actions">
|
|
<button type="submit" class="tier-primary-btn" id="tier-submit-btn">
|
|
Save Tier
|
|
</button>
|
|
<button type="button" class="tier-secondary-btn" id="tier-reset-btn">
|
|
Clear
|
|
</button>
|
|
</div>
|
|
<p id="tier-status" role="status" aria-live="polite"></p>
|
|
</form>
|
|
</article>
|
|
|
|
<section>
|
|
<div class="tier-list-header">
|
|
<h2>Your Tiers</h2>
|
|
<p>Higher levels include access to prompts from lower levels.</p>
|
|
</div>
|
|
<div class="tiers-grid" id="tiers-grid" aria-live="polite">
|
|
<div class="tiers-empty">Loading tiers...</div>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
|
|
<section class="subscriptions-panel" id="subscriptions-tab-panel" role="tabpanel" aria-labelledby="subscriptionsTiersTab" hidden>
|
|
<div class="tier-list-header">
|
|
<h2>Your Subscriptions</h2>
|
|
<p>Creators you follow or support with a monthly tier.</p>
|
|
</div>
|
|
<div class="subscriptions-grid" id="subscriptions-grid" aria-live="polite">
|
|
<div class="tiers-empty">Loading subscriptions...</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
fetch("/sidebar.html")
|
|
.then((r) => r.text())
|
|
.then((data) => {
|
|
document.getElementById("sidebar-container").innerHTML = data;
|
|
document
|
|
.querySelectorAll("#sidebar-container .sidebar a")
|
|
.forEach((link) => {
|
|
link.classList.remove("active");
|
|
link.removeAttribute("aria-current");
|
|
});
|
|
const tiersLink = document.querySelector(
|
|
'#sidebar-container a[href="subscription-tiers.html"]',
|
|
);
|
|
if (tiersLink) {
|
|
tiersLink.classList.add("active");
|
|
tiersLink.setAttribute("aria-current", "page");
|
|
}
|
|
});
|
|
|
|
fetch("/topbar.html")
|
|
.then((r) => r.text())
|
|
.then(
|
|
(data) =>
|
|
(document.getElementById("topbar-container").innerHTML = data),
|
|
);
|
|
|
|
const tierForm = document.getElementById("tier-form");
|
|
const tierFormTitle = document.getElementById("tier-form-title");
|
|
const tierName = document.getElementById("tier-name");
|
|
const tierLevel = document.getElementById("tier-level");
|
|
const tierPrice = document.getElementById("tier-price");
|
|
const tierDescription = document.getElementById("tier-description");
|
|
const tierStatus = document.getElementById("tier-status");
|
|
const tiersGrid = document.getElementById("tiers-grid");
|
|
const subscriptionsGrid = document.getElementById("subscriptions-grid");
|
|
const manageTabPanel = document.getElementById("manage-tab-panel");
|
|
const subscriptionsTabPanel = document.getElementById(
|
|
"subscriptions-tab-panel",
|
|
);
|
|
const resetBtn = document.getElementById("tier-reset-btn");
|
|
let editingTierId = null;
|
|
let tiers = [];
|
|
let subscriptions = [];
|
|
|
|
function setActiveTab(tabName) {
|
|
document.querySelectorAll(".tiers-tab").forEach((tab) => {
|
|
tab.classList.toggle("active", tab.dataset.tab === tabName);
|
|
tab.setAttribute("aria-selected", String(tab.dataset.tab === tabName));
|
|
});
|
|
manageTabPanel.style.display = tabName === "manage" ? "grid" : "none";
|
|
manageTabPanel.hidden = tabName !== "manage";
|
|
subscriptionsTabPanel.style.display =
|
|
tabName === "subscriptions" ? "block" : "none";
|
|
subscriptionsTabPanel.hidden = tabName !== "subscriptions";
|
|
|
|
if (tabName === "subscriptions") loadSubscriptions();
|
|
}
|
|
|
|
function resetForm() {
|
|
editingTierId = null;
|
|
tierFormTitle.textContent = "Create Tier";
|
|
tierForm.reset();
|
|
tierLevel.value = tiers.length ? Math.max(...tiers.map((t) => t.level)) + 1 : 1;
|
|
tierStatus.textContent = "";
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
async function getFriendlyTierError(response) {
|
|
const fallback = `Server error ${response.status}`;
|
|
const text = await response.text();
|
|
if (!text) return fallback;
|
|
|
|
try {
|
|
const error = JSON.parse(text);
|
|
const messages = error.errors
|
|
? Object.values(error.errors).flat()
|
|
: [error.title || fallback];
|
|
|
|
return messages
|
|
.map((message) =>
|
|
message === "Tier with this level already exists."
|
|
? "A tier with this level already exists. Please choose another level."
|
|
: message,
|
|
)
|
|
.join(" ");
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
async function loadTiers() {
|
|
try {
|
|
const response = await fetch("/api/v1/subscriptions/tiers", {
|
|
credentials: "same-origin",
|
|
});
|
|
if (response.status === 401) {
|
|
location.href = "/login";
|
|
return;
|
|
}
|
|
if (!response.ok) throw new Error(`Server error ${response.status}`);
|
|
tiers = await response.json();
|
|
renderTiers();
|
|
if (!editingTierId) resetForm();
|
|
} catch (error) {
|
|
tiersGrid.innerHTML = `<div class="tiers-error">${escapeHtml(error.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadSubscriptions() {
|
|
try {
|
|
const response = await fetch("/api/v1/subscriptions", {
|
|
credentials: "same-origin",
|
|
});
|
|
if (response.status === 401) {
|
|
location.href = "/login";
|
|
return;
|
|
}
|
|
if (!response.ok) throw new Error(`Server error ${response.status}`);
|
|
|
|
subscriptions = await response.json();
|
|
renderSubscriptions();
|
|
} catch (error) {
|
|
subscriptionsGrid.innerHTML = `<div class="tiers-error">${escapeHtml(error.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderSubscriptions() {
|
|
if (!subscriptions.length) {
|
|
subscriptionsGrid.innerHTML =
|
|
'<div class="tiers-empty">No subscriptions yet.</div>';
|
|
return;
|
|
}
|
|
|
|
subscriptionsGrid.innerHTML = subscriptions
|
|
.map((subscription) => {
|
|
const tier = subscription.currentTier;
|
|
return `
|
|
<article class="subscription-card">
|
|
<div>
|
|
<h3>${escapeHtml(subscription.subscribedToName)}</h3>
|
|
<p>${tier ? `${escapeHtml(tier.name)} - Level ${tier.level}` : "Following without tier"}</p>
|
|
</div>
|
|
<div class="subscription-price">
|
|
${tier ? `$${Number(tier.monthlyPrice || 0).toFixed(2)}/mo` : "Free"}
|
|
</div>
|
|
</article>`;
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
function renderTiers() {
|
|
if (!tiers.length) {
|
|
tiersGrid.innerHTML = '<div class="tiers-empty">No tiers yet.</div>';
|
|
return;
|
|
}
|
|
|
|
tiersGrid.innerHTML = tiers
|
|
.map(
|
|
(tier) => `
|
|
<article class="tier-card">
|
|
<div class="tier-card-top">
|
|
<div>
|
|
<h3>${escapeHtml(tier.name)}</h3>
|
|
<div class="tier-level">Level ${tier.level}</div>
|
|
</div>
|
|
<div class="tier-price">$${Number(tier.monthlyPrice || 0).toFixed(2)}/mo</div>
|
|
</div>
|
|
<p class="tier-desc">${escapeHtml(tier.description || "No description yet.")}</p>
|
|
<div class="tier-card-actions">
|
|
<button type="button" data-edit="${tier.id}" aria-label="Edit ${escapeHtml(tier.name)} tier">Edit</button>
|
|
<button type="button" data-delete="${tier.id}" class="tier-delete-btn" aria-label="Delete ${escapeHtml(tier.name)} tier">Delete</button>
|
|
</div>
|
|
</article>`,
|
|
)
|
|
.join("");
|
|
|
|
tiersGrid.querySelectorAll("[data-edit]").forEach((button) => {
|
|
button.addEventListener("click", () => editTier(button.dataset.edit));
|
|
});
|
|
tiersGrid.querySelectorAll("[data-delete]").forEach((button) => {
|
|
button.addEventListener("click", () => deleteTier(button.dataset.delete));
|
|
});
|
|
}
|
|
|
|
function editTier(id) {
|
|
const tier = tiers.find((item) => item.id === id);
|
|
if (!tier) return;
|
|
|
|
editingTierId = id;
|
|
tierFormTitle.textContent = "Edit Tier";
|
|
tierName.value = tier.name || "";
|
|
tierLevel.value = tier.level || 1;
|
|
tierPrice.value = tier.monthlyPrice || 0;
|
|
tierDescription.value = tier.description || "";
|
|
tierStatus.textContent = "";
|
|
}
|
|
|
|
async function deleteTier(id) {
|
|
const tier = tiers.find((item) => item.id === id);
|
|
if (!tier || !confirm(`Delete ${tier.name}?`)) return;
|
|
|
|
const response = await fetch(`/api/v1/subscriptions/tiers/${id}`, {
|
|
method: "DELETE",
|
|
credentials: "same-origin",
|
|
});
|
|
if (response.status === 401) {
|
|
location.href = "/login";
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
tierStatus.textContent = await getFriendlyTierError(response);
|
|
return;
|
|
}
|
|
resetForm();
|
|
loadTiers();
|
|
}
|
|
|
|
tierForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
tierStatus.textContent = "Saving...";
|
|
|
|
const payload = {
|
|
name: tierName.value.trim(),
|
|
level: Number(tierLevel.value),
|
|
monthlyPrice: Number(tierPrice.value),
|
|
description: tierDescription.value.trim() || null,
|
|
};
|
|
|
|
const response = await fetch(
|
|
editingTierId
|
|
? `/api/v1/subscriptions/tiers/${editingTierId}`
|
|
: "/api/v1/subscriptions/tiers",
|
|
{
|
|
method: editingTierId ? "PUT" : "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "same-origin",
|
|
body: JSON.stringify(payload),
|
|
},
|
|
);
|
|
|
|
if (response.status === 401) {
|
|
location.href = "/login";
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
tierStatus.textContent = await getFriendlyTierError(response);
|
|
return;
|
|
}
|
|
|
|
tierStatus.textContent = "Tier saved.";
|
|
editingTierId = null;
|
|
await loadTiers();
|
|
});
|
|
|
|
resetBtn.addEventListener("click", resetForm);
|
|
document.querySelectorAll(".tiers-tab").forEach((tab) => {
|
|
tab.addEventListener("click", () => setActiveTab(tab.dataset.tab));
|
|
});
|
|
setActiveTab("manage");
|
|
loadTiers();
|
|
</script>
|
|
</body>
|
|
</html>
|