362 lines
14 KiB
HTML
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>
|