535 lines
20 KiB
HTML
535 lines
20 KiB
HTML
<!-- OnlyPrompt - Marketplace page: dynamic prompts, category filter, sort, crypto payment modal -->
|
||
|
||
<!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"
|
||
/>
|
||
<style>
|
||
/* Additional inline style for sort dropdown – can be moved to marketplace.css */
|
||
.filter-sort-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
margin-bottom: 32px;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
padding-bottom: 16px;
|
||
}
|
||
.filter-buttons {
|
||
margin-bottom: 0;
|
||
border-bottom: none;
|
||
padding-bottom: 0;
|
||
}
|
||
.sort-dropdown {
|
||
background: #f8fafc;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 30px;
|
||
padding: 8px 16px;
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
color: #334155;
|
||
cursor: pointer;
|
||
outline: none;
|
||
}
|
||
.sort-dropdown:hover {
|
||
border-color: #94a3b8;
|
||
}
|
||
@media (max-width: 700px) {
|
||
.filter-sort-row {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.sort-dropdown {
|
||
align-self: flex-start;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="layout">
|
||
<div id="sidebar-container"></div>
|
||
|
||
<div class="page-body">
|
||
<div id="topbar-container"></div>
|
||
|
||
<main class="marketplace-main">
|
||
<!-- 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">
|
||
<button class="filter-btn active" data-category="">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 Price</option>
|
||
<option value="price|false">Highest Price</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Prompts Grid -->
|
||
<div class="prompts-grid" id="prompts-grid"></div>
|
||
|
||
<!-- Empty State -->
|
||
<div id="market-empty" class="state-empty">
|
||
<i class="bi bi-bag-x state-icon"></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">
|
||
<i class="bi bi-exclamation-circle state-icon"></i>
|
||
<h3 class="state-title">Could not load prompts</h3>
|
||
<p id="market-error-msg"></p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Payment Modal ─────────────────────────────────────────────── -->
|
||
<div id="payment-overlay">
|
||
<div class="payment-modal">
|
||
<button onclick="closePayment()" class="payment-close-btn">
|
||
✕
|
||
</button>
|
||
|
||
<!-- Step 1: Choose method -->
|
||
<div id="pay-step-1">
|
||
<h2 class="payment-title">Subscribe to access</h2>
|
||
<p id="pay-prompt-title"></p>
|
||
<p class="payment-intro">
|
||
Choose a payment method to unlock this prompt:
|
||
</p>
|
||
<div class="payment-method-list">
|
||
<button class="pay-method-btn" onclick="selectCrypto('btc')">
|
||
<span class="pay-crypto-icon">₿</span> Bitcoin (BTC)
|
||
<span id="price-btc" class="pay-method-price"></span>
|
||
</button>
|
||
<button class="pay-method-btn" onclick="selectCrypto('eth')">
|
||
<span class="pay-crypto-icon">Ξ</span> Ethereum (ETH)
|
||
<span id="price-eth" class="pay-method-price"></span>
|
||
</button>
|
||
<button class="pay-method-btn" onclick="selectCrypto('sol')">
|
||
<span class="pay-crypto-icon">◎</span> Solana (SOL)
|
||
<span id="price-sol" class="pay-method-price"></span>
|
||
</button>
|
||
<button class="pay-method-btn" onclick="selectCrypto('usdt')">
|
||
<span class="pay-crypto-icon">₮</span> USDT (TRC-20)
|
||
<span id="price-usdt" class="pay-method-price"></span>
|
||
</button>
|
||
</div>
|
||
<p class="payment-disclaimer">
|
||
<i class="bi bi-shield-lock-fill"></i> Payments are processed
|
||
on-chain. No account needed.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Step 2: Send payment -->
|
||
<div id="pay-step-2">
|
||
<button onclick="backToStep1()" class="payment-back-btn">
|
||
← Back
|
||
</button>
|
||
<h2 id="pay-crypto-title"></h2>
|
||
<p class="payment-amount-text">
|
||
Send exactly <strong id="pay-amount"></strong> to:
|
||
</p>
|
||
<div class="payment-address-box">
|
||
<code id="pay-address"></code>
|
||
<button
|
||
onclick="copyAddress()"
|
||
title="Copy"
|
||
class="payment-copy-btn"
|
||
>
|
||
<i class="bi bi-clipboard"></i>
|
||
</button>
|
||
</div>
|
||
<p class="payment-warning-text">
|
||
⚠️ Only send the exact amount. Payments are non-refundable.
|
||
</p>
|
||
<div class="payment-info-box">
|
||
<i class="bi bi-info-circle-fill"></i> After sending, click the
|
||
button below to confirm. Access will be granted once the transaction
|
||
is verified.
|
||
</div>
|
||
<button onclick="confirmPayment()" class="payment-confirm-btn">
|
||
I've sent the payment ✓
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Step 3: Success -->
|
||
<div id="pay-step-3">
|
||
<i class="bi bi-check-circle-fill payment-success-icon"></i>
|
||
<h2 class="payment-success-title">Payment received!</h2>
|
||
<p class="payment-success-desc">
|
||
Your access is being activated. This usually takes 1–2 minutes.
|
||
</p>
|
||
<button onclick="closePayment()" class="payment-done-btn">
|
||
Done
|
||
</button>
|
||
</div>
|
||
</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"));
|
||
const link = document.querySelectorAll(
|
||
"#sidebar-container .sidebar li a",
|
||
)[1];
|
||
if (link) link.classList.add("active");
|
||
});
|
||
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 target =
|
||
promptId && !locked
|
||
? ` onclick="location.href='/post-detail?id=${promptId}#rating-section'" title="View reviews" class="market-rating-clickable"`
|
||
: "";
|
||
if (rating == null)
|
||
return `<span${target} class="market-rating-none">No reviews yet</span>`;
|
||
const stars = Math.round(rating);
|
||
const label = reviewCount === 1 ? "review" : "reviews";
|
||
return `<span class="prompt-rating"${target}><span class="market-rating-stars">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> ${rating.toFixed(1)} (${reviewCount} ${label})</span>`;
|
||
}
|
||
|
||
function promptPrice(prompt) {
|
||
if (prompt.price != null && Number(prompt.price) > 0) {
|
||
return `$${Number(prompt.price).toFixed(2)}`;
|
||
}
|
||
if (prompt.tierLevel)
|
||
return `$${(prompt.tierLevel * 4.99).toFixed(2)}/mo`;
|
||
if (prompt.canAccess === false) return "Paid";
|
||
return "Free";
|
||
}
|
||
|
||
function getNumericPrice(prompt) {
|
||
if (prompt.price != null && Number(prompt.price) > 0) {
|
||
return Number(prompt.price);
|
||
}
|
||
if (prompt.tierLevel) return prompt.tierLevel * 4.99;
|
||
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 paid = p.price != null && Number(p.price) > 0;
|
||
const locked = p.canAccess === false || paid || p.tierLevel != null;
|
||
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">${timeAgo(p.timeStamp)}</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 class="buy-btn buy-btn-locked" onclick='openPayment(${JSON.stringify(p)})'><i class="bi bi-lock-fill"></i> Pay</button>`
|
||
: `<button class="buy-btn buy-btn-unlocked" onclick="location.href='/post-detail?id=${p.id}'">Access <i class="bi bi-unlock-fill"></i></button>`
|
||
}
|
||
${
|
||
locked
|
||
? `<button class="details-btn" disabled><i class="bi bi-lock-fill"></i> Details</button>`
|
||
: `<button class="details-btn" 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.className = "filter-btn";
|
||
btn.dataset.category = c.slug;
|
||
btn.textContent = c.name;
|
||
btn.addEventListener("click", () => {
|
||
document
|
||
.querySelectorAll("#category-filters .filter-btn")
|
||
.forEach((b) => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
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"));
|
||
this.classList.add("active");
|
||
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();
|
||
|
||
// Make openPayment global
|
||
window.openPayment = openPayment;
|
||
|
||
// ── Payment Modal ──────────────────────────────────────────────────
|
||
const CRYPTO_ADDRESSES = {
|
||
btc: "1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf1N",
|
||
eth: "0x742d35Cc6634C0532925a3b8D4C9B8E4D8F2b1a",
|
||
sol: "7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV1",
|
||
usdt: "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
|
||
};
|
||
const CRYPTO_NAMES = {
|
||
btc: "Bitcoin (BTC)",
|
||
eth: "Ethereum (ETH)",
|
||
sol: "Solana (SOL)",
|
||
usdt: "USDT TRC-20",
|
||
};
|
||
let currentPrompt = null;
|
||
let currentCrypto = null;
|
||
|
||
function openPayment(prompt) {
|
||
currentPrompt = prompt;
|
||
const usd =
|
||
prompt.price != null && Number(prompt.price) > 0
|
||
? Number(prompt.price)
|
||
: prompt.tierLevel
|
||
? prompt.tierLevel * 4.99
|
||
: 0;
|
||
document.getElementById("pay-prompt-title").textContent = prompt.title;
|
||
document.getElementById("price-btc").textContent =
|
||
`≈ ${(usd / 67000).toFixed(6)} BTC`;
|
||
document.getElementById("price-eth").textContent =
|
||
`≈ ${(usd / 3200).toFixed(5)} ETH`;
|
||
document.getElementById("price-sol").textContent =
|
||
`≈ ${(usd / 145).toFixed(3)} SOL`;
|
||
document.getElementById("price-usdt").textContent =
|
||
`${usd.toFixed(2)} USDT`;
|
||
|
||
document.getElementById("pay-step-1").style.display = "block";
|
||
document.getElementById("pay-step-2").style.display = "none";
|
||
document.getElementById("pay-step-3").style.display = "none";
|
||
document.getElementById("payment-overlay").style.display = "flex";
|
||
}
|
||
|
||
window.selectCrypto = function (crypto) {
|
||
currentCrypto = crypto;
|
||
const usd = currentPrompt.tierLevel
|
||
? currentPrompt.tierLevel * 4.99
|
||
: 0;
|
||
const amounts = {
|
||
btc: `${(usd / 67000).toFixed(6)} BTC`,
|
||
eth: `${(usd / 3200).toFixed(5)} ETH`,
|
||
sol: `${(usd / 145).toFixed(3)} SOL`,
|
||
usdt: `${usd.toFixed(2)} USDT`,
|
||
};
|
||
document.getElementById("pay-crypto-title").textContent =
|
||
CRYPTO_NAMES[crypto];
|
||
document.getElementById("pay-amount").textContent = amounts[crypto];
|
||
document.getElementById("pay-address").textContent =
|
||
CRYPTO_ADDRESSES[crypto];
|
||
document.getElementById("pay-step-1").style.display = "none";
|
||
document.getElementById("pay-step-2").style.display = "block";
|
||
};
|
||
|
||
window.backToStep1 = function () {
|
||
document.getElementById("pay-step-1").style.display = "block";
|
||
document.getElementById("pay-step-2").style.display = "none";
|
||
};
|
||
|
||
window.copyAddress = function () {
|
||
navigator.clipboard.writeText(CRYPTO_ADDRESSES[currentCrypto]);
|
||
};
|
||
|
||
window.confirmPayment = function () {
|
||
document.getElementById("pay-step-2").style.display = "none";
|
||
document.getElementById("pay-step-3").style.display = "block";
|
||
};
|
||
|
||
window.closePayment = function () {
|
||
document.getElementById("payment-overlay").style.display = "none";
|
||
};
|
||
|
||
document
|
||
.getElementById("payment-overlay")
|
||
.addEventListener("click", function (e) {
|
||
if (e.target === this) closePayment();
|
||
});
|
||
|
||
// ── Init ───────────────────────────────────────────────────────────
|
||
await loadCategories();
|
||
await loadPrompts();
|
||
</script>
|
||
</body>
|
||
</html>
|