Add subscription tiers and responsive navigation

This commit is contained in:
Isabelle Nachbaur 2026-06-13 18:14:56 +02:00
parent 289f7eebbe
commit de1d3d2d63
24 changed files with 1290 additions and 602 deletions

51
API.md
View File

@ -152,7 +152,7 @@ GET /api/v1/prompts?sortBy=date&ascending=false&limit=50&search=cat
Used by Marketplace. Supports sorting, search and category filtering. The marketplace excludes prompts created by the logged-in user.
Response items include prompt title, description, creator id, creator name, creator avatar, example image, price, like/save counts, average rating, review count and access state.
Response items include prompt title, description, creator id, creator name, creator avatar, example image, tier data, like/save counts, average rating, review count and access state.
### Feed
@ -187,6 +187,8 @@ Request:
Response: created prompt. The frontend redirects to `/post-detail?id={id}`.
`subscriptionTier` is the creator's tier level. `null` means the prompt is public/free. `price` is kept as `null` because prompt access is handled through monthly creator tiers.
### Own Prompts
```http
@ -202,7 +204,7 @@ PUT /api/v1/prompts/{id}
Content-Type: application/json
```
Request body uses the same editable fields as prompt creation. Used by the edit prompt flow.
Request body uses the same editable fields as prompt creation, including `subscriptionTier`. Used by the edit prompt flow.
### Prompt Detail
@ -216,13 +218,13 @@ Response includes:
- prompt content if accessible
- category
- creator information
- price or free state
- tier or free state
- example output
- example image
- average rating and review count
- like/save state and counts
Paid prompts return no detail content for users without access.
Tier prompts return no detail content for users without a matching creator subscription.
### Likes and Saves
@ -283,10 +285,49 @@ Default categories are created automatically when the backend starts.
```http
GET /api/v1/subscriptions/{creatorId}
PUT /api/v1/subscriptions/{creatorId}
PUT /api/v1/subscriptions/{creatorId}/{level}
DELETE /api/v1/subscriptions/{creatorId}
```
Used by Community and public profiles to read follow state, follow creators or unfollow creators.
Used by Community, public profiles and locked prompt details to read follow state, follow creators, subscribe to a monthly tier or unfollow creators.
- `PUT /api/v1/subscriptions/{creatorId}` follows a creator without a paid tier.
- `PUT /api/v1/subscriptions/{creatorId}/{level}` subscribes to one of the creator's tiers. A higher tier gives access to prompts from the same level and lower levels.
### Subscription Tiers
```http
GET /api/v1/subscriptions/tiers
GET /api/v1/subscriptions/tiers/{creatorId}
POST /api/v1/subscriptions/tiers
PUT /api/v1/subscriptions/tiers/{tierId}
DELETE /api/v1/subscriptions/tiers/{tierId}
```
Used by the Subscription Tiers page, Create Prompt and public creator profiles.
Create request:
```json
{
"name": "Supporter",
"monthlyPrice": 4.99,
"level": 1,
"description": "Access to basic premium prompts."
}
```
Response:
```json
{
"id": "uuid",
"name": "Supporter",
"level": 1,
"monthlyPrice": 4.99,
"description": "Access to basic premium prompts."
}
```
## Error Handling

View File

