feat(marketplace): add sponsor logo uploads
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
@@ -431,6 +431,16 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-logo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-edit, .btn-delete, .btn-duplicate {
|
.btn-edit, .btn-delete, .btn-duplicate {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -585,6 +595,105 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sponsor-form {
|
||||||
|
grid-template-columns: 200px repeat(2, minmax(220px, 1fr));
|
||||||
|
align-items: flex-start;
|
||||||
|
grid-auto-flow: dense;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-logo-group {
|
||||||
|
grid-row: span 3;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-two {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-upload {
|
||||||
|
position: relative;
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview.empty {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 12px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 0.35rem 1rem;
|
||||||
|
background: linear-gradient(135deg, var(--primary-cyan), var(--primary-teal));
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--bg-dark);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 6px 18px rgba(80, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn:hover {
|
||||||
|
box-shadow: 0 8px 22px rgba(80, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-upload input[type="file"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.sponsor-form {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-logo-group {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: auto;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-upload {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-two {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Rich Text Editor */
|
/* Rich Text Editor */
|
||||||
.editor-toolbar {
|
.editor-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
// Admin Dashboard - Smart & Powerful
|
// Admin Dashboard - Smart & Powerful
|
||||||
const API_BASE = '/api';
|
const { API_BASE, API_ORIGIN } = (() => {
|
||||||
|
const { hostname, port } = window.location;
|
||||||
|
if ((hostname === 'localhost' || hostname === '127.0.0.1') && port === '8000') {
|
||||||
|
const origin = 'http://127.0.0.1:8100';
|
||||||
|
return { API_BASE: `${origin}/api`, API_ORIGIN: origin };
|
||||||
|
}
|
||||||
|
return { API_BASE: '/api', API_ORIGIN: '' };
|
||||||
|
})();
|
||||||
|
|
||||||
|
const resolveAssetUrl = (path) => {
|
||||||
|
if (!path) return '';
|
||||||
|
if (/^https?:\/\//i.test(path)) return path;
|
||||||
|
if (path.startsWith('/') && API_ORIGIN) {
|
||||||
|
return `${API_ORIGIN}${path}`;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
class AdminDashboard {
|
class AdminDashboard {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -144,13 +160,19 @@ class AdminDashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async apiCall(endpoint, options = {}) {
|
async apiCall(endpoint, options = {}) {
|
||||||
|
const isFormData = options.body instanceof FormData;
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Bearer ${this.token}`,
|
||||||
|
...options.headers
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isFormData && !headers['Content-Type']) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers
|
||||||
'Authorization': `Bearer ${this.token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -189,7 +211,10 @@ class AdminDashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadSponsors() {
|
async loadSponsors() {
|
||||||
this.data.sponsors = await this.apiCall('/sponsors');
|
const cacheBuster = Date.now();
|
||||||
|
this.data.sponsors = await this.apiCall(`/sponsors?limit=100&_=${cacheBuster}`, {
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
this.renderSponsorsTable(this.data.sponsors);
|
this.renderSponsorsTable(this.data.sponsors);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +339,7 @@ class AdminDashboard {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
|
<th>Logo</th>
|
||||||
<th>Company</th>
|
<th>Company</th>
|
||||||
<th>Tier</th>
|
<th>Tier</th>
|
||||||
<th>Start</th>
|
<th>Start</th>
|
||||||
@@ -326,6 +352,7 @@ class AdminDashboard {
|
|||||||
${sponsors.map(sponsor => `
|
${sponsors.map(sponsor => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${sponsor.id}</td>
|
<td>${sponsor.id}</td>
|
||||||
|
<td>${sponsor.logo_url ? `<img class="table-logo" src="${resolveAssetUrl(sponsor.logo_url)}" alt="${sponsor.company_name} logo">` : '-'}</td>
|
||||||
<td>${sponsor.company_name}</td>
|
<td>${sponsor.company_name}</td>
|
||||||
<td>${sponsor.tier}</td>
|
<td>${sponsor.tier}</td>
|
||||||
<td>${new Date(sponsor.start_date).toLocaleDateString()}</td>
|
<td>${new Date(sponsor.start_date).toLocaleDateString()}</td>
|
||||||
@@ -389,6 +416,10 @@ class AdminDashboard {
|
|||||||
|
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove('hidden');
|
||||||
modal.dataset.type = type;
|
modal.dataset.type = type;
|
||||||
|
|
||||||
|
if (type === 'sponsors') {
|
||||||
|
this.setupLogoUploadHandlers();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAppForm(app) {
|
getAppForm(app) {
|
||||||
@@ -524,9 +555,22 @@ class AdminDashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSponsorForm(sponsor) {
|
getSponsorForm(sponsor) {
|
||||||
|
const existingFile = sponsor?.logo_url ? sponsor.logo_url.split('/').pop().split('?')[0] : '';
|
||||||
return `
|
return `
|
||||||
<div class="form-grid">
|
<div class="form-grid sponsor-form">
|
||||||
<div class="form-group">
|
<div class="form-group sponsor-logo-group">
|
||||||
|
<label>Logo</label>
|
||||||
|
<input type="hidden" id="form-logo-url" value="${sponsor?.logo_url || ''}">
|
||||||
|
<div class="logo-upload">
|
||||||
|
<div class="image-preview ${sponsor?.logo_url ? '' : 'empty'}" id="form-logo-preview">
|
||||||
|
${sponsor?.logo_url ? `<img src="${resolveAssetUrl(sponsor.logo_url)}" alt="Logo preview">` : '<span>No logo uploaded</span>'}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="upload-btn" id="form-logo-button">Upload Logo</button>
|
||||||
|
<input type="file" id="form-logo-file" accept="image/png,image/jpeg,image/webp,image/svg+xml" hidden>
|
||||||
|
</div>
|
||||||
|
<p class="upload-hint" id="form-logo-filename">${existingFile ? `Current: ${existingFile}` : 'No file selected'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group span-two">
|
||||||
<label>Company Name *</label>
|
<label>Company Name *</label>
|
||||||
<input type="text" id="form-name" value="${sponsor?.company_name || ''}" required>
|
<input type="text" id="form-name" value="${sponsor?.company_name || ''}" required>
|
||||||
</div>
|
</div>
|
||||||
@@ -567,9 +611,30 @@ class AdminDashboard {
|
|||||||
async saveItem() {
|
async saveItem() {
|
||||||
const modal = document.getElementById('form-modal');
|
const modal = document.getElementById('form-modal');
|
||||||
const type = modal.dataset.type;
|
const type = modal.dataset.type;
|
||||||
const data = this.collectFormData(type);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (type === 'sponsors') {
|
||||||
|
const fileInput = document.getElementById('form-logo-file');
|
||||||
|
if (fileInput && fileInput.files && fileInput.files[0]) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', fileInput.files[0]);
|
||||||
|
formData.append('folder', 'sponsors');
|
||||||
|
|
||||||
|
const uploadResponse = await this.apiCall('/admin/upload-image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.url) {
|
||||||
|
throw new Error('Image upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('form-logo-url').value = uploadResponse.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = this.collectFormData(type);
|
||||||
|
|
||||||
if (this.editingItem) {
|
if (this.editingItem) {
|
||||||
await this.apiCall(`/admin/${type}/${this.editingItem.id}`, {
|
await this.apiCall(`/admin/${type}/${this.editingItem.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -624,6 +689,7 @@ class AdminDashboard {
|
|||||||
data.order_index = parseInt(document.getElementById('form-order').value);
|
data.order_index = parseInt(document.getElementById('form-order').value);
|
||||||
} else if (type === 'sponsors') {
|
} else if (type === 'sponsors') {
|
||||||
data.company_name = document.getElementById('form-name').value;
|
data.company_name = document.getElementById('form-name').value;
|
||||||
|
data.logo_url = document.getElementById('form-logo-url').value;
|
||||||
data.tier = document.getElementById('form-tier').value;
|
data.tier = document.getElementById('form-tier').value;
|
||||||
data.landing_url = document.getElementById('form-landing').value;
|
data.landing_url = document.getElementById('form-landing').value;
|
||||||
data.banner_url = document.getElementById('form-banner').value;
|
data.banner_url = document.getElementById('form-banner').value;
|
||||||
@@ -635,6 +701,63 @@ class AdminDashboard {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupLogoUploadHandlers() {
|
||||||
|
const fileInput = document.getElementById('form-logo-file');
|
||||||
|
const preview = document.getElementById('form-logo-preview');
|
||||||
|
const logoUrlInput = document.getElementById('form-logo-url');
|
||||||
|
const trigger = document.getElementById('form-logo-button');
|
||||||
|
const fileNameEl = document.getElementById('form-logo-filename');
|
||||||
|
|
||||||
|
if (!fileInput || !preview || !logoUrlInput) return;
|
||||||
|
|
||||||
|
const setFileName = (text) => {
|
||||||
|
if (fileNameEl) {
|
||||||
|
fileNameEl.textContent = text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setEmptyState = () => {
|
||||||
|
preview.innerHTML = '<span>No logo uploaded</span>';
|
||||||
|
preview.classList.add('empty');
|
||||||
|
setFileName('No file selected');
|
||||||
|
};
|
||||||
|
|
||||||
|
const setExistingState = () => {
|
||||||
|
if (logoUrlInput.value) {
|
||||||
|
const existingFile = logoUrlInput.value.split('/').pop().split('?')[0];
|
||||||
|
preview.innerHTML = `<img src="${resolveAssetUrl(logoUrlInput.value)}" alt="Logo preview">`;
|
||||||
|
preview.classList.remove('empty');
|
||||||
|
setFileName(existingFile ? `Current: ${existingFile}` : 'Current logo');
|
||||||
|
} else {
|
||||||
|
setEmptyState();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setExistingState();
|
||||||
|
|
||||||
|
if (trigger) {
|
||||||
|
trigger.onclick = () => fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (event) => {
|
||||||
|
const file = event.target.files && event.target.files[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
setExistingState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFileName(file.name);
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
preview.innerHTML = `<img src="${reader.result}" alt="Logo preview">`;
|
||||||
|
preview.classList.remove('empty');
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async deleteItem(type, id) {
|
async deleteItem(type, id) {
|
||||||
if (!confirm(`Are you sure you want to delete this ${type.slice(0, -1)}?`)) return;
|
if (!confirm(`Are you sure you want to delete this ${type.slice(0, -1)}?`)) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from fastapi import FastAPI, HTTPException, Query, Depends, Body
|
from fastapi import FastAPI, HTTPException, Query, Depends, Body, UploadFile, File, Form
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
from database import DatabaseManager
|
from database import DatabaseManager
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
@@ -31,6 +33,21 @@ app.add_middleware(
|
|||||||
# Initialize database with configurable path
|
# Initialize database with configurable path
|
||||||
db = DatabaseManager(Config.DATABASE_PATH)
|
db = DatabaseManager(Config.DATABASE_PATH)
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
UPLOAD_ROOT = BASE_DIR / "uploads"
|
||||||
|
UPLOAD_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
app.mount("/uploads", StaticFiles(directory=UPLOAD_ROOT), name="uploads")
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_TYPES = {
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"image/svg+xml": ".svg"
|
||||||
|
}
|
||||||
|
ALLOWED_UPLOAD_FOLDERS = {"sponsors"}
|
||||||
|
MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2 MB
|
||||||
|
|
||||||
def json_response(data, cache_time=3600):
|
def json_response(data, cache_time=3600):
|
||||||
"""Helper to return JSON with cache headers"""
|
"""Helper to return JSON with cache headers"""
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -183,6 +200,31 @@ def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
|||||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/upload-image", dependencies=[Depends(verify_token)])
|
||||||
|
async def upload_image(file: UploadFile = File(...), folder: str = Form("sponsors")):
|
||||||
|
"""Upload image files for admin assets"""
|
||||||
|
folder = (folder or "").strip().lower()
|
||||||
|
if folder not in ALLOWED_UPLOAD_FOLDERS:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid upload folder")
|
||||||
|
|
||||||
|
if file.content_type not in ALLOWED_IMAGE_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail="Unsupported file type")
|
||||||
|
|
||||||
|
contents = await file.read()
|
||||||
|
if len(contents) > MAX_UPLOAD_SIZE:
|
||||||
|
raise HTTPException(status_code=400, detail="File too large (max 2MB)")
|
||||||
|
|
||||||
|
extension = ALLOWED_IMAGE_TYPES[file.content_type]
|
||||||
|
filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{secrets.token_hex(8)}{extension}"
|
||||||
|
|
||||||
|
target_dir = UPLOAD_ROOT / folder
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_path = target_dir / filename
|
||||||
|
target_path.write_bytes(contents)
|
||||||
|
|
||||||
|
return {"url": f"/uploads/{folder}/{filename}"}
|
||||||
|
|
||||||
@app.post("/api/admin/login")
|
@app.post("/api/admin/login")
|
||||||
async def admin_login(password: str = Body(..., embed=True)):
|
async def admin_login(password: str = Body(..., embed=True)):
|
||||||
"""Admin login with password"""
|
"""Admin login with password"""
|
||||||
|
|||||||
2
docs/md_v2/marketplace/backend/uploads/.gitignore
vendored
Normal file
2
docs/md_v2/marketplace/backend/uploads/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -410,6 +410,21 @@ a:hover {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sponsor-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 60px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-logo img {
|
||||||
|
max-height: 60px;
|
||||||
|
max-width: 100%;
|
||||||
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.sponsor-card h4 {
|
.sponsor-card h4 {
|
||||||
color: var(--accent-pink);
|
color: var(--accent-pink);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
// Marketplace JS - Magazine Layout
|
// Marketplace JS - Magazine Layout
|
||||||
const API_BASE = '/api';
|
const { API_BASE, API_ORIGIN } = (() => {
|
||||||
|
const { hostname, port } = window.location;
|
||||||
|
if ((hostname === 'localhost' || hostname === '127.0.0.1') && port === '8000') {
|
||||||
|
const origin = 'http://127.0.0.1:8100';
|
||||||
|
return { API_BASE: `${origin}/api`, API_ORIGIN: origin };
|
||||||
|
}
|
||||||
|
return { API_BASE: '/api', API_ORIGIN: '' };
|
||||||
|
})();
|
||||||
|
|
||||||
|
const resolveAssetUrl = (path) => {
|
||||||
|
if (!path) return '';
|
||||||
|
if (/^https?:\/\//i.test(path)) return path;
|
||||||
|
if (path.startsWith('/') && API_ORIGIN) {
|
||||||
|
return `${API_ORIGIN}${path}`;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
};
|
||||||
const CACHE_TTL = 3600000; // 1 hour in ms
|
const CACHE_TTL = 3600000; // 1 hour in ms
|
||||||
|
|
||||||
class MarketplaceCache {
|
class MarketplaceCache {
|
||||||
@@ -204,6 +220,7 @@ class MarketplaceUI {
|
|||||||
const container = document.getElementById('sponsored-content');
|
const container = document.getElementById('sponsored-content');
|
||||||
container.innerHTML = sponsors.slice(0, 5).map(sponsor => `
|
container.innerHTML = sponsors.slice(0, 5).map(sponsor => `
|
||||||
<div class="sponsor-card">
|
<div class="sponsor-card">
|
||||||
|
${sponsor.logo_url ? `<div class="sponsor-logo"><img src="${resolveAssetUrl(sponsor.logo_url)}" alt="${sponsor.company_name} logo"></div>` : ''}
|
||||||
<h4>${sponsor.company_name}</h4>
|
<h4>${sponsor.company_name}</h4>
|
||||||
<p>${sponsor.tier} Sponsor - Premium Solutions</p>
|
<p>${sponsor.tier} Sponsor - Premium Solutions</p>
|
||||||
<a href="${sponsor.landing_url}" target="_blank">Learn More →</a>
|
<a href="${sponsor.landing_url}" target="_blank">Learn More →</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user