1088 lines
38 KiB
TypeScript
1088 lines
38 KiB
TypeScript
/**
|
|
* <folk-pubs-publish-panel> — Unified sidebar for sharing, DIY printing, and ordering.
|
|
*
|
|
* Three tabs: Share, DIY Print, Order.
|
|
* Replaces the old bare "Download PDF" link.
|
|
*
|
|
* Attributes:
|
|
* pdf-url — Blob URL of the generated PDF
|
|
* format-id — Selected format (a7, a6, quarter-letter, digest)
|
|
* format-name — Display name of the format
|
|
* page-count — Number of pages in the generated PDF
|
|
* space-slug — Current space slug for API calls
|
|
*/
|
|
|
|
export class FolkPubsPublishPanel extends HTMLElement {
|
|
private _pdfUrl = "";
|
|
private _formatId = "";
|
|
private _formatName = "";
|
|
private _pageCount = 0;
|
|
private _spaceSlug = "personal";
|
|
private _activeTab: "share" | "diy" | "order" = "share";
|
|
private _printers: any[] = [];
|
|
private _printersLoading = false;
|
|
private _printersError: string | null = null;
|
|
private _selectedProvider: any = null;
|
|
private _emailSending = false;
|
|
private _emailSent = false;
|
|
private _emailError: string | null = null;
|
|
private _impositionLoading = false;
|
|
private _orderStatus: string | null = null;
|
|
private _batchStatus: any = null;
|
|
|
|
private static readonly ICONS = {
|
|
download: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
|
|
copy: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
|
|
mail: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>`,
|
|
book: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>`,
|
|
scissors: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>`,
|
|
printer: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>`,
|
|
location: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>`,
|
|
users: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
|
|
check: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
|
|
send: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`,
|
|
share: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>`,
|
|
info: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
|
|
phone: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>`,
|
|
globe: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`,
|
|
verified: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>`,
|
|
};
|
|
|
|
static get observedAttributes() {
|
|
return ["pdf-url", "format-id", "format-name", "page-count", "space-slug"];
|
|
}
|
|
|
|
attributeChangedCallback(name: string, _old: string, val: string) {
|
|
if (name === "pdf-url") this._pdfUrl = val;
|
|
else if (name === "format-id") this._formatId = val;
|
|
else if (name === "format-name") this._formatName = val;
|
|
else if (name === "page-count") this._pageCount = parseInt(val) || 0;
|
|
else if (name === "space-slug") this._spaceSlug = val;
|
|
if (this.shadowRoot) this.render();
|
|
}
|
|
|
|
connectedCallback() {
|
|
this._pdfUrl = this.getAttribute("pdf-url") || "";
|
|
this._formatId = this.getAttribute("format-id") || "";
|
|
this._formatName = this.getAttribute("format-name") || "";
|
|
this._pageCount = parseInt(this.getAttribute("page-count") || "0") || 0;
|
|
this._spaceSlug = this.getAttribute("space-slug") || "personal";
|
|
if (!this.shadowRoot) this.attachShadow({ mode: "open" });
|
|
this.render();
|
|
}
|
|
|
|
private render() {
|
|
if (!this.shadowRoot) return;
|
|
this.shadowRoot.innerHTML = `
|
|
${this.getStyles()}
|
|
<div class="panel">
|
|
<div class="tabs">
|
|
<button class="tab ${this._activeTab === 'share' ? 'active' : ''}" data-tab="share">
|
|
${FolkPubsPublishPanel.ICONS.share}
|
|
<span>Share</span>
|
|
</button>
|
|
<button class="tab ${this._activeTab === 'diy' ? 'active' : ''}" data-tab="diy">
|
|
${FolkPubsPublishPanel.ICONS.scissors}
|
|
<span>DIY Print</span>
|
|
</button>
|
|
<button class="tab ${this._activeTab === 'order' ? 'active' : ''}" data-tab="order">
|
|
${FolkPubsPublishPanel.ICONS.printer}
|
|
<span>Order</span>
|
|
</button>
|
|
</div>
|
|
<div class="tab-content">
|
|
${this._activeTab === 'share' ? this.renderShareTab() : ''}
|
|
${this._activeTab === 'diy' ? this.renderDiyTab() : ''}
|
|
${this._activeTab === 'order' ? this.renderOrderTab() : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
this.bindEvents();
|
|
}
|
|
|
|
private renderShareTab(): string {
|
|
return `
|
|
<div class="section">
|
|
<div class="info-card">
|
|
${FolkPubsPublishPanel.ICONS.book}
|
|
<div class="info-card-text">
|
|
<span class="info-card-title">${this.esc(this._formatName)}</span>
|
|
<span class="info-card-detail">${this._pageCount} pages</span>
|
|
</div>
|
|
</div>
|
|
|
|
<a class="action-btn primary" href="${this._pdfUrl}" download>
|
|
${FolkPubsPublishPanel.ICONS.download}
|
|
<span>Download PDF</span>
|
|
</a>
|
|
|
|
<button class="action-btn secondary" data-action="copy-link">
|
|
${FolkPubsPublishPanel.ICONS.copy}
|
|
<span>Copy Flipbook Link</span>
|
|
</button>
|
|
|
|
<div class="divider-row">
|
|
<span class="divider-line"></span>
|
|
<span class="divider-text">or send by email</span>
|
|
<span class="divider-line"></span>
|
|
</div>
|
|
|
|
<div class="email-row">
|
|
<div class="email-input-wrap">
|
|
${FolkPubsPublishPanel.ICONS.mail}
|
|
<input type="email" class="email-input" placeholder="recipient@email.com" />
|
|
</div>
|
|
<button class="action-btn accent send-btn" data-action="email-pdf" ${this._emailSending ? 'disabled' : ''}>
|
|
${this._emailSending
|
|
? '<span class="spinner"></span>'
|
|
: FolkPubsPublishPanel.ICONS.send}
|
|
<span>${this._emailSending ? 'Sending...' : 'Send'}</span>
|
|
</button>
|
|
</div>
|
|
${this._emailSent ? `<div class="msg success">${FolkPubsPublishPanel.ICONS.check} PDF sent!</div>` : ''}
|
|
${this._emailError ? `<div class="msg error">${this.esc(this._emailError)}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderDiyTab(): string {
|
|
return `
|
|
<div class="section">
|
|
<div class="section-header">
|
|
${FolkPubsPublishPanel.ICONS.scissors}
|
|
<div>
|
|
<div class="section-title">DIY Printing Guide</div>
|
|
<div class="section-subtitle">Print, fold & bind at home</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="action-btn primary" data-action="download-imposition" ${this._impositionLoading ? 'disabled' : ''}>
|
|
${this._impositionLoading
|
|
? '<span class="spinner"></span>'
|
|
: FolkPubsPublishPanel.ICONS.download}
|
|
<span>${this._impositionLoading ? 'Generating...' : 'Download Imposition PDF'}</span>
|
|
</button>
|
|
<p class="hint">Pre-arranged pages for double-sided printing & folding.</p>
|
|
<div class="guide-placeholder" data-guide-target></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderOrderTab(): string {
|
|
if (this._selectedProvider) {
|
|
return this.renderProviderDetail();
|
|
}
|
|
|
|
return `
|
|
<div class="section">
|
|
<div class="pricing-tiers">
|
|
<div class="tier-card">
|
|
<div class="tier-qty">25+</div>
|
|
<div class="tier-binding">Saddle stitch</div>
|
|
<div class="tier-price">~$1.20/ea</div>
|
|
</div>
|
|
<div class="tier-card popular">
|
|
<div class="tier-badge">popular</div>
|
|
<div class="tier-qty">50+</div>
|
|
<div class="tier-binding">Perfect bind</div>
|
|
<div class="tier-price">~$0.85/ea</div>
|
|
</div>
|
|
<div class="tier-card">
|
|
<div class="tier-qty">100+</div>
|
|
<div class="tier-binding">Perfect bind</div>
|
|
<div class="tier-price">~$0.60/ea</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="action-btn primary" data-action="find-printers" ${this._printersLoading ? 'disabled' : ''}>
|
|
${this._printersLoading
|
|
? '<span class="spinner"></span>'
|
|
: FolkPubsPublishPanel.ICONS.location}
|
|
<span>${this._printersLoading ? 'Searching...' : 'Find Nearby Printers'}</span>
|
|
</button>
|
|
${this._printersError ? `<div class="msg error">${this.esc(this._printersError)}</div>` : ''}
|
|
${this._printers.length > 0 ? this.renderPrinterList() : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderPrinterList(): string {
|
|
return `
|
|
<div class="printer-list">
|
|
${this._printers.map((p) => `
|
|
<button class="printer-card" data-provider-id="${this.esc(p.id)}">
|
|
<div class="printer-header">
|
|
<span class="printer-name">${this.esc(p.name)}</span>
|
|
<span class="printer-distance">${p.distance_km} km</span>
|
|
</div>
|
|
<div class="printer-meta">
|
|
${this.esc(p.city)}
|
|
${p.source === 'curated' ? `<span class="verified-badge">${FolkPubsPublishPanel.ICONS.verified} verified</span>` : ''}
|
|
</div>
|
|
${p.tags?.length ? `<div class="printer-tags">${p.tags.map((t: string) => `<span class="tag">${this.esc(t)}</span>`).join('')}</div>` : ''}
|
|
${p.capabilities?.length ? `<div class="printer-caps">${p.capabilities.join(', ')}</div>` : ''}
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderProviderDetail(): string {
|
|
const p = this._selectedProvider;
|
|
return `
|
|
<div class="section">
|
|
<button class="back-btn" data-action="back-to-list">← Back to results</button>
|
|
<div class="provider-detail">
|
|
<div class="provider-card-header">
|
|
${FolkPubsPublishPanel.ICONS.printer}
|
|
<h4>${this.esc(p.name)}</h4>
|
|
</div>
|
|
<div class="provider-info-grid">
|
|
${p.address ? `<div class="provider-info-row">${FolkPubsPublishPanel.ICONS.location} <span>${this.esc(p.address)}</span></div>` : ''}
|
|
${p.phone ? `<div class="provider-info-row">${FolkPubsPublishPanel.ICONS.phone} <span>${this.esc(p.phone)}</span></div>` : ''}
|
|
${p.website ? `<div class="provider-info-row">${FolkPubsPublishPanel.ICONS.globe} <a href="${this.esc(p.website)}" target="_blank" rel="noopener">${this.esc(p.website)}</a></div>` : ''}
|
|
${p.email ? `<div class="provider-info-row">${FolkPubsPublishPanel.ICONS.mail} <span>${this.esc(p.email)}</span></div>` : ''}
|
|
</div>
|
|
${p.description ? `<div class="provider-desc">${this.esc(p.description)}</div>` : ''}
|
|
|
|
<button class="action-btn primary" data-action="place-order">
|
|
${FolkPubsPublishPanel.ICONS.printer}
|
|
<span>Place Order</span>
|
|
</button>
|
|
<button class="action-btn secondary" data-action="join-batch">
|
|
${FolkPubsPublishPanel.ICONS.users}
|
|
<span>Join Group Buy</span>
|
|
</button>
|
|
|
|
${this._orderStatus ? `<div class="msg success">${FolkPubsPublishPanel.ICONS.check} ${this.esc(this._orderStatus)}</div>` : ''}
|
|
${this._batchStatus ? this.renderBatchProgress() : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderBatchProgress(): string {
|
|
const b = this._batchStatus;
|
|
if (b.action === 'error') return `<div class="msg error">${this.esc(b.error)}</div>`;
|
|
|
|
const participants = b.participants || 1;
|
|
const threshold = 25;
|
|
const pct = Math.min(Math.round((participants / threshold) * 100), 100);
|
|
|
|
return `
|
|
<div class="batch-card">
|
|
<div class="batch-header">
|
|
${FolkPubsPublishPanel.ICONS.users}
|
|
<span>Group Buy Progress</span>
|
|
</div>
|
|
<div class="batch-bar-wrap">
|
|
<div class="batch-bar">
|
|
<div class="batch-bar-fill" style="width:${pct}%"></div>
|
|
</div>
|
|
<div class="batch-bar-label">${participants} / ${threshold} participants</div>
|
|
</div>
|
|
<div class="batch-tiers">
|
|
<span class="${participants >= 25 ? 'tier-unlocked' : 'tier-locked'}">25+ unlocked</span>
|
|
<span class="${participants >= 50 ? 'tier-unlocked' : 'tier-locked'}">50+ ${participants >= 50 ? 'unlocked' : 'locked'}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private bindEvents() {
|
|
if (!this.shadowRoot) return;
|
|
|
|
// Tab switching
|
|
this.shadowRoot.querySelectorAll(".tab").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
this._activeTab = (btn as HTMLElement).dataset.tab as any;
|
|
this.render();
|
|
});
|
|
});
|
|
|
|
// Share tab actions
|
|
this.shadowRoot.querySelector('[data-action="copy-link"]')?.addEventListener("click", () => {
|
|
const url = `${window.location.origin}${window.location.pathname}`;
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
const btn = this.shadowRoot!.querySelector('[data-action="copy-link"]')!;
|
|
const span = btn.querySelector('span');
|
|
if (span) span.textContent = "Copied!";
|
|
btn.innerHTML = `${FolkPubsPublishPanel.ICONS.check}<span>Copied!</span>`;
|
|
setTimeout(() => {
|
|
btn.innerHTML = `${FolkPubsPublishPanel.ICONS.copy}<span>Copy Flipbook Link</span>`;
|
|
}, 2000);
|
|
});
|
|
});
|
|
|
|
this.shadowRoot.querySelector('[data-action="email-pdf"]')?.addEventListener("click", () => {
|
|
this.sendEmailPdf();
|
|
});
|
|
|
|
// DIY tab
|
|
this.shadowRoot.querySelector('[data-action="download-imposition"]')?.addEventListener("click", () => {
|
|
this.downloadImposition();
|
|
});
|
|
|
|
// Load guide content
|
|
const guideTarget = this.shadowRoot.querySelector('[data-guide-target]');
|
|
if (guideTarget && this._activeTab === 'diy') {
|
|
this.loadGuide(guideTarget as HTMLElement);
|
|
}
|
|
|
|
// Order tab
|
|
this.shadowRoot.querySelector('[data-action="find-printers"]')?.addEventListener("click", () => {
|
|
this.findPrinters();
|
|
});
|
|
|
|
this.shadowRoot.querySelectorAll(".printer-card").forEach((card) => {
|
|
card.addEventListener("click", () => {
|
|
const id = (card as HTMLElement).dataset.providerId;
|
|
this._selectedProvider = this._printers.find((p) => p.id === id) || null;
|
|
this.render();
|
|
});
|
|
});
|
|
|
|
this.shadowRoot.querySelector('[data-action="back-to-list"]')?.addEventListener("click", () => {
|
|
this._selectedProvider = null;
|
|
this._orderStatus = null;
|
|
this._batchStatus = null;
|
|
this.render();
|
|
});
|
|
|
|
this.shadowRoot.querySelector('[data-action="place-order"]')?.addEventListener("click", () => {
|
|
this.placeOrder();
|
|
});
|
|
|
|
this.shadowRoot.querySelector('[data-action="join-batch"]')?.addEventListener("click", () => {
|
|
this.joinBatch();
|
|
});
|
|
}
|
|
|
|
private async sendEmailPdf() {
|
|
const input = this.shadowRoot?.querySelector(".email-input") as HTMLInputElement;
|
|
const email = input?.value?.trim();
|
|
if (!email || !email.includes("@")) {
|
|
this._emailError = "Enter a valid email address";
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
this._emailSending = true;
|
|
this._emailError = null;
|
|
this._emailSent = false;
|
|
this.render();
|
|
|
|
try {
|
|
const res = await fetch(`/${this._spaceSlug}/rpubs/api/email-pdf`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
email,
|
|
format: this._formatId,
|
|
// Content will be re-read from the editor via a custom event
|
|
...this.getEditorContent(),
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || "Failed to send email");
|
|
}
|
|
|
|
this._emailSent = true;
|
|
} catch (e: any) {
|
|
this._emailError = e.message;
|
|
} finally {
|
|
this._emailSending = false;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
private async downloadImposition() {
|
|
this._impositionLoading = true;
|
|
this.render();
|
|
|
|
try {
|
|
const res = await fetch(`/${this._spaceSlug}/rpubs/api/imposition`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
format: this._formatId,
|
|
...this.getEditorContent(),
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || "Imposition generation failed");
|
|
}
|
|
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `imposition-${this._formatId}.pdf`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (e: any) {
|
|
console.error("[rpubs] Imposition error:", e);
|
|
} finally {
|
|
this._impositionLoading = false;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
private async loadGuide(target: HTMLElement) {
|
|
if (!this._formatId) return;
|
|
|
|
// Dynamically import the guide
|
|
const { getGuide, recommendedBinding, paddedPageCount } = await import("../print-guides");
|
|
const guide = getGuide(this._formatId);
|
|
if (!guide) { target.innerHTML = '<p class="hint">No guide available for this format.</p>'; return; }
|
|
|
|
const binding = recommendedBinding(this._formatId, this._pageCount);
|
|
const padded = paddedPageCount(this._pageCount);
|
|
const sheets = Math.ceil(padded / guide.pagesPerSheet);
|
|
|
|
target.innerHTML = `
|
|
<div class="guide">
|
|
<div class="guide-card">
|
|
<div class="guide-stats">
|
|
<div class="guide-stat-col">
|
|
<span class="guide-stat-value">${sheets}</span>
|
|
<span class="guide-stat-label">Sheets</span>
|
|
</div>
|
|
<div class="guide-stat-col">
|
|
<span class="guide-stat-value">${guide.parentSheet}</span>
|
|
<span class="guide-stat-label">Paper</span>
|
|
</div>
|
|
<div class="guide-stat-col">
|
|
<span class="guide-stat-value">${binding}</span>
|
|
<span class="guide-stat-label">Binding</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="guide-info-banner">
|
|
${FolkPubsPublishPanel.ICONS.info}
|
|
<span>${guide.paperRecommendation}</span>
|
|
</div>
|
|
|
|
<div class="guide-section">
|
|
<div class="guide-section-title">Tools needed</div>
|
|
<ul class="guide-list">${guide.tools.map((t: string) => `<li>${t}</li>`).join('')}</ul>
|
|
</div>
|
|
|
|
<div class="guide-section">
|
|
<div class="guide-section-title">Folding</div>
|
|
<div class="guide-numbered-steps">
|
|
${guide.foldInstructions.map((s: string, i: number) => `
|
|
<div class="guide-step">
|
|
<span class="guide-step-num">${i + 1}</span>
|
|
<span>${s}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="guide-section">
|
|
<div class="guide-section-title">Binding</div>
|
|
<div class="guide-numbered-steps">
|
|
${guide.bindingInstructions.filter((s: string) => s).map((s: string, i: number) => `
|
|
<div class="guide-step">
|
|
<span class="guide-step-num">${i + 1}</span>
|
|
<span>${s.replace(/^\s+/, '')}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="guide-section">
|
|
<div class="guide-section-title">Tips</div>
|
|
<ul class="guide-tips">${guide.tips.map((t: string) => `<li>\u2605 ${t}</li>`).join('')}</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private async findPrinters() {
|
|
this._printersLoading = true;
|
|
this._printersError = null;
|
|
this.render();
|
|
|
|
try {
|
|
const pos = await new Promise<GeolocationPosition>((resolve, reject) => {
|
|
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 });
|
|
});
|
|
|
|
const { latitude: lat, longitude: lng } = pos.coords;
|
|
const res = await fetch(
|
|
`/${this._spaceSlug}/rpubs/api/printers?lat=${lat}&lng=${lng}&radius=100&format=${this._formatId}`,
|
|
);
|
|
|
|
if (!res.ok) throw new Error("Failed to search printers");
|
|
const data = await res.json();
|
|
this._printers = data.providers || [];
|
|
} catch (e: any) {
|
|
if (e.code === 1) {
|
|
this._printersError = "Location access denied. Enable location to find nearby printers.";
|
|
} else {
|
|
this._printersError = e.message || "Search failed";
|
|
}
|
|
} finally {
|
|
this._printersLoading = false;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
private async placeOrder() {
|
|
if (!this._selectedProvider) return;
|
|
try {
|
|
const res = await fetch(`/${this._spaceSlug}/rpubs/api/order`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
provider_id: this._selectedProvider.id,
|
|
provider_name: this._selectedProvider.name,
|
|
provider_distance_km: this._selectedProvider.distance_km,
|
|
total_price: 0,
|
|
currency: "USD",
|
|
format: this._formatId,
|
|
...this.getEditorContent(),
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || "Order failed");
|
|
}
|
|
|
|
const order = await res.json();
|
|
this._orderStatus = `Order created: ${order.id || 'confirmed'}`;
|
|
this.render();
|
|
} catch (e: any) {
|
|
this._orderStatus = `Error: ${e.message}`;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
private async joinBatch() {
|
|
if (!this._selectedProvider) return;
|
|
try {
|
|
const res = await fetch(`/${this._spaceSlug}/rpubs/api/batch`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
provider_id: this._selectedProvider.id,
|
|
provider_name: this._selectedProvider.name,
|
|
format: this._formatId,
|
|
...this.getEditorContent(),
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || "Batch operation failed");
|
|
}
|
|
|
|
this._batchStatus = await res.json();
|
|
this.render();
|
|
} catch (e: any) {
|
|
this._batchStatus = { action: "error", error: e.message };
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
/** Get content from the parent editor by reading its textarea */
|
|
private getEditorContent(): { content: string; title?: string; author?: string } {
|
|
const editor = this.closest("folk-pubs-editor") || document.querySelector("folk-pubs-editor");
|
|
if (!editor?.shadowRoot) return { content: "" };
|
|
|
|
const textarea = editor.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
|
|
const titleInput = editor.shadowRoot.querySelector(".title-input") as HTMLInputElement;
|
|
const authorInput = editor.shadowRoot.querySelector(".author-input") as HTMLInputElement;
|
|
|
|
return {
|
|
content: textarea?.value || "",
|
|
title: titleInput?.value?.trim() || undefined,
|
|
author: authorInput?.value?.trim() || undefined,
|
|
};
|
|
}
|
|
|
|
private esc(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
private getStyles(): string {
|
|
return `<style>
|
|
:host { display: block; max-width: 32rem; margin: 0 auto; }
|
|
|
|
.panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--rs-bg-surface, #1e1e2e);
|
|
border: 1px solid var(--rs-border, #333);
|
|
border-radius: 0.75rem;
|
|
box-shadow: var(--rs-shadow-md, 0 4px 16px rgba(0,0,0,0.25));
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ── Tabs ── */
|
|
|
|
.tabs {
|
|
display: flex;
|
|
gap: 0;
|
|
border-bottom: 1px solid var(--rs-border-subtle, #333);
|
|
background: var(--rs-bg-page, #181825);
|
|
}
|
|
.tab {
|
|
flex: 1;
|
|
padding: 0.625rem 0.5rem;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
background: transparent;
|
|
color: var(--rs-text-secondary, #aaa);
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.375rem;
|
|
}
|
|
.tab:hover { color: var(--rs-text-primary, #eee); }
|
|
.tab.active {
|
|
color: var(--rs-accent, #14b8a6);
|
|
border-bottom-color: var(--rs-accent, #14b8a6);
|
|
}
|
|
.tab svg { opacity: 0.7; }
|
|
.tab.active svg { opacity: 1; }
|
|
|
|
/* ── Tab Content ── */
|
|
|
|
.tab-content { padding: 1rem; }
|
|
.section { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
|
|
/* ── Info Card ── */
|
|
|
|
.info-card {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.625rem;
|
|
padding: 0.625rem 0.75rem;
|
|
background: var(--rs-card-bg, #232336);
|
|
border: 1px solid var(--rs-card-border, #2a2a3e);
|
|
border-radius: 0.5rem;
|
|
}
|
|
.info-card svg { color: var(--rs-accent, #14b8a6); flex-shrink: 0; }
|
|
.info-card-text { display: flex; flex-direction: column; gap: 0.125rem; }
|
|
.info-card-title { font-size: 0.8rem; font-weight: 600; color: var(--rs-text-primary, #eee); }
|
|
.info-card-detail { font-size: 0.7rem; color: var(--rs-text-muted, #666); }
|
|
|
|
/* ── Section Header ── */
|
|
|
|
.section-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.625rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
.section-header svg { color: var(--rs-accent, #14b8a6); flex-shrink: 0; }
|
|
.section-title { font-size: 0.85rem; font-weight: 600; color: var(--rs-text-primary, #eee); }
|
|
.section-subtitle { font-size: 0.7rem; color: var(--rs-text-muted, #666); }
|
|
|
|
/* ── Action Buttons ── */
|
|
|
|
.action-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.375rem;
|
|
width: 100%;
|
|
text-align: center;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: 0.5rem;
|
|
border: 1px solid var(--rs-border, #444);
|
|
background: var(--rs-bg-surface, #2a2a2a);
|
|
color: var(--rs-text-primary, #eee);
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
transition: all 0.15s;
|
|
}
|
|
.action-btn:hover { border-color: var(--rs-accent, #14b8a6); }
|
|
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.action-btn svg { flex-shrink: 0; }
|
|
|
|
.action-btn.primary {
|
|
background: var(--rs-accent, #14b8a6);
|
|
border-color: var(--rs-accent, #14b8a6);
|
|
color: #fff;
|
|
font-weight: 600;
|
|
}
|
|
.action-btn.primary:hover { background: var(--rs-accent-hover, #0d9488); }
|
|
|
|
.action-btn.secondary {
|
|
background: transparent;
|
|
border: 1px solid var(--rs-border, #444);
|
|
}
|
|
.action-btn.secondary:hover { border-color: var(--rs-accent, #14b8a6); color: var(--rs-accent, #14b8a6); }
|
|
|
|
.action-btn.accent {
|
|
background: var(--rs-accent, #14b8a6);
|
|
border-color: var(--rs-accent, #14b8a6);
|
|
color: #fff;
|
|
font-weight: 600;
|
|
}
|
|
.action-btn.accent:hover { background: var(--rs-accent-hover, #0d9488); }
|
|
|
|
.send-btn { width: auto; flex-shrink: 0; padding: 0.5rem 0.875rem; }
|
|
|
|
/* ── Spinner ── */
|
|
|
|
.spinner {
|
|
display: inline-block;
|
|
width: 14px; height: 14px;
|
|
border: 2px solid rgba(255,255,255,0.3);
|
|
border-top-color: #fff;
|
|
border-radius: 50%;
|
|
animation: spin 0.6s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
/* ── Divider ── */
|
|
|
|
.divider-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.625rem;
|
|
margin: 0.25rem 0;
|
|
}
|
|
.divider-line { flex: 1; height: 1px; background: var(--rs-border-subtle, #333); }
|
|
.divider-text { font-size: 0.7rem; color: var(--rs-text-muted, #666); white-space: nowrap; }
|
|
|
|
/* ── Email ── */
|
|
|
|
.email-row { display: flex; gap: 0.375rem; }
|
|
.email-input-wrap {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
padding: 0 0.5rem;
|
|
border: 1px solid var(--rs-input-border, #444);
|
|
border-radius: 0.5rem;
|
|
background: var(--rs-input-bg, #1a1a2e);
|
|
transition: border-color 0.15s;
|
|
}
|
|
.email-input-wrap:focus-within { border-color: var(--rs-accent, #14b8a6); }
|
|
.email-input-wrap svg { color: var(--rs-text-muted, #666); flex-shrink: 0; }
|
|
.email-input {
|
|
flex: 1;
|
|
padding: 0.5rem 0;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--rs-input-text, #eee);
|
|
font-size: 0.8rem;
|
|
outline: none;
|
|
}
|
|
.email-input::placeholder { color: var(--rs-text-muted, #666); }
|
|
|
|
/* ── Messages ── */
|
|
|
|
.msg {
|
|
font-size: 0.75rem;
|
|
padding: 0.5rem 0.625rem;
|
|
border-radius: 0.5rem;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
}
|
|
.msg.success {
|
|
background: rgba(20, 184, 166, 0.1);
|
|
border: 1px solid rgba(20, 184, 166, 0.2);
|
|
color: var(--rs-accent, #14b8a6);
|
|
}
|
|
.msg.error {
|
|
background: rgba(248, 113, 113, 0.1);
|
|
border: 1px solid rgba(248, 113, 113, 0.2);
|
|
color: #f87171;
|
|
}
|
|
.msg.info {
|
|
background: rgba(20, 184, 166, 0.08);
|
|
border: 1px solid rgba(20, 184, 166, 0.15);
|
|
color: var(--rs-accent, #14b8a6);
|
|
}
|
|
|
|
.hint { font-size: 0.7rem; color: var(--rs-text-muted, #666); margin: 0; }
|
|
|
|
/* ── DIY Guide ── */
|
|
|
|
.guide-card {
|
|
background: var(--rs-card-bg, #232336);
|
|
border: 1px solid var(--rs-card-border, #2a2a3e);
|
|
border-radius: 0.5rem;
|
|
padding: 0.875rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.guide-stats {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 0.5rem;
|
|
text-align: center;
|
|
}
|
|
.guide-stat-col { display: flex; flex-direction: column; gap: 0.125rem; }
|
|
.guide-stat-value { font-size: 0.85rem; font-weight: 700; color: var(--rs-accent, #14b8a6); }
|
|
.guide-stat-label { font-size: 0.65rem; color: var(--rs-text-muted, #666); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
|
|
.guide-info-banner {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.625rem;
|
|
background: rgba(20, 184, 166, 0.08);
|
|
border: 1px solid rgba(20, 184, 166, 0.15);
|
|
border-radius: 0.375rem;
|
|
font-size: 0.72rem;
|
|
color: var(--rs-text-secondary, #aaa);
|
|
line-height: 1.4;
|
|
}
|
|
.guide-info-banner svg { color: var(--rs-accent, #14b8a6); flex-shrink: 0; margin-top: 0.05rem; }
|
|
|
|
.guide-section { display: flex; flex-direction: column; gap: 0.375rem; }
|
|
.guide-section-title {
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
color: var(--rs-text-secondary, #aaa);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.guide-list {
|
|
margin: 0; padding-left: 1.25rem;
|
|
font-size: 0.75rem; color: var(--rs-text-primary, #ddd); line-height: 1.5;
|
|
}
|
|
.guide-list li { margin-bottom: 0.2rem; }
|
|
|
|
.guide-numbered-steps { display: flex; flex-direction: column; gap: 0.375rem; }
|
|
.guide-step {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.5rem;
|
|
font-size: 0.75rem;
|
|
color: var(--rs-text-primary, #ddd);
|
|
line-height: 1.4;
|
|
}
|
|
.guide-step-num {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px; height: 20px;
|
|
border-radius: 50%;
|
|
background: var(--rs-accent, #14b8a6);
|
|
color: #fff;
|
|
font-size: 0.65rem;
|
|
font-weight: 700;
|
|
flex-shrink: 0;
|
|
margin-top: 0.05rem;
|
|
}
|
|
|
|
.guide-tips {
|
|
margin: 0; padding: 0; list-style: none;
|
|
font-size: 0.75rem; color: var(--rs-text-primary, #ddd); line-height: 1.5;
|
|
}
|
|
.guide-tips li { margin-bottom: 0.2rem; }
|
|
|
|
/* ── Pricing Tiers ── */
|
|
|
|
.pricing-tiers {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 0.5rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
.tier-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.125rem;
|
|
padding: 0.625rem 0.5rem;
|
|
border: 1px solid var(--rs-card-border, #2a2a3e);
|
|
border-radius: 0.5rem;
|
|
background: var(--rs-card-bg, #232336);
|
|
text-align: center;
|
|
position: relative;
|
|
}
|
|
.tier-card.popular {
|
|
border-color: var(--rs-accent, #14b8a6);
|
|
background: rgba(20, 184, 166, 0.06);
|
|
}
|
|
.tier-badge {
|
|
position: absolute;
|
|
top: -8px;
|
|
font-size: 0.55rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
padding: 0.1rem 0.5rem;
|
|
border-radius: 1rem;
|
|
background: var(--rs-accent, #14b8a6);
|
|
color: #fff;
|
|
}
|
|
.tier-qty { font-size: 0.9rem; font-weight: 700; color: var(--rs-accent, #14b8a6); }
|
|
.tier-binding { font-size: 0.65rem; color: var(--rs-text-muted, #666); }
|
|
.tier-price { font-size: 0.75rem; font-weight: 600; color: var(--rs-text-primary, #eee); }
|
|
|
|
/* ── Printer List ── */
|
|
|
|
.printer-list { display: flex; flex-direction: column; gap: 0.375rem; max-height: 300px; overflow-y: auto; }
|
|
.printer-card {
|
|
text-align: left;
|
|
padding: 0.625rem 0.75rem;
|
|
border: 1px solid var(--rs-card-border, #2a2a3e);
|
|
border-radius: 0.5rem;
|
|
background: var(--rs-card-bg, #232336);
|
|
color: var(--rs-text-primary, #eee);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.printer-card:hover {
|
|
border-color: var(--rs-accent, #14b8a6);
|
|
background: rgba(20, 184, 166, 0.04);
|
|
}
|
|
.printer-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.printer-name { font-size: 0.8rem; font-weight: 600; }
|
|
.printer-distance { font-size: 0.7rem; color: var(--rs-text-muted, #666); }
|
|
.printer-meta {
|
|
font-size: 0.7rem;
|
|
color: var(--rs-text-secondary, #aaa);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
margin-top: 0.125rem;
|
|
}
|
|
.verified-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.2rem;
|
|
font-size: 0.6rem;
|
|
color: var(--rs-accent, #14b8a6);
|
|
}
|
|
.printer-tags { display: flex; gap: 0.25rem; flex-wrap: wrap; margin-top: 0.375rem; }
|
|
.tag {
|
|
font-size: 0.6rem;
|
|
padding: 0.15rem 0.5rem;
|
|
border-radius: 1rem;
|
|
background: rgba(20, 184, 166, 0.1);
|
|
color: var(--rs-accent, #14b8a6);
|
|
}
|
|
.printer-caps { font-size: 0.65rem; color: var(--rs-text-muted, #666); margin-top: 0.25rem; }
|
|
|
|
/* ── Provider Detail ── */
|
|
|
|
.back-btn {
|
|
background: none; border: none;
|
|
color: var(--rs-text-secondary, #aaa);
|
|
font-size: 0.75rem; cursor: pointer;
|
|
padding: 0; text-align: left;
|
|
}
|
|
.back-btn:hover { color: var(--rs-text-primary, #eee); }
|
|
|
|
.provider-detail {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.625rem;
|
|
}
|
|
.provider-card-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
.provider-card-header svg { color: var(--rs-accent, #14b8a6); }
|
|
.provider-detail h4 { margin: 0; font-size: 0.9rem; }
|
|
|
|
.provider-info-grid {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.375rem;
|
|
padding: 0.625rem 0.75rem;
|
|
background: var(--rs-card-bg, #232336);
|
|
border: 1px solid var(--rs-card-border, #2a2a3e);
|
|
border-radius: 0.5rem;
|
|
}
|
|
.provider-info-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.75rem;
|
|
color: var(--rs-text-secondary, #aaa);
|
|
}
|
|
.provider-info-row svg { color: var(--rs-text-muted, #666); flex-shrink: 0; }
|
|
.provider-info-row a { color: var(--rs-accent, #14b8a6); text-decoration: none; }
|
|
.provider-info-row a:hover { text-decoration: underline; }
|
|
|
|
.provider-desc { font-size: 0.72rem; color: var(--rs-text-muted, #666); line-height: 1.4; }
|
|
|
|
/* ── Batch / Group Buy ── */
|
|
|
|
.batch-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
padding: 0.75rem;
|
|
background: var(--rs-card-bg, #232336);
|
|
border: 1px solid var(--rs-card-border, #2a2a3e);
|
|
border-radius: 0.5rem;
|
|
}
|
|
.batch-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
color: var(--rs-text-primary, #eee);
|
|
}
|
|
.batch-header svg { color: var(--rs-accent, #14b8a6); }
|
|
.batch-bar-wrap { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
.batch-bar {
|
|
height: 6px;
|
|
border-radius: 3px;
|
|
background: var(--rs-border-subtle, #333);
|
|
overflow: hidden;
|
|
}
|
|
.batch-bar-fill {
|
|
height: 100%;
|
|
border-radius: 3px;
|
|
background: linear-gradient(90deg, var(--rs-accent, #14b8a6), var(--rs-accent-hover, #0d9488));
|
|
transition: width 0.4s ease;
|
|
}
|
|
.batch-bar-label { font-size: 0.68rem; color: var(--rs-text-muted, #666); }
|
|
.batch-tiers {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
font-size: 0.68rem;
|
|
}
|
|
.tier-unlocked { color: var(--rs-accent, #14b8a6); font-weight: 500; }
|
|
.tier-locked { color: var(--rs-text-muted, #666); }
|
|
|
|
/* ── Responsive ── */
|
|
|
|
@media (max-width: 480px) {
|
|
.tab-content { padding: 0.75rem; }
|
|
.pricing-tiers { grid-template-columns: 1fr; }
|
|
.guide-stats { grid-template-columns: 1fr; }
|
|
}
|
|
</style>`;
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-pubs-publish-panel", FolkPubsPublishPanel);
|