@ -1,7 +1,7 @@
namespace OnlyPrompt.Backend.ApiModels.Prompt
{
public record ApiPrompt(Guid Id, string Title, string Description, string? Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CategoryName, string CategorySlug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, double? AverageRating, int ReviewCount, bool CanAccess);
public record ApiMinimalPrompt(Guid Id, string Title, string Description, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CreatorAvatarUrl, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, double? AverageRating, int ReviewCount, bool CanAccess);
public record ApiPrompt(Guid Id, string Title, string Description, string? Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CategoryName, string CategorySlug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, decimal? TierMonthlyPrice, double? AverageRating, int ReviewCount, bool CanAccess);
public record ApiMinimalPrompt(Guid Id, string Title, string Description, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CreatorAvatarUrl, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, decimal? TierMonthlyPrice, double? AverageRating, int ReviewCount, bool CanAccess);
public record ApiReview(Guid CreatorId, string CreatorName, string? Comment, int Rating);
public record ApiLikeState(int LikeCount, bool IsLiked);
public record ApiSaveState(int SaveCount, bool IsSaved);

View File

@ -1,5 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.Prompt
{
public record ApiCreatePromptRequest(string Title, string Description, string Content, string Category, int? SubscriptionTier, string? Slug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
public record ApiUpdatePromptRequest(string Title, string Description, string Content, string Category, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
public record ApiUpdatePromptRequest(string Title, string Description, string Content, string Category, int? SubscriptionTier, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
}

View File

@ -71,9 +71,10 @@ namespace OnlyPrompt.Backend.Controllers
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
x.Reviews.Average(r => (double?)r.Rating),
x.Reviews.Count,
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level <= s.SubscriptionTier.Level)
)).ToArrayAsync();
return prompts;

View File

@ -81,9 +81,10 @@ namespace OnlyPrompt.Backend.Controllers
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
x.Reviews.Average(r => (double?)r.Rating),
x.Reviews.Count,
x.CreatorId == userId || ((x.Price == null || x.Price <= 0) && (x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)))
x.CreatorId == userId || x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level <= s.SubscriptionTier.Level)
)).ToArrayAsync();
return prompts;
@ -112,6 +113,7 @@ namespace OnlyPrompt.Backend.Controllers
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
x.Reviews.Average(r => (double?)r.Rating),
x.Reviews.Count,
true
@ -132,9 +134,6 @@ namespace OnlyPrompt.Backend.Controllers
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
if (prompt.CreatorId != userId && prompt.Price.HasValue && prompt.Price.Value > 0)
return TypedResults.NotFound("Prompt not found or requires payment");
var canAccess = await GetAccessiblePrompts(userId.Value).AnyAsync(p => p.Id == prompt.Id);
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with
{
@ -169,7 +168,20 @@ namespace OnlyPrompt.Backend.Controllers
prompt.Category = category;
prompt.ExampleOutput = request.ExampleOutput;
prompt.ExampleImageUrl = request.ExampleImageUrl;
prompt.Price = request.Price;
prompt.Price = null;
prompt.SubscriptionTier = null;
if (request.SubscriptionTier.HasValue)
{
var subscriptionTier = await _db.SubscriptionTiers.FirstOrDefaultAsync(
t => t.Level == request.SubscriptionTier.Value
&& t.UserId == userId
);
if (subscriptionTier is null)
return TypedResults.NotFound("Subscription tier not found");
prompt.SubscriptionTier = subscriptionTier;
}
await _db.SaveChangesAsync();
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with { Content = prompt.Prompt, CanAccess = true };
@ -315,7 +327,7 @@ namespace OnlyPrompt.Backend.Controllers
Prompt = request.Content,
ExampleOutput = request.ExampleOutput,
ExampleImageUrl = request.ExampleImageUrl,
Price = request.Price,
Price = null,
CreatorId = userId.Value,
SubscriptionTier = subscriptionTier,
Category = category,

View File

@ -100,6 +100,35 @@ namespace OnlyPrompt.Backend.Controllers
return subscription;
}
[HttpGet("tiers")]
public async Task<ApiSubscriptionTier[]> GetOwnSubscriptionTiersAsync()
{
var userId = User.GetUserId();
return await _db.SubscriptionTiers
.Where(t => t.UserId == userId)
.OrderBy(t => t.Level)
.ProjectTo<ApiSubscriptionTier>(_mapper.ConfigurationProvider)
.ToArrayAsync();
}
[HttpGet("tiers/{userId}")]
public async Task<Results<Ok<ApiSubscriptionTier[]>, NotFound<string>>> GetCreatorSubscriptionTiersAsync([FromRoute(Name = "userId")] Identifier creatorId)
{
var creatorExists = await _db.Users.AnyAsync(
user => creatorId.Id.HasValue ? user.Id == creatorId.Id.Value : user.Profile.Slug == creatorId.Slug
);
if (creatorExists == false)
return TypedResults.NotFound($"No user found with identifier {creatorId}");
var tiers = await _db.SubscriptionTiers
.Where(t => creatorId.Id.HasValue ? t.UserId == creatorId.Id.Value : t.User.Profile.Slug == creatorId.Slug)
.OrderBy(t => t.Level)
.ProjectTo<ApiSubscriptionTier>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return TypedResults.Ok(tiers);
}
[HttpDelete("{userId}")]
public async Task<Results<Ok, NotFound<string>>> UnsubscribeAsync([FromRoute(Name = "userId")] Identifier subscribeToId)
{

View File

@ -44,6 +44,7 @@ namespace OnlyPrompt.Backend.Utils
.MapCtorParamFrom(x => x.IsSaved, x => false)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.TierMonthlyPrice, x => x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice)
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
@ -66,6 +67,7 @@ namespace OnlyPrompt.Backend.Utils
.MapCtorParamFrom(x => x.IsSaved, x => false)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.TierMonthlyPrice, x => x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice)
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
.MapCtorParamFrom(x => x.ReviewCount, x => x.Reviews.Count)
.MapCtorParamFrom(x => x.CanAccess, x => true);

View File

