Add subscription tiers and responsive navigation
This commit is contained in:
parent
289f7eebbe
commit
de1d3d2d63
51
API.md
51
API.md
@ -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
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
271
OnlyPrompt.Frontend/css/subscription-tiers.css
Normal file
271
OnlyPrompt.Frontend/css/subscription-tiers.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,31 +1,35 @@
|
||||
/*
|
||||
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 {
|
||||
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;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 12px;
|
||||
max-width: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 14px; /* Rounded like login inputs */
|
||||
}
|
||||
|
||||
.topbar-search i {
|
||||
@ -34,12 +38,12 @@
|
||||
}
|
||||
|
||||
.topbar-search input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.95rem;
|
||||
border: none;
|
||||
color: #334155;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topbar-search input::placeholder {
|
||||
@ -47,27 +51,29 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Icons and avatar container */
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.topbar-logout-form {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topbar-icon-btn {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
border-radius: 50%;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1.4rem;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
padding: 8px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.topbar-icon-btn:hover {
|
||||
@ -76,42 +82,47 @@
|
||||
}
|
||||
|
||||
.topbar-avatar-btn {
|
||||
border: none;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.topbar-avatar {
|
||||
width: 48px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 50%;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 50%; /* Avatar round */
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.topbar-shell {
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.topbar-search {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.topbar-search i {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.topbar-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.topbar-icon-btn {
|
||||
font-size: 1.2rem;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
@ -119,10 +130,14 @@
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.topbar-shell {
|
||||
padding: 10px 16px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.topbar-search {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
✕
|
||||
</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")
|
||||
@ -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 CRYPTO_NAMES = {
|
||||
btc: "Bitcoin (BTC)",
|
||||
eth: "Ethereum (ETH)",
|
||||
sol: "Solana (SOL)",
|
||||
usdt: "USDT TRC-20",
|
||||
};
|
||||
let currentPrompt = null;
|
||||
let currentCrypto = null;
|
||||
const response = await fetch(
|
||||
`/api/v1/subscriptions/${encodeURIComponent(prompt.creatorId)}/${prompt.tierLevel}`,
|
||||
{
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
},
|
||||
);
|
||||
|
||||
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";
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
|
||||
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`,
|
||||
if (response.ok) {
|
||||
loadPrompts();
|
||||
}
|
||||
};
|
||||
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();
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
379
OnlyPrompt.Frontend/subscription-tiers.html
Normal file
379
OnlyPrompt.Frontend/subscription-tiers.html
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
async function getFriendlyTierError(response) {
|
||||
const fallback = `Server error ${response.status}`;
|
||||
const text = await response.text();
|
||||
if (!text) return fallback;
|
||||
|
||||
try {
|
||||
const error = JSON.parse(text);
|
||||
const messages = error.errors
|
||||
? Object.values(error.errors).flat()
|
||||
: [error.title || fallback];
|
||||
|
||||
return messages
|
||||
.map((message) =>
|
||||
message === "Tier with this level already exists."
|
||||
? "A tier with this level already exists. Please choose another level."
|
||||
: message,
|
||||
)
|
||||
.join(" ");
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTiers() {
|
||||
try {
|
||||
const response = await fetch("/api/v1/subscriptions/tiers", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error(`Server error ${response.status}`);
|
||||
tiers = await response.json();
|
||||
renderTiers();
|
||||
if (!editingTierId) resetForm();
|
||||
} catch (error) {
|
||||
tiersGrid.innerHTML = `<div class="tiers-error">${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
const response = await fetch("/api/v1/subscriptions", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error(`Server error ${response.status}`);
|
||||
|
||||
subscriptions = await response.json();
|
||||
renderSubscriptions();
|
||||
} catch (error) {
|
||||
subscriptionsGrid.innerHTML = `<div class="tiers-error">${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubscriptions() {
|
||||
if (!subscriptions.length) {
|
||||
subscriptionsGrid.innerHTML =
|
||||
'<div class="tiers-empty">No subscriptions yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
subscriptionsGrid.innerHTML = subscriptions
|
||||
.map((subscription) => {
|
||||
const tier = subscription.currentTier;
|
||||
return `
|
||||
<article class="subscription-card">
|
||||
<div>
|
||||
<h3>${escapeHtml(subscription.subscribedToName)}</h3>
|
||||
<p>${tier ? `${escapeHtml(tier.name)} - Level ${tier.level}` : "Following without tier"}</p>
|
||||
</div>
|
||||
<div class="subscription-price">
|
||||
${tier ? `$${Number(tier.monthlyPrice || 0).toFixed(2)}/mo` : "Free"}
|
||||
</div>
|
||||
</article>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderTiers() {
|
||||
if (!tiers.length) {
|
||||
tiersGrid.innerHTML = '<div class="tiers-empty">No tiers yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
tiersGrid.innerHTML = tiers
|
||||
.map(
|
||||
(tier) => `
|
||||
<article class="tier-card">
|
||||
<div class="tier-card-top">
|
||||
<div>
|
||||
<h3>${escapeHtml(tier.name)}</h3>
|
||||
<div class="tier-level">Level ${tier.level}</div>
|
||||
</div>
|
||||
<div class="tier-price">$${Number(tier.monthlyPrice || 0).toFixed(2)}/mo</div>
|
||||
</div>
|
||||
<p class="tier-desc">${escapeHtml(tier.description || "No description yet.")}</p>
|
||||
<div class="tier-card-actions">
|
||||
<button type="button" data-edit="${tier.id}">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>
|
||||
@ -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>
|
||||
<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>
|
||||
<button class="topbar-icon-btn" aria-label="Messages">
|
||||
</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>
|
||||
|
||||
12
README.md
12
README.md
@ -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 |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user