rspace-online/modules/rpubs/components/folk-pubs-publish-panel.ts

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 &amp; 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 &amp; 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">&larr; 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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);