@ -83,15 +83,18 @@
<!-- Pricing (with toggle) -->
<div class="form-group pricing-group">
<label>Pricing</label>
<label>Access</label>
<div class="pricing-toggle">
<button type="button" id="freeBtn" class="price-option active">Free</button>
<button type="button" id="paidBtn" class="price-option">Paid</button>
<button type="button" id="tierBtn" class="price-option">Tier</button>
</div>
<div id="priceField">
<input type="number" id="price" name="price" step="0.01" min="0" placeholder="Price in USD (e.g., 19.99)">
<div id="tierField">
<select id="subscriptionTier" name="subscriptionTier">
<option value="">No tiers created yet</option>
</select>
<a class="tier-manage-link" href="subscription-tiers.html">Manage tiers</a>
</div>
<small class="form-hint">You can set a price later or keep it free.</small>
<small class="form-hint">Free prompts are public. Tier prompts require a monthly creator subscription.</small>
</div>
<!-- Submit Button -->
@ -109,23 +112,24 @@
<script>
// Toggle between free and paid
const freeBtn = document.getElementById('freeBtn');
const paidBtn = document.getElementById('paidBtn');
const priceField = document.getElementById('priceField');
const priceInput = document.getElementById('price');
const tierBtn = document.getElementById('tierBtn');
const tierField = document.getElementById('tierField');
const tierSelect = document.getElementById('subscriptionTier');
const editPromptId = new URLSearchParams(location.search).get('id');
const submitPromptBtn = document.getElementById('submitPromptBtn');
let ownSubscriptionTiers = [];
freeBtn.addEventListener('click', () => {
freeBtn.classList.add('active');
paidBtn.classList.remove('active');
priceField.style.display = 'none';
priceInput.removeAttribute('required');
tierBtn.classList.remove('active');
tierField.style.display = 'none';
tierSelect.removeAttribute('required');
});
paidBtn.addEventListener('click', () => {
paidBtn.classList.add('active');
tierBtn.addEventListener('click', () => {
tierBtn.classList.add('active');
freeBtn.classList.remove('active');
priceField.style.display = 'block';
priceInput.setAttribute('required', 'required');
tierField.style.display = 'grid';
tierSelect.setAttribute('required', 'required');
});
// Image preview for example image
@ -171,6 +175,31 @@
}
}
async function loadSubscriptionTiers() {
try {
const response = await fetch('/api/v1/subscriptions/tiers', {
credentials: 'same-origin'
});
if (response.status === 401) {
location.href = '/login';
return;
}
if (!response.ok) return;
ownSubscriptionTiers = await response.json();
if (!ownSubscriptionTiers.length) {
tierSelect.innerHTML = '<option value="">Create a tier first</option>';
return;
}
tierSelect.innerHTML = ownSubscriptionTiers
.map((tier) => `<option value="${tier.level}">${tier.name} - $${Number(tier.monthlyPrice || 0).toFixed(2)}/mo</option>`)
.join('');
} catch {
tierSelect.innerHTML = '<option value="">Tiers could not be loaded</option>';
}
}
async function loadPromptForEdit() {
if (!editPromptId) return;
@ -202,9 +231,9 @@
imagePreview.style.display = 'block';
}
if (prompt.price != null && Number(prompt.price) > 0) {
paidBtn.click();
priceInput.value = Number(prompt.price);
if (prompt.tierLevel != null) {
tierBtn.click();
tierSelect.value = String(prompt.tierLevel);
} else {
freeBtn.click();
}
@ -224,8 +253,7 @@
submitBtn.disabled = true;
try {
const isPaid = paidBtn.classList.contains('active');
const price = isPaid ? Number(priceInput.value || 0) : null;
const isTier = tierBtn.classList.contains('active');
const payload = {
title: document.getElementById('title').value.trim(),
description: document.getElementById('description').value.trim(),
@ -233,8 +261,8 @@
content: document.getElementById('promptContent').value.trim(),
exampleOutput: document.getElementById('exampleOutput').value.trim() || null,
exampleImageUrl: exampleImageUrl || null,
price,
subscriptionTier: null,
price: null,
subscriptionTier: isTier ? Number(tierSelect.value) : null,
slug: null
};
@ -300,7 +328,7 @@
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
loadCategories().then(loadPromptForEdit);
Promise.all([loadCategories(), loadSubscriptionTiers()]).then(loadPromptForEdit);
</script>
</body>
</html>

View File

@ -58,6 +58,25 @@ body {
overflow-y: auto;
}
@media (max-width: 700px) {
.layout {
padding-bottom: 74px;
}
#sidebar-container {
position: fixed;
top: auto;
right: 0;
bottom: 0;
left: 0;
z-index: 100;
width: 100%;
height: auto;
overflow: visible;
border-top: 1px solid #e2e8f0;
}
}
/* Main content area - flex child that fills remaining space */
.page-body {
flex: 1;

View File

@ -96,9 +96,21 @@
background: var(--gradient);
color: white;
}
#priceField {
margin-top: 8px;
#tierField {
display: none;
gap: 8px;
margin-top: 8px;
}
.tier-manage-link {
color: #3b82f6;
font-size: 0.85rem;
font-weight: 700;
text-decoration: none;
}
.tier-manage-link:hover {
text-decoration: underline;
}
/* Image preview */

View File

