rspace-online/modules/providers/components/folk-provider-directory.ts

183 lines
7.5 KiB
TypeScript

/**
* <folk-provider-directory> — browseable provider directory.
* Shows a grid of provider cards with search, capability filter, and proximity sorting.
*/
class FolkProviderDirectory extends HTMLElement {
private shadow: ShadowRoot;
private providers: any[] = [];
private capabilities: string[] = [];
private selectedCap = "";
private searchQuery = "";
private userLat: number | null = null;
private userLng: number | null = null;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
this.loadProviders();
}
private getApiBase(): string {
const path = window.location.pathname;
const parts = path.split("/").filter(Boolean);
return parts.length >= 2 ? `/${parts[0]}/providers` : "/demo/providers";
}
private async loadProviders() {
try {
const params = new URLSearchParams();
if (this.selectedCap) params.set("capability", this.selectedCap);
if (this.userLat && this.userLng) {
params.set("lat", String(this.userLat));
params.set("lng", String(this.userLng));
}
params.set("limit", "100");
const res = await fetch(`${this.getApiBase()}/api/providers?${params}`);
const data = await res.json();
this.providers = data.providers || [];
// Collect unique capabilities
const capSet = new Set<string>();
for (const p of this.providers) {
for (const cap of (p.capabilities || [])) capSet.add(cap);
}
this.capabilities = Array.from(capSet).sort();
this.render();
} catch (e) {
console.error("Failed to load providers:", e);
}
}
private render() {
const filtered = this.providers.filter((p) => {
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
const name = (p.name || "").toLowerCase();
const city = (p.location?.city || "").toLowerCase();
const country = (p.location?.country || "").toLowerCase();
if (!name.includes(q) && !city.includes(q) && !country.includes(q)) return false;
}
return true;
});
this.shadow.innerHTML = `
<style>
:host { display: block; padding: 1.5rem; }
.header { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; margin-bottom: 1.5rem; }
.header h2 { margin: 0; font-size: 1.5rem; color: #f1f5f9; flex: 1; }
.search { padding: 0.5rem 0.75rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #f1f5f9; font-size: 0.875rem; width: 240px; }
.search:focus { outline: none; border-color: #6366f1; }
.locate-btn { padding: 0.5rem 0.75rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.75rem; }
.locate-btn:hover { background: #334155; color: #f1f5f9; }
.locate-btn.active { border-color: #6366f1; color: #a5b4fc; }
.caps { display: flex; flex-wrap: wrap; gap: 0.375rem; margin-bottom: 1.5rem; }
.cap { padding: 0.25rem 0.625rem; border-radius: 999px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; font-size: 0.75rem; cursor: pointer; }
.cap:hover { border-color: #6366f1; color: #c7d2fe; }
.cap.active { background: #4f46e5; border-color: #6366f1; color: #fff; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; }
.card:hover { border-color: #475569; }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem; }
.card-name { font-size: 1.125rem; font-weight: 600; color: #f1f5f9; margin: 0; }
.card-location { font-size: 0.8125rem; color: #94a3b8; margin-top: 0.25rem; }
.badge { padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 500; }
.badge-active { background: rgba(34,197,94,0.15); color: #4ade80; }
.badge-distance { background: rgba(99,102,241,0.15); color: #a5b4fc; }
.caps-list { display: flex; flex-wrap: wrap; gap: 0.25rem; margin: 0.75rem 0; }
.cap-tag { padding: 0.125rem 0.5rem; border-radius: 4px; background: rgba(99,102,241,0.1); color: #818cf8; font-size: 0.6875rem; }
.card-footer { display: flex; gap: 1rem; font-size: 0.75rem; color: #64748b; border-top: 1px solid #334155; padding-top: 0.75rem; margin-top: 0.75rem; }
.card-footer a { color: #818cf8; text-decoration: none; }
.card-footer a:hover { text-decoration: underline; }
.turnaround { font-size: 0.75rem; color: #94a3b8; }
.empty { text-align: center; padding: 3rem; color: #64748b; }
</style>
<div class="header">
<h2>\u{1F3ED} Provider Directory</h2>
<input class="search" type="text" placeholder="Search providers..." value="${this.searchQuery}">
<button class="locate-btn ${this.userLat ? 'active' : ''}">\u{1F4CD} ${this.userLat ? 'Nearby' : 'Use location'}</button>
</div>
${this.capabilities.length > 0 ? `
<div class="caps">
<span class="cap ${!this.selectedCap ? 'active' : ''}" data-cap="">All</span>
${this.capabilities.map((cap) => `
<span class="cap ${this.selectedCap === cap ? 'active' : ''}" data-cap="${cap}">${cap}</span>
`).join("")}
</div>` : ""}
${filtered.length === 0 ? `<div class="empty">No providers found</div>` : `
<div class="grid">
${filtered.map((p) => `
<div class="card">
<div class="card-header">
<div>
<h3 class="card-name">${this.esc(p.name)}</h3>
<div class="card-location">${this.esc(p.location?.city || "")}${p.location?.region ? `, ${this.esc(p.location.region)}` : ""} ${this.esc(p.location?.country || "")}</div>
</div>
<div style="display:flex;gap:0.375rem;flex-direction:column;align-items:flex-end">
<span class="badge badge-active">\u2713 Active</span>
${p.distance_km !== undefined ? `<span class="badge badge-distance">${p.distance_km} km</span>` : ""}
</div>
</div>
<div class="caps-list">
${(p.capabilities || []).map((cap: string) => `<span class="cap-tag">${this.esc(cap)}</span>`).join("")}
</div>
${p.turnaround?.standard_days ? `<div class="turnaround">\u23F1 ${p.turnaround.standard_days} days standard${p.turnaround.rush_days ? ` / ${p.turnaround.rush_days} days rush (+${p.turnaround.rush_surcharge_pct || 0}%)` : ""}</div>` : ""}
<div class="card-footer">
${p.contact?.email ? `<a href="mailto:${this.esc(p.contact.email)}">\u2709 Email</a>` : ""}
${p.contact?.website ? `<a href="${this.esc(p.contact.website)}" target="_blank">\u{1F310} Website</a>` : ""}
${p.location?.offers_shipping ? `<span>\u{1F4E6} Ships</span>` : ""}
</div>
</div>
`).join("")}
</div>`}
`;
// Event listeners
this.shadow.querySelector(".search")?.addEventListener("input", (e) => {
this.searchQuery = (e.target as HTMLInputElement).value;
this.render();
});
this.shadow.querySelector(".locate-btn")?.addEventListener("click", () => {
if (this.userLat) {
this.userLat = null;
this.userLng = null;
this.loadProviders();
} else {
navigator.geolocation?.getCurrentPosition(
(pos) => {
this.userLat = pos.coords.latitude;
this.userLng = pos.coords.longitude;
this.loadProviders();
},
() => { console.warn("Geolocation denied"); }
);
}
});
this.shadow.querySelectorAll(".cap[data-cap]").forEach((el) => {
el.addEventListener("click", () => {
this.selectedCap = (el as HTMLElement).dataset.cap || "";
this.loadProviders();
});
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
}
customElements.define("folk-provider-directory", FolkProviderDirectory);