2026-06-14 11:58:04 +02:00

362 lines
14 KiB
HTML

<!-- OnlyPrompt - Marketplace page: dynamic prompts, category filter, sort and tier access -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OnlyPrompt - Marketplace</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/login.css" />
<link rel="stylesheet" href="../css/topbar.css" />
<link rel="stylesheet" href="../css/marketplace.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="marketplace-main" id="main-content" tabindex="-1">
<!-- Header -->
<div class="marketplace-header">
<h1>Marketplace</h1>
<p>Browse and discover high-quality AI prompts</p>
</div>
<!-- Filter + Sort Row -->
<div class="filter-sort-row">
<div class="filter-buttons" id="category-filters" role="group" aria-label="Filter prompts by category">
<button type="button" class="filter-btn active" data-category="" aria-pressed="true">All</button>
</div>
<select
class="sort-dropdown"
id="sort-select"
aria-label="Sort prompts"
>
<option value="date|false">Newest</option>
<option value="date|true">Oldest</option>
<option value="rating|false">Best Rating</option>
<option value="rating|true">Lowest Rating</option>
<option value="free|true">Free</option>
<option value="price|true">Lowest Tier Price</option>
<option value="price|false">Highest Tier Price</option>
</select>
</div>
<!-- Prompts Grid -->
<div class="prompts-grid" id="prompts-grid" aria-live="polite"></div>
<!-- Empty State -->
<div id="market-empty" class="state-empty" role="status" aria-live="polite">
<i class="bi bi-bag-x state-icon" aria-hidden="true"></i>
<h3 class="state-title">No prompts found</h3>
<p>Try a different category or search term.</p>
</div>
<!-- Error State -->
<div id="market-error" class="state-error" role="alert" aria-live="assertive">
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
<h3 class="state-title">Could not load prompts</h3>
<p id="market-error-msg"></p>
</div>
</main>
</div>
</div>
<script type="module">
// ── Sidebar & Topbar ──────────────────────────────────────────────
fetch("/sidebar.html")
.then((r) => r.text())
.then((data) => {
document.getElementById("sidebar-container").innerHTML = data;
document
.querySelectorAll("#sidebar-container .sidebar a")
.forEach((l) => {
l.classList.remove("active");
l.removeAttribute("aria-current");
});
const link = document.querySelectorAll(
"#sidebar-container .sidebar li a",
)[1];
if (link) {
link.classList.add("active");
link.setAttribute("aria-current", "page");
}
});
fetch("/topbar.html")
.then((r) => r.text())
.then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
// ── State ─────────────────────────────────────────────────────────
let activeCategory = "";
let searchTimeout;
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function renderStars(
rating,
reviewCount = 0,
promptId = null,
locked = false,
) {
const href =
promptId && !locked
? `/post-detail?id=${encodeURIComponent(promptId)}#rating-section`
: "";
if (rating == null)
return href
? `<a href="${href}" title="View reviews" class="market-rating-none market-rating-clickable">No reviews yet</a>`
: `<span class="market-rating-none">No reviews yet</span>`;
const stars = Math.round(rating);
const label = reviewCount === 1 ? "review" : "reviews";
const content = `<span class="market-rating-stars" aria-hidden="true">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> <span aria-label="${rating.toFixed(1)} out of 5 stars">${rating.toFixed(1)}</span> (${reviewCount} ${label})`;
return href
? `<a class="prompt-rating market-rating-clickable" href="${href}" title="View reviews">${content}</a>`
: `<span class="prompt-rating">${content}</span>`;
}
function promptPrice(prompt) {
if (prompt.tierLevel) {
const price = prompt.tierMonthlyPrice == null
? ""
: ` - $${Number(prompt.tierMonthlyPrice).toFixed(2)}/mo`;
return `${prompt.tierName || `Tier ${prompt.tierLevel}`}${price}`;
}
return "Free";
}
function getNumericPrice(prompt) {
if (prompt.tierMonthlyPrice != null) return Number(prompt.tierMonthlyPrice);
return 0;
}
function applyMarketplaceSort(prompts, sortBy, ascending) {
if (sortBy === "free") {
return prompts.filter((prompt) => getNumericPrice(prompt) === 0);
}
if (sortBy === "price") {
const direction = ascending === "true" ? 1 : -1;
return prompts
.slice()
.sort(
(a, b) => (getNumericPrice(a) - getNumericPrice(b)) * direction,
);
}
return prompts;
}
const MARKET_IMAGES = [
"/images/content/market1.png",
"/images/content/market2.png",
"/images/content/market3.png",
"/images/content/market4.png",
"/images/content/market5.png",
"/images/content/market6.png",
];
let cardIndex = 0;
function renderCard(p) {
const locked = p.canAccess === false;
const img =
p.exampleImageUrl ||
p._img ||
MARKET_IMAGES[cardIndex++ % MARKET_IMAGES.length];
return `
<div class="prompt-card">
<img src="${img}" alt="${p.title}" class="prompt-img">
<div class="prompt-info">
<div class="market-card-header">
<div class="market-card-avatar">${p.creatorName.charAt(0).toUpperCase()}</div>
<span class="prompt-author">@${p.creatorName}</span>
<span class="market-card-time"><time datetime="${p.timeStamp}">${timeAgo(p.timeStamp)}</time></span>
</div>
<h3 class="prompt-title">${p.title}</h3>
<p class="prompt-description">${p.description || "No description yet."}</p>
<div class="market-card-rating">${renderStars(p.averageRating, p.reviewCount || 0, p.id, locked)}</div>
<div class="prompt-price">${promptPrice(p)}</div>
<div class="prompt-actions">
${
locked
? `<button type="button" class="buy-btn buy-btn-locked" aria-label="Subscribe to unlock ${p.title}" onclick='subscribeToPromptTier(${JSON.stringify(p)})'><i class="bi bi-lock-fill" aria-hidden="true"></i> Subscribe</button>`
: `<button type="button" class="buy-btn buy-btn-unlocked" aria-label="Access ${p.title}" onclick="location.href='/post-detail?id=${p.id}'">Access <i class="bi bi-unlock-fill" aria-hidden="true"></i></button>`
}
${
locked
? `<button type="button" class="details-btn" disabled aria-label="Details for ${p.title} are locked"><i class="bi bi-lock-fill" aria-hidden="true"></i> Details</button>`
: `<button type="button" class="details-btn" aria-label="View details for ${p.title}" onclick="location.href='/post-detail?id=${p.id}'">View Details</button>`
}
</div>
</div>
</div>`;
}
async function loadPrompts() {
const grid = document.getElementById("prompts-grid");
const emptyEl = document.getElementById("market-empty");
const errorEl = document.getElementById("market-error");
const search =
document.getElementById("topbarSearchInput")?.value.trim() || "";
const [sortBy, ascending] = document
.getElementById("sort-select")
.value.split("|");
grid.innerHTML = "";
emptyEl.style.display = "none";
errorEl.style.display = "none";
cardIndex = 0;
try {
const apiSortBy =
sortBy === "price" || sortBy === "free" ? "date" : sortBy;
const apiAscending =
sortBy === "price" || sortBy === "free" ? "false" : ascending;
let url = `/api/v1/prompts?sortBy=${apiSortBy}&ascending=${apiAscending}&limit=50`;
if (activeCategory) url += `&category=${activeCategory}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
const res = await fetch(url);
if (res.status === 401) {
location.href = "/login";
return;
}
if (!res.ok) throw new Error(`Server error ${res.status}`);
let prompts = applyMarketplaceSort(
await res.json(),
sortBy,
ascending,
);
if (prompts.length === 0) {
emptyEl.style.display = "block";
return;
}
grid.innerHTML = prompts.map(renderCard).join("");
} catch (e) {
document.getElementById("market-error-msg").textContent =
e.message || "Please check if the backend is running.";
errorEl.style.display = "block";
}
}
async function loadCategories() {
try {
const res = await fetch("/api/v1/categories/minimal");
if (!res.ok) return;
const cats = await res.json();
const container = document.getElementById("category-filters");
cats.forEach((c) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "filter-btn";
btn.dataset.category = c.slug;
btn.setAttribute("aria-pressed", "false");
btn.textContent = c.name;
btn.addEventListener("click", () => {
document
.querySelectorAll("#category-filters .filter-btn")
.forEach((b) => {
b.classList.remove("active");
b.setAttribute("aria-pressed", "false");
});
btn.classList.add("active");
btn.setAttribute("aria-pressed", "true");
activeCategory = c.slug;
loadPrompts();
});
container.appendChild(btn);
});
} catch {}
}
// ── Category filter (All) ──────────────────────────────────────────
document
.querySelector("#category-filters .filter-btn")
.addEventListener("click", function () {
document
.querySelectorAll("#category-filters .filter-btn")
.forEach((b) => {
b.classList.remove("active");
b.setAttribute("aria-pressed", "false");
});
this.classList.add("active");
this.setAttribute("aria-pressed", "true");
activeCategory = "";
loadPrompts();
});
document
.getElementById("sort-select")
.addEventListener("change", loadPrompts);
function wireMarketplaceTopbarSearch() {
const input = document.getElementById("topbarSearchInput");
if (!input || input.dataset.marketplaceSearchReady === "true") return;
input.dataset.marketplaceSearchReady = "true";
input.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadPrompts, 400);
});
input.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
loadPrompts();
});
}
const topbarObserver = new MutationObserver(wireMarketplaceTopbarSearch);
topbarObserver.observe(document.body, { childList: true, subtree: true });
wireMarketplaceTopbarSearch();
window.subscribeToPromptTier = async function (prompt) {
if (!prompt.tierLevel) return;
const response = await fetch(
`/api/v1/subscriptions/${encodeURIComponent(prompt.creatorId)}/${prompt.tierLevel}`,
{
method: "PUT",
credentials: "same-origin",
},
);
if (response.status === 401) {
location.href = "/login";
return;
}
if (response.ok) {
loadPrompts();
}
};
// ── Init ───────────────────────────────────────────────────────────
await loadCategories();
await loadPrompts();
</script>
</body>
</html>