@ -115,6 +115,7 @@
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.prompt-title {
@ -157,7 +158,7 @@
font-size: 1.3rem;
font-weight: 700;
color: #3b82f6;
margin: 8px 0 4px;
margin: auto 0 4px;
}
.prompt-actions {
@ -220,200 +221,6 @@
}
}
/* Payment method buttons */
.pay-method-btn {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
color: #1e293b;
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
text-align: left;
}
.pay-method-btn:hover {
border-color: #6366f1;
background: #f5f3ff;
}
/* ── Payment Modal ──────────────────────────────────────────────────── */
#payment-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.payment-modal {
background: #fff;
border-radius: 16px;
padding: 32px;
max-width: 460px;
width: 90%;
position: relative;
max-height: 90vh;
overflow-y: auto;
}
.payment-close-btn {
position: absolute;
top: 14px;
right: 18px;
background: none;
border: none;
font-size: 1.4rem;
cursor: pointer;
color: #64748b;
}
.payment-title {
margin-bottom: 4px;
}
#pay-prompt-title {
color: #6366f1;
font-weight: 600;
margin-bottom: 16px;
}
.payment-intro {
color: #64748b;
margin-bottom: 24px;
}
.payment-method-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.pay-crypto-icon {
font-size: 1.4rem;
}
.pay-method-price {
margin-left: auto;
font-size: 0.85rem;
color: #94a3b8;
}
.payment-disclaimer {
margin-top: 20px;
font-size: 0.8rem;
color: #94a3b8;
text-align: center;
}
#pay-step-2 {
display: none;
}
.payment-back-btn {
background: none;
border: none;
color: #6366f1;
cursor: pointer;
margin-bottom: 16px;
font-size: 0.9rem;
}
#pay-crypto-title {
margin-bottom: 8px;
}
.payment-amount-text {
color: #64748b;
margin-bottom: 8px;
}
.payment-address-box {
background: #f1f5f9;
border-radius: 10px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
#pay-address {
font-size: 0.85rem;
word-break: break-all;
flex: 1;
}
.payment-copy-btn {
background: none;
border: none;
cursor: pointer;
color: #6366f1;
font-size: 1.1rem;
}
.payment-warning-text {
font-size: 0.78rem;
color: #94a3b8;
margin-bottom: 20px;
}
.payment-info-box {
background: #fef9c3;
border: 1px solid #fde68a;
border-radius: 10px;
padding: 12px 14px;
font-size: 0.82rem;
color: #92400e;
margin-bottom: 20px;
}
.payment-confirm-btn {
width: 100%;
padding: 12px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
}
#pay-step-3 {
display: none;
text-align: center;
padding: 20px 0;
}
.payment-success-icon {
font-size: 3.5rem;
color: #10b981;
display: block;
margin-bottom: 16px;
}
.payment-success-title {
margin-bottom: 8px;
}
.payment-success-desc {
color: #64748b;
margin-bottom: 24px;
}
.payment-done-btn {
padding: 12px 28px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
}
.market-card-header {
display: flex;
align-items: center;

View File

@ -482,14 +482,6 @@
font-size: 0.9rem;
color: #94a3b8;
}
.tier-badge-paid {
background: #fef3c7;
color: #92400e;
border-radius: 20px;
padding: 4px 14px;
font-size: 0.8rem;
font-weight: 600;
}
.tier-badge-tier {
background: #f1f5f9;
color: #475569;

View File

@ -59,6 +59,62 @@
display: flex;
flex-direction: column;
gap: 10px;
min-width: 220px;
}
#profileActions .login-button {
box-sizing: border-box;
width: 100%;
}
.profile-tier-list {
background: #ffffff;
border: 1px solid #eef2f7;
border-radius: 16px;
display: grid;
gap: 10px;
margin-top: 6px;
padding: 14px;
}
.profile-tier-list h3 {
font-size: 0.95rem;
margin: 0 0 4px;
}
.profile-tier-option {
align-items: center;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
color: #334155;
cursor: pointer;
display: flex;
gap: 12px;
justify-content: space-between;
padding: 10px 12px;
text-align: left;
}
.profile-tier-option.active {
background: #eef2ff;
border-color: #818cf8;
color: #2563eb;
}
.profile-tier-option strong,
.profile-tier-option small {
display: block;
}
.profile-tier-option small {
color: #64748b;
font-size: 0.75rem;
margin-top: 2px;
}
.profile-tier-option b {
white-space: nowrap;
}
/* ── Profile tabs ────────────────────────────────────────────────────── */
@ -162,16 +218,32 @@
}
/* Share button: darker background and text */
.profile-header button:last-child {
#shareProfileButton {
background: #cbd5e1 !important; /* darker gray */
color: #1e293b !important;
box-shadow: none !important;
border: none !important;
}
#manageTiersButton {
background: #f3e8ff !important;
color: #7c3aed !important;
box-shadow: none !important;
border: 1px solid #d8b4fe !important;
}
/* Buttons keep rounded corners */
.login-button {
align-items: center;
border-radius: 14px !important;
display: flex;
gap: 8px;
justify-content: center;
text-decoration: none;
}
.login-button i {
font-size: 1rem;
}
.profile-tab {

View File

@ -172,8 +172,45 @@
}
@media (max-width: 700px) {
.sidebar-shell {
height: auto;
padding: 7px 6px;
border-right: none;
border-top: 1px solid #eef2f7;
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.08);
}
.sidebar-logo,
.sidebar-bottom {
display: none;
}
.sidebar ul {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 1px;
}
.sidebar li {
margin: 0;
}
.sidebar li.mobile-hidden {
display: none;
}
.sidebar a,
.sidebar-logout {
padding: 10px;
min-height: 46px;
padding: 7px 2px;
}
.sidebar i {
font-size: 1.05rem;
}
.sidebar a.active {
border-right: none;
border-top: 3px solid #3b82f6;
}
}

View File

