183 lines
7.5 KiB
TypeScript
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);
|