Files
kashilo/js/components/pages/page-create.js

1035 lines
38 KiB
JavaScript

import { t, i18n } from '../../i18n.js'
import { router } from '../../router.js'
import { auth } from '../../services/auth.js'
import { directus } from '../../services/directus.js'
import { listingsService } from '../../services/listings.js'
import { categoriesService } from '../../services/categories.js'
import { SUPPORTED_CURRENCIES, getDisplayCurrency } from '../../services/currency.js'
import { createInvoice, openCheckout, getPendingInvoice, savePendingInvoice, clearPendingInvoice, getInvoiceStatus } from '../../services/btcpay.js'
import { cryptoService } from '../../services/crypto.js'
import { escapeHTML } from '../../utils/helpers.js'
import '../location-picker.js'
import '../pow-captcha.js'
import '../image-cropper.js'
const STORAGE_KEY = 'kashilo_create_draft'
class PageCreate extends HTMLElement {
constructor() {
super()
this.editMode = false
this.editId = null
this.editListing = null
this.existingImages = []
this.formData = this.loadDraft() || this.getEmptyFormData()
this.imageFiles = []
this.imagePreviews = []
this.categoryTree = []
this.submitting = false
this.isNewAccount = true
}
getEmptyFormData() {
// Use user's preferred currency from settings as default
const defaultCurrency = getDisplayCurrency()
// Map display currencies to listing currencies (XMR not for fiat listings)
const currency = (SUPPORTED_CURRENCIES.includes(defaultCurrency) && defaultCurrency !== 'XMR') ? defaultCurrency : 'EUR'
return {
title: '',
description: '',
price: '',
currency,
price_mode: 'fiat',
price_type: 'fixed',
category: '',
condition: 'good',
location: '',
shipping: false,
shipping_cost: '',
moneroAddress: ''
}
}
loadDraft() {
try {
const saved = localStorage.getItem(STORAGE_KEY)
return saved ? JSON.parse(saved) : null
} catch (e) {
return null
}
}
saveDraft() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.formData))
} catch (e) {
// Storage full or unavailable
}
}
clearDraft() {
localStorage.removeItem(STORAGE_KEY)
}
async connectedCallback() {
this._unsubs = []
// Check if logged in
if (!auth.isLoggedIn()) {
this.showLoginRequired()
return
}
// Check if edit mode
if (this.dataset.id) {
this.editMode = true
this.editId = this.dataset.id
await this.loadExistingListing()
} else {
this.hasDraft = !!localStorage.getItem(STORAGE_KEY)
}
await this.loadCategories()
await this.checkAccountStatus()
this.render()
this._unsubs.push(i18n.subscribe(() => this.render()))
}
async loadExistingListing() {
try {
const listing = await directus.getListing(this.editId)
// Verify ownership
const user = await auth.getUser()
if (listing.user_created !== user?.id) {
window.location.hash = '#/'
return
}
this.editListing = listing
this.formData = {
title: listing.title || '',
description: listing.description || '',
price: listing.price?.toString() || '',
currency: listing.currency || 'EUR',
price_mode: listing.price_mode || 'fiat',
price_type: listing.price_type || 'fixed',
category: listing.category?.id || listing.category || '',
condition: listing.condition || 'good',
location: listing.location?.id || listing.location || '',
shipping: listing.shipping || false,
shipping_cost: listing.shipping_cost?.toString() || '',
moneroAddress: listing.monero_address || '',
status: listing.status || 'published'
}
// Store existing images
this.existingImages = (listing.images || []).map(img => ({
id: img.id,
fileId: img.directus_files_id?.id || img.directus_files_id
}))
// Create previews for existing images
this.imagePreviews = this.existingImages.map(img =>
directus.getThumbnailUrl(img.fileId, 200)
)
} catch (e) {
console.error('Failed to load listing for edit:', e)
window.location.hash = '#/'
}
}
async checkAccountStatus() {
try {
// Check if user has any published listings
const user = await auth.getUser()
if (user?.id) {
const listings = await directus.get('/items/listings', {
filter: { user_created: { _eq: user.id }, status: { _eq: 'published' } },
limit: 1
})
this.isNewAccount = !listings.data || listings.data.length === 0
}
} catch (e) {
// Default to new account (show captcha)
this.isNewAccount = true
}
}
showLoginRequired() {
// Show login modal directly
const authModal = document.querySelector('auth-modal')
if (authModal) {
authModal.show('login')
authModal.addEventListener('login', async () => {
// After login, load the create page
this.hasDraft = !!localStorage.getItem(STORAGE_KEY)
await this.loadCategories()
this.render()
this._unsubs.push(i18n.subscribe(() => this.render()))
}, { once: true })
authModal.addEventListener('close', () => {
// If closed without login, go back
if (!auth.isLoggedIn()) {
router.back()
}
}, { once: true })
}
// Show minimal loading state while modal is open
this.innerHTML = /* html */`
<div class="create-page">
<div class="login-required">
<p>${t('auth.loginRequired')}</p>
</div>
</div>
`
}
async loadCategories() {
try {
this.categoryTree = await categoriesService.getTree()
} catch (e) {
console.error('Failed to load categories:', e)
this.categoryTree = []
}
}
validateMoneroAddress(address) {
if (!address) return false
// Standard addresses: start with 4, 95 chars
// Subaddresses: start with 8, 95 chars
// Integrated addresses: start with 4, 106 chars
const standardRegex = /^4[0-9A-Za-z]{94}$/
const subaddressRegex = /^8[0-9A-Za-z]{94}$/
const integratedRegex = /^4[0-9A-Za-z]{105}$/
return standardRegex.test(address) || subaddressRegex.test(address) || integratedRegex.test(address)
}
disconnectedCallback() {
this._unsubs.forEach(fn => fn())
this._unsubs = []
}
render() {
const pageTitle = this.editMode ? t('create.editTitle') : t('create.title')
this.innerHTML = /* html */`
<div class="create-page">
<h1>${pageTitle}</h1>
${!this.editMode && this.hasDraft ? `
<div class="draft-notice">
<span>${t('create.draftRestored')}</span>
<button type="button" class="btn-link" id="clear-draft-btn">${t('create.clearDraft')}</button>
</div>
` : ''}
<form id="create-form" class="create-form">
<div class="form-group">
<label class="label" for="title" data-i18n="create.listingTitle">${t('create.listingTitle')}</label>
<input
type="text"
class="input"
id="title"
name="title"
value="${escapeHTML(this.formData.title)}"
required
data-i18n-placeholder="create.titlePlaceholder"
placeholder="${t('create.titlePlaceholder')}"
>
</div>
<div class="form-group">
<label class="label" for="category">${t('create.category')}</label>
<select class="input" id="category" name="category" required>
<option value="">${t('create.selectCategory')}</option>
${(this.categoryTree || []).map(cat => `
<optgroup label="${categoriesService.getTranslatedName(cat)}">
${(cat.children || []).map(sub => `
<option value="${sub.id}" ${this.formData.category === sub.id ? 'selected' : ''}>
${categoriesService.getTranslatedName(sub)}
</option>
`).join('')}
</optgroup>
`).join('')}
</select>
</div>
<div class="form-group">
<label class="label" for="condition">${t('create.condition')}</label>
<select class="input" id="condition" name="condition" required>
<option value="new" ${this.formData.condition === 'new' ? 'selected' : ''}>${t('create.conditionNew')}</option>
<option value="like_new" ${this.formData.condition === 'like_new' ? 'selected' : ''}>${t('create.conditionLikeNew')}</option>
<option value="good" ${this.formData.condition === 'good' ? 'selected' : ''}>${t('create.conditionGood')}</option>
<option value="fair" ${this.formData.condition === 'fair' ? 'selected' : ''}>${t('create.conditionFair')}</option>
<option value="poor" ${this.formData.condition === 'poor' ? 'selected' : ''}>${t('create.conditionPoor')}</option>
</select>
</div>
<div class="form-row">
<div class="form-group form-group-price">
<label class="label" for="price">${t('create.price')}</label>
<input
type="number"
class="input"
id="price"
name="price"
value="${this.formData.price}"
min="0"
step="0.0001"
required
placeholder="0.00"
>
</div>
<div class="form-group form-group-currency">
<label class="label" for="currency">${t('create.currency')}</label>
<select class="input" id="currency" name="currency">
${SUPPORTED_CURRENCIES.map(cur => `
<option value="${cur}" ${this.formData.currency === cur ? 'selected' : ''}>${cur}</option>
`).join('')}
</select>
</div>
</div>
<div class="form-group">
<label class="label" for="price_mode">${t('create.priceMode')}</label>
<select class="input" id="price_mode" name="price_mode">
<option value="fiat" ${this.formData.price_mode === 'fiat' ? 'selected' : ''}>${t('create.priceModeFiat')}</option>
<option value="xmr" ${this.formData.price_mode === 'xmr' ? 'selected' : ''}>${t('create.priceModeXmr')}</option>
</select>
<p class="field-hint">${t('create.priceModeHint')}</p>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="shipping" name="shipping" ${this.formData.shipping ? 'checked' : ''}>
<span>${t('create.shippingAvailable')}</span>
</label>
</div>
<div class="form-group shipping-cost-group" id="shipping-cost-group" style="display: ${this.formData.shipping ? 'block' : 'none'}">
<label class="label" for="shipping_cost">${t('create.shippingCost')}</label>
<input
type="number"
class="input"
id="shipping_cost"
name="shipping_cost"
min="0"
step="0.01"
value="${this.formData.shipping_cost || ''}"
placeholder="${t('create.shippingCostPlaceholder')}"
>
</div>
<div class="form-group">
<label class="label" for="location" data-i18n="create.location">${t('create.location')}</label>
<location-picker
id="location-picker"
placeholder="${t('create.locationPlaceholder')}"
></location-picker>
<p class="field-hint">${t('create.locationHint') || 'Stadt oder PLZ eingeben'}</p>
</div>
<div class="form-group">
<label class="label" for="description" data-i18n="create.description">${t('create.description')}</label>
<textarea
class="input"
id="description"
name="description"
rows="5"
required
data-i18n-placeholder="create.descriptionPlaceholder"
placeholder="${t('create.descriptionPlaceholder')}"
>${escapeHTML(this.formData.description)}</textarea>
</div>
<div class="form-group">
<label class="label" data-i18n="create.images">${t('create.images')}</label>
<div class="image-upload">
<input type="file" id="images" name="images" accept="image/*" multiple hidden>
<label for="images" class="upload-area" id="upload-area">
<span class="upload-icon">📷</span>
<span data-i18n="create.uploadImages">${t('create.uploadImages')}</span>
</label>
<div class="image-previews" id="image-previews">
${this.renderImagePreviews()}
</div>
</div>
</div>
<div class="form-group">
<label class="label" for="moneroAddress">${t('create.moneroAddress')}</label>
<input
type="text"
class="input"
id="moneroAddress"
name="moneroAddress"
value="${escapeHTML(this.formData.moneroAddress)}"
required
placeholder="${t('create.moneroPlaceholder')}"
>
<p class="field-hint">${t('create.moneroHint')}</p>
</div>
${this.isNewAccount ? `
<div class="form-group">
<pow-captcha id="pow-captcha"></pow-captcha>
</div>
` : ''}
<image-cropper id="image-cropper"></image-cropper>
<div class="form-actions">
<button type="button" class="btn btn-outline btn-lg" id="cancel-btn">
${t('create.cancel')}
</button>
<button type="submit" class="btn btn-primary btn-lg">
${this.editMode ? t('create.saveChanges') : t('create.publish')}
</button>
</div>
</form>
</div>
`
this.setupEventListeners()
}
setupEventListeners() {
const form = this.querySelector('#create-form')
const cancelBtn = this.querySelector('#cancel-btn')
const imageInput = this.querySelector('#images')
form.addEventListener('submit', (e) => this.handleSubmit(e))
cancelBtn.addEventListener('click', () => router.back())
form.querySelectorAll('input:not([type="checkbox"]), textarea, select').forEach(input => {
input.addEventListener('input', (e) => {
if (e.target.name) {
this.formData[e.target.name] = e.target.value
this.saveDraft()
}
})
})
// Checkbox handler with shipping cost toggle
const shippingCheckbox = this.querySelector('#shipping')
const shippingCostGroup = this.querySelector('#shipping-cost-group')
shippingCheckbox?.addEventListener('change', (e) => {
this.formData.shipping = e.target.checked
if (shippingCostGroup) {
shippingCostGroup.style.display = e.target.checked ? 'block' : 'none'
}
this.saveDraft()
})
// Location picker handler
const locationPicker = this.querySelector('#location-picker')
locationPicker?.addEventListener('location-select', (e) => {
this.formData.locationData = e.detail
this.formData.location = e.detail.name
this.saveDraft()
})
imageInput?.addEventListener('change', (e) => this.handleImageSelect(e))
// Clear draft button
this.querySelector('#clear-draft-btn')?.addEventListener('click', () => {
this.clearDraft()
this.formData = {
title: '',
description: '',
price: '',
currency: 'EUR',
price_mode: 'fiat',
price_type: 'fixed',
category: '',
condition: 'good',
location: '',
shipping: false,
moneroAddress: ''
}
this.hasDraft = false
this.render()
this.setupEventListeners()
})
}
handleImageSelect(e) {
const files = Array.from(e.target.files)
this.pendingFiles = [...files]
this.processNextImage()
}
processNextImage() {
if (this.pendingFiles.length === 0) return
if (this.imageFiles.length >= 5) {
this.pendingFiles = []
return
}
const file = this.pendingFiles.shift()
const cropper = this.querySelector('#image-cropper')
if (cropper) {
cropper.open(
file,
(croppedFile, previewUrl) => {
// On crop complete
this.imageFiles.push(croppedFile)
this.imagePreviews.push(previewUrl)
this.updateImagePreviews()
// Process next image
this.processNextImage()
},
() => {
// On cancel - skip this image
this.processNextImage()
}
)
}
}
updateImagePreviews() {
const container = this.querySelector('#image-previews')
const uploadArea = this.querySelector('#upload-area')
if (container) {
container.innerHTML = this.renderImagePreviews()
this.setupRemoveListeners()
}
if (uploadArea) {
uploadArea.style.display = this.imageFiles.length >= 5 ? 'none' : 'flex'
}
}
renderImagePreviews() {
if (this.imagePreviews.length === 0) return ''
return this.imagePreviews.map((src, index) => /* html */`
<div class="image-preview">
<img src="${src}" alt="Preview ${index + 1}">
<button type="button" class="remove-image" data-index="${index}" aria-label="${t('common.remove')}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
`).join('')
}
setupRemoveListeners() {
this.querySelectorAll('.remove-image').forEach(btn => {
btn.addEventListener('click', (e) => {
const index = parseInt(e.currentTarget.dataset.index)
this.imageFiles.splice(index, 1)
this.imagePreviews.splice(index, 1)
this.updateImagePreviews()
})
})
}
async handleSubmit(e) {
e.preventDefault()
if (this.submitting) return
const form = e.target
const formElements = {
title: form.querySelector('#title')?.value || '',
description: form.querySelector('#description')?.value || '',
price: form.querySelector('#price')?.value || '',
currency: form.querySelector('#currency')?.value || 'EUR',
price_mode: form.querySelector('#price_mode')?.value || 'fiat',
category: form.querySelector('#category')?.value || '',
condition: form.querySelector('#condition')?.value || 'good',
shipping: form.querySelector('#shipping')?.checked || false,
shipping_cost: form.querySelector('#shipping_cost')?.value || '',
moneroAddress: form.querySelector('#moneroAddress')?.value || ''
}
if (!this.editMode && this.isNewAccount) {
const captcha = this.querySelector('#pow-captcha')
if (!captcha?.isSolved()) {
this.showError(t('captcha.error'))
return
}
}
if (formElements.moneroAddress && !this.validateMoneroAddress(formElements.moneroAddress)) {
this.showError(t('create.invalidMoneroAddress'))
return
}
this.submitting = true
this.clearError()
const submitBtn = form.querySelector('[type="submit"]')
submitBtn.disabled = true
submitBtn.textContent = this.editMode ? t('create.saving') : t('create.publishing')
try {
let newImageIds = []
if (this.imageFiles.length > 0) {
const uploadedFiles = await directus.uploadMultipleFiles(this.imageFiles)
newImageIds = uploadedFiles.map(f => f.id)
}
const listingData = {
title: formElements.title,
slug: this.generateSlug(formElements.title),
description: formElements.description,
price: String(parseFloat(formElements.price) || 0),
currency: formElements.currency
}
if (formElements.price_mode) listingData.price_mode = formElements.price_mode
if (formElements.category) listingData.category = formElements.category
if (formElements.condition) listingData.condition = formElements.condition
listingData.shipping = formElements.shipping
if (formElements.shipping && formElements.shipping_cost) {
listingData.shipping_cost = parseFloat(formElements.shipping_cost)
}
if (formElements.moneroAddress) listingData.monero_address = formElements.moneroAddress
if (this.formData.locationData) {
const locationId = await this.findOrCreateLocation(this.formData.locationData)
if (locationId) {
listingData.location = locationId
}
}
if (this.editMode) {
if (newImageIds.length > 0) {
listingData.images = {
create: newImageIds.map((id, index) => ({
directus_files_id: id,
sort: this.existingImages.length + index
}))
}
}
if (this.editListing && !listingsService.isPaidAndActive(this.editListing)) {
await directus.updateListing(this.editId, listingData)
await this.startPayment(this.editId, formElements.currency)
return
}
await directus.updateListing(this.editId, listingData)
// Generate contact keypair for old listings that don't have one
if (!this.editListing?.contact_public_key) {
const contactPublicKey = await cryptoService.generateListingKeyPair(this.editId)
await directus.updateListing(this.editId, { contact_public_key: contactPublicKey })
}
router.navigate(`/listing/${this.editId}`)
} else {
// Save as draft first, then trigger payment
listingData.status = 'draft'
listingData.payment_status = 'unpaid'
if (newImageIds.length > 0) {
listingData.images = {
create: newImageIds.map((id, index) => ({
directus_files_id: id,
sort: index
}))
}
}
const listing = await directus.createListing(listingData)
this.clearDraft()
if (listing?.id) {
// Generate per-listing E2E keypair
const contactPublicKey = await cryptoService.generateListingKeyPair(listing.id)
await directus.updateListing(listing.id, { contact_public_key: contactPublicKey })
await this.startPayment(listing.id, formElements.currency)
} else {
router.navigate('/')
}
}
} catch (error) {
console.error('Failed to save listing:', error)
console.error('Error details:', JSON.stringify(error.data, null, 2))
submitBtn.disabled = false
submitBtn.textContent = this.editMode ? t('create.saveChanges') : t('create.publish')
this.submitting = false
const errorMsg = error.data?.errors?.[0]?.message || error.message || t('create.publishFailed')
this.showError(errorMsg)
}
}
async startPayment(listingId, currency = 'EUR') {
const submitBtn = this.querySelector('[type="submit"]')
try {
// Check for existing pending invoice
const pending = getPendingInvoice(listingId)
let invoiceId = null
if (pending?.invoiceId) {
const status = await getInvoiceStatus(pending.invoiceId)
if (status.status === 'New') {
invoiceId = pending.invoiceId
} else if (status.status === 'Settled') {
await this.onPaymentSuccess(listingId)
return
} else {
clearPendingInvoice(listingId)
}
}
if (!invoiceId) {
if (submitBtn) submitBtn.textContent = t('payment.paying')
const invoice = await createInvoice(listingId, currency)
invoiceId = invoice.invoiceId
savePendingInvoice(listingId, invoiceId)
await directus.updateListing(listingId, {
payment_status: 'pending',
btcpay_invoice_id: invoiceId
})
}
const modalStatus = await openCheckout(invoiceId)
if (modalStatus === 'complete' || modalStatus === 'paid') {
await this.onPaymentSuccess(listingId)
return
}
// Check status once after modal close
const currentStatus = await getInvoiceStatus(invoiceId)
if (currentStatus.status === 'Settled') {
await this.onPaymentSuccess(listingId)
return
}
// Processing = payment received, waiting for confirmation
if (currentStatus.status === 'Processing') {
await this.onPaymentReceived(listingId)
return
}
if (currentStatus.status === 'Expired' || currentStatus.status === 'Invalid') {
await directus.updateListing(listingId, { payment_status: 'expired' })
clearPendingInvoice(listingId)
this.showError(t('payment.expired'))
this.submitting = false
if (submitBtn) {
submitBtn.disabled = false
submitBtn.textContent = t('create.publish')
}
return
}
// Still "New" - user closed modal without paying
this.showError(t('payment.failed'))
this.submitting = false
if (submitBtn) {
submitBtn.disabled = false
submitBtn.textContent = t('create.publish')
}
} catch (error) {
console.error('Payment failed:', error)
this.showError(t('payment.failed'))
this.submitting = false
if (submitBtn) {
submitBtn.disabled = false
submitBtn.textContent = t('create.publish')
}
}
}
async onPaymentSuccess(listingId) {
try {
const days = 30
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + days)
await directus.updateListing(listingId, {
status: 'published',
payment_status: 'paid',
paid_at: new Date().toISOString(),
expires_at: expiresAt.toISOString()
})
} catch (e) {
console.warn('Could not update listing status, webhook will handle it:', e)
}
clearPendingInvoice(listingId)
router.navigate('/my-listings')
}
async onPaymentReceived(listingId) {
try {
await directus.updateListing(listingId, {
payment_status: 'processing'
})
} catch (e) {
console.warn('Could not update listing status, webhook will handle it:', e)
}
clearPendingInvoice(listingId)
router.navigate('/my-listings')
}
showError(message) {
let errorDiv = this.querySelector('.form-error')
if (!errorDiv) {
errorDiv = document.createElement('div')
errorDiv.className = 'form-error'
this.querySelector('.form-actions')?.insertAdjacentElement('beforebegin', errorDiv)
}
errorDiv.textContent = message
}
clearError() {
this.querySelector('.form-error')?.remove()
}
generateSlug(title) {
return title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 100)
}
async findOrCreateLocation(locationData) {
if (!locationData || !locationData.name) return null
try {
// Build filter based on available data
const filter = {}
if (locationData.postalCode) {
filter.postal_code = { _eq: locationData.postalCode }
}
if (locationData.countryCode) {
filter.country = { _eq: locationData.countryCode }
}
if (locationData.name) {
filter.name = { _eq: locationData.name }
}
// Only search if we have at least one filter
if (Object.keys(filter).length > 0) {
const existing = await directus.get('/items/locations', {
filter,
limit: 1
})
if (existing.data && existing.data.length > 0) {
return existing.data[0].id
}
}
// Create new location
const newLocation = await directus.post('/items/locations', {
name: locationData.name || '',
postal_code: locationData.postalCode || null,
region: locationData.region || null,
country: locationData.countryCode || null
})
return newLocation.data?.id
} catch (error) {
console.error('Failed to find/create location:', error)
return null
}
}
}
customElements.define('page-create', PageCreate)
const style = document.createElement('style')
style.textContent = /* css */`
page-create .create-page {
max-width: 600px;
margin: 0 auto;
padding: var(--space-lg) 0;
}
page-create .create-page h1 {
margin-bottom: var(--space-xl);
}
page-create .create-form .form-group {
margin-bottom: var(--space-lg);
}
page-create textarea.input {
resize: vertical;
min-height: 120px;
}
page-create .image-upload {
border: 2px dashed var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
page-create .upload-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-2xl);
cursor: pointer;
transition: background-color var(--transition-fast);
}
page-create .upload-area:hover {
background-color: var(--color-bg-secondary);
}
page-create .upload-icon {
font-size: 2rem;
margin-bottom: var(--space-sm);
}
page-create .image-previews {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: var(--space-sm);
padding: var(--space-sm);
}
page-create .image-previews:empty {
display: none;
}
page-create .image-preview {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius-md);
overflow: hidden;
}
page-create .image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
page-create .remove-image {
position: absolute;
top: var(--space-xs);
right: var(--space-xs);
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-error);
color: white;
border-radius: var(--radius-full);
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast);
}
page-create .image-preview:hover .remove-image {
opacity: 1;
}
page-create .field-hint {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin-top: var(--space-xs);
}
page-create .form-row {
display: flex;
gap: var(--space-md);
}
page-create .form-group-price {
flex: 2;
}
page-create .form-group-currency {
flex: 1;
}
page-create .checkbox-label {
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: pointer;
}
page-create .checkbox-label input {
width: 18px;
height: 18px;
accent-color: var(--color-accent);
}
page-create .login-required {
text-align: center;
padding: var(--space-3xl) 0;
}
page-create .login-required h2 {
margin-bottom: var(--space-lg);
}
page-create .form-actions {
display: flex;
gap: var(--space-md);
justify-content: flex-end;
margin-top: var(--space-xl);
}
page-create .form-error {
padding: var(--space-md);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-error);
border-radius: var(--radius-md);
color: var(--color-text);
margin-bottom: var(--space-md);
font-size: var(--font-size-sm);
}
page-create .draft-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
margin-bottom: var(--space-lg);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
page-create .btn-link {
background: none;
border: none;
color: var(--color-primary);
font-size: var(--font-size-sm);
cursor: pointer;
text-decoration: underline;
}
page-create .btn-link:hover {
color: var(--color-primary-hover);
}
`
document.head.appendChild(style)