@ -0,0 +1,271 @@
/* Subscription tiers page - manage monthly creator access levels */
.tiers-main {
flex: 1;
padding: 20px 32px;
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
.tiers-header {
text-align: center;
margin-bottom: 28px;
}
.tiers-header h1 {
font-size: 2rem;
font-weight: 800;
margin-bottom: 8px;
}
.tiers-header p {
color: #64748b;
}
.tiers-layout {
display: grid;
grid-template-columns: minmax(280px, 380px) 1fr;
gap: 24px;
align-items: start;
}
.tiers-tabs {
border-bottom: 2px solid #e5e7eb;
display: flex;
gap: 24px;
margin-bottom: 24px;
}
.tiers-tab {
background: transparent;
border: none;
border-bottom: 3px solid transparent;
color: #64748b;
cursor: pointer;
font: inherit;
font-weight: 800;
padding: 10px 0;
}
.tiers-tab.active {
border-bottom-color: #3b82f6;
color: #2563eb;
}
.subscriptions-panel {
display: none;
}
.tier-panel,
.tier-card {
background: #ffffff;
border: 1px solid #eef2f7;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
}
.tier-panel {
padding: 24px;
}
.tier-panel h2,
.tier-list-header h2 {
font-size: 1.25rem;
margin-bottom: 16px;
}
.tier-form {
display: grid;
gap: 16px;
}
.tier-form label {
display: grid;
gap: 8px;
color: #334155;
font-weight: 700;
}
.tier-form input,
.tier-form textarea {
border: 1px solid #dbe2ea;
border-radius: 12px;
font: inherit;
padding: 12px 14px;
}
.tier-form textarea {
min-height: 88px;
resize: vertical;
}
.tier-form input:focus,
.tier-form textarea:focus {
border-color: #8b5cf6;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.12);
outline: none;
}
.tier-form-actions {
display: flex;
gap: 12px;
}
.tier-primary-btn,
.tier-secondary-btn,
.tier-card button {
border: none;
border-radius: 14px;
cursor: pointer;
font: inherit;
font-weight: 800;
padding: 12px 16px;
}
.tier-primary-btn {
background: var(--gradient);
color: #ffffff;
flex: 1;
}
.tier-secondary-btn {
background: #f1f5f9;
color: #334155;
}
#tier-status {
color: #64748b;
min-height: 20px;
}
.tier-list-header {
margin-bottom: 16px;
}
.tier-list-header p {
color: #64748b;
}
.tiers-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.subscriptions-grid {
display: grid;
gap: 14px;
}
.subscription-card {
align-items: center;
background: #ffffff;
border: 1px solid #eef2f7;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
display: flex;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
}
.subscription-card h3 {
margin-bottom: 4px;
}
.subscription-card p {
color: #64748b;
}
.subscription-price {
color: #3b82f6;
font-weight: 900;
white-space: nowrap;
}
.tier-card {
padding: 20px;
}
.tier-card-top {
align-items: start;
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.tier-card h3 {
font-size: 1.2rem;
margin-bottom: 4px;
}
.tier-level {
color: #64748b;
font-size: 0.9rem;
font-weight: 700;
}
.tier-price {
color: #3b82f6;
font-size: 1.4rem;
font-weight: 900;
white-space: nowrap;
}
.tier-desc {
color: #475569;
line-height: 1.45;
min-height: 44px;
}
.tier-card-actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.tier-card button {
background: #f1f5f9;
color: #334155;
padding: 9px 12px;
}
.tier-delete-btn {
color: #dc2626 !important;
}
.tiers-empty,
.tiers-error {
background: #ffffff;
border-radius: 18px;
color: #64748b;
padding: 32px;
text-align: center;
}
.tiers-error {
color: #ef4444;
}
@media (max-width: 900px) {
.tiers-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 700px) {
.tiers-main {
padding: 16px;
}
.tiers-tabs {
gap: 16px;
}
.subscription-card {
align-items: flex-start;
flex-direction: column;
}
}

View File

@ -1,128 +1,143 @@
/*
Topbar styles for OnlyPrompt
- clean, modern, full-width
- search bar centered (expands on full screen), profile avatar always on the right
- ONLY search bar and avatar have rounded corners
- sticky on all app pages
- search bar fills the available width
- logout, messages and profile avatar stay on the right
*/
.topbar-shell {
width: 100%;
align-items: center;
background: #ffffff;
border-bottom: 1px solid #eef2f7;
padding: 16px 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
box-sizing: border-box;
display: flex;
gap: 18px;
justify-content: space-between;
padding: 16px 32px;
position: sticky;
top: 0;
width: 100%;
z-index: 90;
}
.topbar-search {
align-items: center;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 14px;
display: flex;
flex: 1;
gap: 12px;
max-width: none;
padding: 10px 20px;
}
.topbar-search i {
color: #94a3b8;
font-size: 1.3rem;
}
.topbar-search input {
background: transparent;
border: none;
color: #334155;
font-size: 0.95rem;
outline: none;
width: 100%;
}
.topbar-search input::placeholder {
color: #94a3b8;
font-weight: 400;
}
.topbar-actions {
align-items: center;
display: flex;
gap: 16px;
}
.topbar-logout-form {
display: flex;
margin: 0;
}
.topbar-icon-btn {
align-items: center;
background: transparent;
border: none;
border-radius: 50%;
color: #475569;
cursor: pointer;
display: flex;
font-size: 1.4rem;
justify-content: center;
padding: 8px;
transition: background 0.2s, color 0.2s;
}
.topbar-icon-btn:hover {
background: #f1f5f9;
color: #3b82f6;
}
.topbar-avatar-btn {
align-items: center;
background: transparent;
border: none;
cursor: pointer;
display: flex;
justify-content: center;
padding: 0;
}
.topbar-avatar {
border: 1px solid #e2e8f0;
border-radius: 50%;
height: 48px;
object-fit: cover;
width: 48px;
}
@media (max-width: 768px) {
.topbar-shell {
gap: 12px;
padding: 12px 20px;
}
.topbar-search {
flex: 1; /* Takes all available space */
max-width: none; /* No upper limit, expands freely */
display: flex;
align-items: center;
gap: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 10px 20px;
border-radius: 14px; /* Rounded like login inputs */
padding: 8px 16px;
}
.topbar-search i {
color: #94a3b8;
font-size: 1.3rem;
}
.topbar-search input {
width: 100%;
border: none;
outline: none;
background: transparent;
font-size: 0.95rem;
color: #334155;
}
.topbar-search input::placeholder {
color: #94a3b8;
font-weight: 400;
}
/* Icons and avatar container */
.topbar-actions {
display: flex;
align-items: center;
gap: 16px;
}
.topbar-icon-btn {
background: transparent;
border: none;
font-size: 1.4rem;
color: #475569;
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition:
background 0.2s,
color 0.2s;
}
.topbar-icon-btn:hover {
background: #f1f5f9;
color: #3b82f6;
}
.topbar-avatar-btn {
border: none;
background: transparent;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
}
.topbar-avatar {
width: 48px;
height: 48px;
object-fit: cover;
border: 1px solid #e2e8f0;
border-radius: 50%; /* Avatar round */
height: 40px;
width: 40px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.topbar-shell {
padding: 12px 20px;
}
.topbar-search {
padding: 8px 16px;
}
.topbar-search i {
font-size: 1.1rem;
}
.topbar-avatar {
width: 40px;
height: 40px;
}
.topbar-icon-btn {
font-size: 1.2rem;
padding: 6px;
}
.topbar-actions {
gap: 8px;
}
.topbar-icon-btn {
font-size: 1.2rem;
padding: 6px;
}
@media (max-width: 480px) {
.topbar-shell {
padding: 10px 16px;
}
.topbar-search {
padding: 6px 12px;
}
.topbar-actions {
gap: 8px;
}
}
@media (max-width: 480px) {
.topbar-shell {
padding: 10px 12px;
}
.topbar-search {
padding: 6px 10px;
}
.topbar-actions {
gap: 4px;
}
}

View File

@ -148,7 +148,7 @@
${prompt.exampleImageUrl ? `<img class="post-image${locked ? " post-image-locked" : ""}" src="${prompt.exampleImageUrl}" alt="${prompt.title}">` : `<img class="post-image${locked ? " post-image-locked" : ""}" src="${feedImg(prompt.id)}" alt="${prompt.title}">`}
<h3 class="post-title">${prompt.title}</h3>
<p class="post-description">${prompt.description || ""}</p>
${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill"></i> ${prompt.tierName ?? "Paid"} tier required</p>` : ""}
${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill"></i> ${prompt.tierName ?? "Subscription"} tier required</p>` : ""}
${renderStars(prompt.averageRating)}
</div>
<div class="post-actions">

View File

@ -1,4 +1,4 @@
<!-- OnlyPrompt - Marketplace page: dynamic prompts, category filter, sort, crypto payment modal -->
<!-- OnlyPrompt - Marketplace page: dynamic prompts, category filter, sort and tier access -->
<!doctype html>
<html lang="en">
@ -17,47 +17,6 @@
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">
@ -88,8 +47,8 @@
<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>
<option value="price|true">Lowest Tier Price</option>
<option value="price|false">Highest Tier Price</option>
</select>
</div>
@ -113,90 +72,6 @@
</div>
</div>
<!-- ── Payment Modal ─────────────────────────────────────────────── -->
<div id="payment-overlay">
<div class="payment-modal">
<button onclick="closePayment()" class="payment-close-btn">
&#x2715;
</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">
&#8592; 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 12 minutes.
</p>
<button onclick="closePayment()" class="payment-done-btn">
Done
</button>
</div>
</div>
</div>
<script type="module">
// ── Sidebar & Topbar ──────────────────────────────────────────────
fetch("/sidebar.html")
@ -250,20 +125,17 @@
}
function promptPrice(prompt) {
if (prompt.price != null && Number(prompt.price) > 0) {
return `$${Number(prompt.price).toFixed(2)}`;
if (prompt.tierLevel) {
const price = prompt.tierMonthlyPrice == null
? ""
: ` - $${Number(prompt.tierMonthlyPrice).toFixed(2)}/mo`;
return `${prompt.tierName || `Tier ${prompt.tierLevel}`}${price}`;
}
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;
if (prompt.tierMonthlyPrice != null) return Number(prompt.tierMonthlyPrice);
return 0;
}
@ -295,8 +167,7 @@
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 locked = p.canAccess === false;
const img =
p.exampleImageUrl ||
p._img ||
@ -317,7 +188,7 @@
<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-locked" onclick='subscribeToPromptTier(${JSON.stringify(p)})'><i class="bi bi-lock-fill"></i> Subscribe</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>`
}
${
@ -439,92 +310,26 @@
topbarObserver.observe(document.body, { childList: true, subtree: true });
wireMarketplaceTopbarSearch();
// Make openPayment global
window.openPayment = openPayment;
window.subscribeToPromptTier = async function (prompt) {
if (!prompt.tierLevel) return;
// ── Payment Modal ──────────────────────────────────────────────────
const CRYPTO_ADDRESSES = {
btc: "1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf1N",
eth: "0x742d35Cc6634C0532925a3b8D4C9B8E4D8F2b1a",
sol: "7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV1",
usdt: "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
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();
}
};
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();

View File

@ -238,10 +238,11 @@
// Tier badge
const badge = document.getElementById("tier-badge");
if (p.price != null && Number(p.price) > 0) {
badge.innerHTML = `<span class="tier-badge-paid">$${Number(p.price).toFixed(2)}</span>`;
} else if (p.tierName) {
badge.innerHTML = `<span class="tier-badge-tier"><i class="bi bi-lock-fill"></i> ${p.tierName}</span>`;
if (p.tierName) {
const price = p.tierMonthlyPrice == null
? ""
: ` - $${Number(p.tierMonthlyPrice).toFixed(2)}/mo`;
badge.innerHTML = `<span class="tier-badge-tier"><i class="bi bi-lock-fill"></i> ${p.tierName}${price}</span>`;
} else {
badge.innerHTML = `<span class="tier-badge-free">Free</span>`;
}
@ -275,6 +276,7 @@
document.getElementById("locked-tier-name").textContent = p.tierName
? `— ${p.tierName}`
: "";
setupLockedSubscription(p);
}
document.getElementById("detail-loading").style.display = "none";
@ -282,6 +284,38 @@
scrollToHashSection();
}
function setupLockedSubscription(prompt) {
const button = document.getElementById("locked-subscribe-btn");
if (!button) return;
button.disabled = prompt.tierLevel == null;
button.onclick = async () => {
if (prompt.tierLevel == null) return;
button.disabled = true;
button.textContent = "Subscribing...";
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) {
button.disabled = false;
button.textContent = await response.text();
return;
}
location.reload();
};
}
function scrollToHashSection() {
if (!location.hash) return;

View File

@ -58,11 +58,22 @@
class="login-button"
onclick="location.href = 'settings.html'"
>
<i class="bi bi-gear"></i>
Edit Profile
</button>
<button id="shareProfileButton" class="login-button">
<i class="bi bi-share"></i>
Share Profile
</button>
<a
id="manageTiersButton"
class="login-button"
href="subscription-tiers.html"
>
<i class="bi bi-gem"></i>
Manage Tiers
</a>
<div id="creatorTierList"></div>
</div>
</section>
@ -145,6 +156,8 @@
"primaryProfileButton",
);
const shareProfileButton = document.getElementById("shareProfileButton");
const manageTiersButton = document.getElementById("manageTiersButton");
const creatorTierList = document.getElementById("creatorTierList");
const profileTabs = document.querySelector(".profile-tabs");
const params = new URLSearchParams(location.search);
const profileId = params.get("id");
@ -156,6 +169,8 @@
let isOwnProfile = !profileId;
let profileLoaded = false;
let currentIsFollowing = false;
let currentSubscriptionTier = null;
let creatorSubscriptionTiers = [];
async function fetchJson(url) {
const response = await fetch(url, { credentials: "same-origin" });
@ -332,17 +347,21 @@
function updateProfileMode() {
if (isOwnProfile) {
profileActions.style.display = "flex";
primaryProfileButton.textContent = "Edit Profile";
primaryProfileButton.innerHTML = '<i class="bi bi-gear"></i> Edit Profile';
primaryProfileButton.disabled = false;
primaryProfileButton.onclick = () =>
(location.href = "settings.html");
manageTiersButton.style.display = "flex";
profileTabs.style.display = "flex";
return;
}
profileActions.style.display = "flex";
manageTiersButton.style.display = "none";
primaryProfileButton.textContent = currentIsFollowing
? "Following"
? currentSubscriptionTier
? `Subscribed: ${currentSubscriptionTier.name}`
: "Following"
: "Follow";
primaryProfileButton.disabled = false;
primaryProfileButton.onclick = toggleProfileFollow;
@ -350,6 +369,45 @@
savedTab.style.display = "none";
myPromptsTab.textContent = `Prompts (${profilePrompts.length})`;
renderPromptList(profilePrompts, "No prompts yet.");
renderCreatorTiers();
}
function renderCreatorTiers() {
if (isOwnProfile) {
creatorTierList.innerHTML = "";
return;
}
if (!creatorSubscriptionTiers.length) {
creatorTierList.innerHTML = "";
return;
}
creatorTierList.innerHTML = `
<div class="profile-tier-list">
<h3>Subscription Tiers</h3>
${creatorSubscriptionTiers
.map(
(tier) => `
<button type="button" class="profile-tier-option ${currentSubscriptionTier?.level === tier.level ? "active" : ""}" data-tier-level="${tier.level}">
<span>
<strong>${tier.name}</strong>
<small>Level ${tier.level}</small>
</span>
<b>$${Number(tier.monthlyPrice || 0).toFixed(2)}/mo</b>
</button>
`,
)
.join("")}
</div>`;
creatorTierList
.querySelectorAll("[data-tier-level]")
.forEach((button) => {
button.addEventListener("click", () =>
subscribeToTier(Number(button.dataset.tierLevel)),
);
});
}
async function loadFollowState() {
@ -369,9 +427,33 @@
const subscription = response.ok ? await response.json() : null;
currentIsFollowing = subscription !== null;
currentSubscriptionTier = subscription?.currentTier || null;
updateProfileMode();
} catch {
currentIsFollowing = false;
currentSubscriptionTier = null;
}
}
async function loadCreatorTiers() {
if (isOwnProfile || !profileId) return;
try {
const response = await fetch(
`/api/v1/subscriptions/tiers/${encodeURIComponent(profileId)}`,
{
credentials: "same-origin",
},
);
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) return;
creatorSubscriptionTiers = await response.json();
renderCreatorTiers();
} catch {
creatorSubscriptionTiers = [];
}
}
@ -397,6 +479,7 @@
profileSubscribers.textContent || 0,
);
currentIsFollowing = !currentIsFollowing;
if (!currentIsFollowing) currentSubscriptionTier = null;
profileSubscribers.textContent = Math.max(
0,
currentSubscribers + (currentIsFollowing ? 1 : -1),
@ -407,6 +490,43 @@
}
}
async function subscribeToTier(level) {
if (!profileId) return;
creatorTierList
.querySelectorAll("button")
.forEach((button) => (button.disabled = true));
const response = await fetch(
`/api/v1/subscriptions/${encodeURIComponent(profileId)}/${level}`,
{
method: "PUT",
credentials: "same-origin",
},
);
if (response.status === 401) {
location.href = "/login";
return;
}
creatorTierList
.querySelectorAll("button")
.forEach((button) => (button.disabled = false));
if (!response.ok) return;
const wasFollowing = currentIsFollowing;
currentIsFollowing = true;
currentSubscriptionTier =
creatorSubscriptionTiers.find((tier) => tier.level === level) || null;
if (!wasFollowing) {
profileSubscribers.textContent =
Number(profileSubscribers.textContent || 0) + 1;
}
updateProfileMode();
}
shareProfileButton.addEventListener("click", async () => {
const url = isOwnProfile
? `${location.origin}/profile.html`
@ -414,9 +534,9 @@
try {
await navigator.clipboard.writeText(url);
shareProfileButton.textContent = "Copied";
shareProfileButton.innerHTML = '<i class="bi bi-check2"></i> Copied';
setTimeout(
() => (shareProfileButton.textContent = "Share Profile"),
() => (shareProfileButton.innerHTML = '<i class="bi bi-share"></i> Share Profile'),
1200,
);
} catch {
@ -475,6 +595,7 @@
await loadProfile();
await loadCreatorCardFallback();
await loadFollowState();
await loadCreatorTiers();
updateProfileMode();
if (isOwnProfile) {
loadOwnPrompts();

View File

@ -14,54 +14,61 @@
<!-- Navigation -->
<nav class="sidebar">
<ul>
<li>
<li class="mobile-nav-item">
<a href="dashboard.html" class="active">
<i class="bi bi-house icon-blue"></i>
<span class="nav-text">Dashboard</span>
</a>
</li>
<li>
<li class="mobile-nav-item">
<a href="marketplace.html">
<i class="bi bi-shop icon-purple"></i>
<span class="nav-text">Marketplace</span>
</a>
</li>
<li>
<li class="mobile-nav-item">
<a href="community.html">
<i class="bi bi-people icon-pink"></i>
<span class="nav-text">Community</span>
</a>
</li>
<li>
<li class="mobile-nav-item">
<a href="chats.html">
<i class="bi bi-chat-dots icon-blue"></i>
<span class="nav-text">Chats</span>
</a>
</li>
<li>
<li class="mobile-nav-item">
<a href="settings.html">
<i class="bi bi-gear icon-purple"></i>
<span class="nav-text">Settings</span>
</a>
</li>
<li>
<li class="mobile-nav-item">
<a href="profile.html">
<i class="bi bi-person icon-pink"></i>
<span class="nav-text">My Profile</span>
</a>
</li>
<li>
<li class="mobile-nav-item">
<a href="create.html">
<i class="bi bi-plus-circle-fill icon-blue"></i>
<span class="nav-text">Create New</span>
</a>
</li>
<li class="mobile-nav-item">
<a href="subscription-tiers.html">
<i class="bi bi-gem icon-purple"></i>
<span class="nav-text">Subscriptions</span>
</a>
</li>
</ul>
</nav>

View File

@ -0,0 +1,379 @@
<!-- 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>
<div class="layout">
<div id="sidebar-container"></div>
<div class="page-body">
<div id="topbar-container"></div>
<main class="tiers-main">
<header class="tiers-header">
<h1>Subscription Tiers</h1>
<p>Create monthly access levels for your paid prompts.</p>
</header>
<nav class="tiers-tabs">
<button type="button" class="tiers-tab active" data-tab="manage">
My Tiers
</button>
<button type="button" class="tiers-tab" data-tab="subscriptions">
My Subscriptions
</button>
</nav>
<section class="tiers-layout" id="manage-tab-panel">
<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"></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">
<div class="tiers-empty">Loading tiers...</div>
</div>
</section>
</section>
<section class="subscriptions-panel" id="subscriptions-tab-panel">
<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">
<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"));
const tiersLink = document.querySelector(
'#sidebar-container a[href="subscription-tiers.html"]',
);
if (tiersLink) tiersLink.classList.add("active");
});
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);
});
manageTabPanel.style.display = tabName === "manage" ? "grid" : "none";
subscriptionsTabPanel.style.display =
tabName === "subscriptions" ? "block" : "none";
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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}">Edit</button>
<button type="button" data-delete="${tier.id}" class="tier-delete-btn">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>

View File

@ -1,7 +1,7 @@
<!--
Reusable topbar for OnlyPrompt
- Search in the middle
- small chat & notification icons
- small chat & logout icons
- profile avatar on the right
-->
@ -12,15 +12,17 @@
</div>
<div class="topbar-actions">
<button class="topbar-icon-btn" aria-label="Notifications">
<i class="bi bi-bell"></i>
</button>
<button class="topbar-icon-btn" aria-label="Messages">
<form action="/api/v1/auth/logout" method="post" class="topbar-logout-form">
<button class="topbar-icon-btn" type="submit" aria-label="Logout" title="Logout">
<i class="bi bi-box-arrow-right"></i>
</button>
</form>
<button class="topbar-icon-btn" type="button" aria-label="Messages" title="Messages" onclick="location.href='/chats.html'">
<i class="bi bi-chat-dots"></i>
</button>
<!-- Profile avatar on the right (must be changed with backend) -->
<button class="topbar-avatar-btn" aria-label="Profile">
<button class="topbar-avatar-btn" type="button" aria-label="Profile" title="Profile" onclick="location.href='/profile.html'">
<img id="topbarAvatar" src="../images/content/cat.png" alt="Profile Picture" class="topbar-avatar">
</button>
</div>

View File

@ -1,20 +1,22 @@
# OnlyPrompt - AI Prompt Marketplace
## Description
OnlyPrompt is a web application where users can create, publish, discover and interact with AI prompts. Users can edit their profiles, follow creators, like and save prompts, write reviews and browse free or paid prompt cards in a marketplace.
OnlyPrompt is a web application where users can create, publish, discover and interact with AI prompts. Users can edit their profiles, follow creators, like and save prompts, write reviews and browse free or subscription-based prompt cards in a marketplace.
This project is built with HTML, CSS and JavaScript.
## Special Features
- 📝 Create, edit and publish AI prompts
- 🔍 Browse prompts in a marketplace with category, search and price filters
- 🔍 Browse prompts in a marketplace with category, search and tier price filters
- 📄 View prompt detail pages with examples, ratings and access states
- 💎 Create creator subscription tiers and assign prompts to tier levels
- 🔐 Subscribe to creator tiers to unlock matching and lower-level prompts
- ⭐ Write reviews with star ratings and comments
- ❤️ Like and save prompts
- 👥 Follow and discover other creators
- 👤 Edit user profiles with display name, username, bio and profile picture
- 🌐 View own and public creator profiles
- 📱 Responsive layout for desktop and mobile
- 📱 Responsive layout for desktop and mobile, including a bottom icon navigation on smartphones
- 🔄 Server communication through a REST API
- 💾 Shared data persistence with backend and database
@ -53,12 +55,12 @@ AI tools were used as support during development, mainly for debugging, comparin
The project uses authentication with a JWT cookie so that protected pages and API endpoints require a logged-in user. User input is validated on the backend for important operations such as registration, profile updates, prompt creation and reviews.
Known limitations:
- Payment and premium access are simulated and are not connected to a real payment provider.
- Subscription access is simulated for the semester project and is not connected to a real payment provider.
- User-generated content is displayed in the frontend, so XSS prevention is important. The project avoids intentionally executing user input as code, but further output sanitization would be needed for production.
- Authentication is implemented for local project use and would need additional hardening for production.
## Reflection
A main challenge was connecting static frontend pages with dynamic backend data while keeping the application usable and consistent. During development, the project evolved from demo pages into a connected application with real profiles, prompts, reviews, likes, saves and creator interactions. We learned how important clear API structures, consistent data models and regular browser testing are when multiple pages depend on the same shared data.
A main challenge was connecting static frontend pages with dynamic backend data while keeping the application usable and consistent. During development, the project evolved from demo pages into a connected application with real profiles, prompts, reviews, likes, saves, creator interactions and subscription tiers. We learned how important clear API structures, consistent data models, responsive navigation and regular browser testing are when multiple pages depend on the same shared data.
## Group members and their roles
| Name | Role |