Files
HomeAssistantVS/custom_components/taskmate/www/taskmate-wishlist-card.js
T

974 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* TaskMate Wishlist Card (#932)
*
* One child's wishlist: things they want, what they have saved towards each,
* and what the family has pledged. The child adds a wish (it waits for a
* grown-up), moves points in, takes their own savings back out, and asks to
* redeem once it's funded — which becomes an ordinary reward claim for the
* parent. Pledges are entered by a parent in the admin panel.
*
* Reads the overview sensor (children, points name/icon) plus the child's own
* wishlist sensor, found by its `wishlist_child_id` attribute. Pictures are
* stored images, signed per viewer through auth/sign_path before use.
*
* Single layout, so it consumes the design tokens directly (like the family
* goal card) rather than carrying a second _renderDesigned() template. Every
* --tmd-* reference has the card's original value as its fallback, so classic
* — which defines no tokens — renders as designed. Layout classes carry a
* wl- prefix because the shared design kit defines its own .btn / .bar.
*/
const LitElement = customElements.get("hui-masonry-view")
? Object.getPrototypeOf(customElements.get("hui-masonry-view"))
: Object.getPrototypeOf(customElements.get("hui-view"));
const html = LitElement.prototype.html;
const css = LitElement.prototype.css;
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
// Keeps number runs like "3 / 1" reading left to right in RTL text (#995);
// identity until the design layer has loaded (it is also what stamps dir).
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
// "a / b" for a template: the same text nodes as a literal `${a} / ${b}` in
// LTR (so it renders pixel-for-pixel as before), the isolated string in RTL.
const _ltrRatio = (a, b) => {
const s = `${a} / ${b}`;
const r = _ltrNums(s);
return r === s ? html`${a} / ${b}` : r;
};
const DEFAULT_ACCENT = "#e91e63";
const IMAGE_PREFIX = "/api/taskmate/image/";
const TARGET_STEP = 10;
const MAX_TARGET = 100000;
/**
* A wish link is typed by a child. Only an absolute http(s) URL is ever
* rendered as a link — never javascript:, data: or a relative path — even
* though the backend already refuses anything else.
*/
function safeLink(value) {
if (typeof value !== "string" || !value) return null;
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return { href: url.href, host: url.hostname.replace(/^www\./, "") };
} catch (_e) {
return null;
}
}
class TaskMateWishlistCard extends LitElement {
static get properties() {
return {
hass: { type: Object },
config: { type: Object },
_sheet: { state: true },
_signed: { state: true },
};
}
constructor() {
super();
this._sheet = null;
this._signed = {};
this._inflight = new Set();
}
setConfig(config) {
if (!config || !config.entity) {
throw new Error("A TaskMate overview entity is required");
}
this.config = config;
}
static getStubConfig() {
return { entity: "sensor.taskmate_overview" };
}
static getConfigElement() {
return document.createElement("taskmate-wishlist-card-editor");
}
getCardSize() {
return 5;
}
shouldUpdate(changedProps) {
if (changedProps.has("hass")) {
const old = changedProps.get("hass");
const wlId = this._wishlistEntityId();
if (old && wlId && old.states && this.hass.states[wlId] !== old.states[wlId]) return true;
return window.__taskmate_hasChanged
? window.__taskmate_hasChanged(old, this.hass, this.config && this.config.entity)
: true;
}
return true;
}
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
/**
* Resolve the active design, stamp data-tm-design on the host, and set the
* accent. The accent is an inline host property, not a template <style>: a
* shadow tree's <style> is ordered before adoptedStyleSheets, so it would
* lose to the :host default in static styles().
*/
_applyDesign() {
if (window.__taskmate_design) {
window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity);
}
const configured = this.config.header_color;
if (typeof configured === "string" && /^#[0-9a-fA-F]{3,8}$/.test(configured)) {
this.style.setProperty("--wl-accent", _safeColor(configured, DEFAULT_ACCENT));
} else {
this.style.removeProperty("--wl-accent");
}
}
// ── data ────────────────────────────────────────────────────────────────
/** The child's own wishlist sensor, found by the child id it carries. */
_wishlistEntityId() {
const childId = this.config && this.config.child_id;
if (!childId || !this.hass || !this.hass.states) return null;
const cached = this._wlEntityId;
const cachedState = cached && this.hass.states[cached];
if (cachedState && cachedState.attributes && cachedState.attributes.wishlist_child_id === childId) return cached;
this._wlEntityId = null;
for (const [id, st] of Object.entries(this.hass.states)) {
if (id.startsWith("sensor.") && st && st.attributes && st.attributes.wishlist_child_id === childId) {
this._wlEntityId = id;
break;
}
}
return this._wlEntityId;
}
_wishlist() {
const id = this._wishlistEntityId();
const st = id && this.hass.states[id];
const attrs = (st && st.attributes) || {};
return {
wishes: Array.isArray(attrs.wishes) ? attrs.wishes : [],
max: Number(attrs.wish_max_open) || 5,
};
}
_pledged(w) {
return Number(w.pledged) || 0;
}
_remaining(w) {
return Math.max(0, (Number(w.target) || 0) - (Number(w.saved) || 0) - this._pledged(w));
}
_funded(w) {
return (Number(w.target) || 0) > 0 && this._remaining(w) === 0;
}
// ── pictures ────────────────────────────────────────────────────────────
async _ensureSigned(wishes) {
for (const w of wishes) {
const url = w.image_url;
// Only our own stored images are signed; nothing else is ever handed to
// auth/sign_path or rendered as a picture.
if (typeof url !== "string" || !url.startsWith(IMAGE_PREFIX)) continue;
if (this._signed[url] || this._inflight.has(url)) continue;
this._inflight.add(url);
try {
const res = await this.hass.callWS({ type: "auth/sign_path", path: url, expires: 3600 });
if (res && res.path) this._signed = { ...this._signed, [url]: res.path };
} catch (e) {
console.warn("taskmate: sign_path failed", e);
} finally {
this._inflight.delete(url);
}
}
}
updated() {
if (this.hass && this.config) this._ensureSigned(this._wishlist().wishes);
}
// ── actions ─────────────────────────────────────────────────────────────
async _call(service, data) {
this._sheet = this._sheet ? { ...this._sheet, busy: true, error: "" } : null;
try {
await this.hass.callService("taskmate", service, { child_id: this.config.child_id, ...data });
this._closeSheet();
return true;
} catch (err) {
const message = (err && err.message) || String(err);
if (this._sheet) {
this._sheet = { ...this._sheet, busy: false, error: this._t("wishlist.error", { error: message }) };
} else {
this._flash = this._t("wishlist.error", { error: message });
this.requestUpdate();
}
return false;
}
}
_openSheet(sheet) {
this._flash = "";
this._sheet = { busy: false, error: "", ...sheet };
}
_closeSheet() {
if (this._sheet && this._sheet.previewUrl) URL.revokeObjectURL(this._sheet.previewUrl);
this._sheet = null;
}
_openAdd() {
this._openSheet({ kind: "add", name: "", target: 50, link: "" });
}
_openMove(w, spendable) {
const max = Math.min(spendable, this._remaining(w));
this._openSheet({ kind: "move", id: w.id, amount: Math.min(max, 50) });
}
_openTake(w) {
this._openSheet({ kind: "take", id: w.id, amount: Number(w.saved) || 0 });
}
_setSheet(patch) {
this._sheet = { ...this._sheet, ...patch, error: "" };
}
_stepTarget(delta) {
const next = Math.min(MAX_TARGET, Math.max(1, (Number(this._sheet.target) || 0) + delta));
this._setSheet({ target: next });
}
_pickPicture() {
const input = this.renderRoot && this.renderRoot.querySelector("#wl-file");
if (input) {
input.value = "";
input.click();
}
}
/** Downscale to a small JPEG in the browser: the server keeps it as-is. */
_onPicture(e) {
const file = e.target.files && e.target.files[0];
e.target.value = "";
if (!file || !this._sheet) return;
if (!file.type || !file.type.startsWith("image/")) {
this._sheet = { ...this._sheet, error: this._t("wishlist.picture_not_image") };
return;
}
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const max = 512;
let { width, height } = img;
if (width > max || height > max) {
const scale = Math.min(max / width, max / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.getContext("2d").drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => {
if (!this._sheet) return;
if (!blob) {
this._sheet = { ...this._sheet, error: this._t("wishlist.picture_failed") };
return;
}
if (this._sheet.previewUrl) URL.revokeObjectURL(this._sheet.previewUrl);
this._sheet = { ...this._sheet, blob, previewUrl: URL.createObjectURL(blob), error: "" };
}, "image/jpeg", 0.82);
};
img.onerror = () => {
URL.revokeObjectURL(url);
if (this._sheet) this._sheet = { ...this._sheet, error: this._t("wishlist.picture_failed") };
};
img.src = url;
}
/** Post the picture to the photo endpoint (open to children); the wish takes it over. */
async _uploadPicture(blob) {
const post = () => {
const token = this.hass && this.hass.auth && this.hass.auth.data && this.hass.auth.data.access_token;
const fd = new FormData();
fd.append("file", blob, "wish.jpg");
return fetch("/api/taskmate/photo", {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
});
};
if (this.hass.auth && this.hass.auth.expired && this.hass.auth.refreshAccessToken) {
try { await this.hass.auth.refreshAccessToken(); } catch (_e) { /* surfaced below */ }
}
let resp = await post();
if (resp.status === 401 && this.hass.auth && this.hass.auth.refreshAccessToken) {
await this.hass.auth.refreshAccessToken();
resp = await post();
}
if (!resp.ok) throw new Error(`upload failed (${resp.status})`);
const body = await resp.json();
if (!body || !body.photo_url) throw new Error("no photo_url");
return body.photo_url;
}
async _submitAdd() {
const s = this._sheet;
const name = (s.name || "").trim();
if (!name) return;
const link = (s.link || "").trim();
if (link && !safeLink(link)) {
this._sheet = { ...s, error: this._t("wishlist.link_invalid") };
return;
}
const data = { name, target: Number(s.target) || 1 };
if (link) data.link = link;
if (s.blob) {
this._sheet = { ...s, busy: true, error: "" };
try {
data.photo_url = await this._uploadPicture(s.blob);
} catch (_e) {
this._sheet = { ...this._sheet, busy: false, error: this._t("wishlist.picture_failed") };
return;
}
}
await this._call("add_wish", data);
}
// ── render ──────────────────────────────────────────────────────────────
render() {
if (!this.hass || !this.config) return html``;
this._applyDesign();
const entity = this.hass.states[this.config.entity];
if (!entity) {
return html`<ha-card><div class="wl-empty"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t("common.entity_not_found", { entity: this.config.entity })}</div></div></ha-card>`;
}
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
const children = Array.isArray(attrs.children) ? attrs.children : [];
const child = children.find((c) => c.id === this.config.child_id);
if (!this.config.child_id || !child) {
return html`<ha-card><div class="wl-empty"><ha-icon icon="mdi:heart-outline"></ha-icon><div>${this._t("wishlist.pick_child")}</div></div></ha-card>`;
}
const pointsIcon = attrs.points_icon || "mdi:star";
const spendable = Number(child.spendable_balance != null ? child.spendable_balance : child.points) || 0;
const { wishes, max } = this._wishlist();
const open = wishes.filter((w) => w.status === "pending" || w.status === "active" || w.status === "redeem_requested");
const reserved = wishes
.filter((w) => w.status === "active" || w.status === "redeem_requested")
.reduce((sum, w) => sum + (Number(w.saved) || 0), 0);
const atLimit = open.length >= max;
// Declined wishes sink to the bottom; the rest keep the order they were made in.
const ordered = [...wishes.filter((w) => w.status !== "declined"), ...wishes.filter((w) => w.status === "declined")];
const title = this.config.title || this._t("wishlist.title", { name: child.name });
return html`
<ha-card class="${this._sheet ? "wl-has-sheet" : ""}">
<div class="wl-header">
<div class="wl-hd-left">
<ha-icon class="wl-hd-icon" icon="mdi:heart"></ha-icon>
<div class="wl-hd-text">
<div class="wl-hd-title">${title}</div>
<div class="wl-hd-sub">${this._t("wishlist.subtitle")}</div>
</div>
</div>
<button class="wl-hd-pill" ?disabled=${atLimit}
title="${atLimit ? this._t("wishlist.limit_reached", { max }) : this._t("wishlist.add")}"
@click=${() => this._openAdd()}>
<ha-icon icon="mdi:plus"></ha-icon>${this._t("wishlist.add")}
</button>
</div>
<div class="wl-balance">
<ha-icon icon="mdi:piggy-bank-outline"></ha-icon>
<span>${this._t("wishlist.can_spend")}</span>
<b class="wl-amount"><ha-icon icon="${pointsIcon}"></ha-icon>${spendable}</b>
<span class="wl-reserved">${this._t("wishlist.saved_in_wishes", { points: reserved })}</span>
</div>
${this._flash ? html`<div class="wl-flash" role="alert">${this._flash}</div>` : ""}
<div class="wl-content">
${ordered.length === 0
? html`<div class="wl-empty wl-empty-list"><ha-icon icon="mdi:gift-outline"></ha-icon><div>${this._t("wishlist.empty")}</div></div>`
: ordered.map((w) => this._renderWish(w, spendable))}
</div>
${this._sheet ? this._renderSheet(wishes, spendable, pointsIcon) : ""}
</ha-card>
`;
}
_renderThumb(w) {
const src = this._signed[w.image_url];
return src
? html`<img class="wl-thumb" src="${src}" alt="" loading="lazy">`
: html`<div class="wl-thumb wl-thumb-ph"><ha-icon icon="mdi:gift"></ha-icon></div>`;
}
_renderWish(w, spendable) {
const target = Number(w.target) || 0;
const saved = Number(w.saved) || 0;
const pledged = this._pledged(w);
const remaining = this._remaining(w);
const funded = this._funded(w);
const link = safeLink(w.link);
const showProgress = w.status === "active" || w.status === "redeem_requested";
const cls = w.status === "pending" ? "pending"
: w.status === "declined" ? "declined"
: (funded || w.status === "redeem_requested") ? "funded" : "";
const pct = (n) => (target > 0 ? Math.max(0, Math.min(100, (n / target) * 100)) : 0);
return html`
<div class="wl-wish ${cls}" data-wish="${w.id}">
<div class="wl-wish-top">
${this._renderThumb(w)}
<div class="wl-grow">
<div class="wl-wish-name">${w.name}</div>
<div class="wl-wish-meta">
<span class="wl-tag"><ha-icon icon="mdi:flag-checkered"></ha-icon>${this._t("wishlist.target_tag", { points: target })}</span>
${link ? html`<a class="wl-link" href="${link.href}" target="_blank" rel="noopener noreferrer"
title="${this._t("wishlist.open_link")}"><ha-icon icon="mdi:link-variant"></ha-icon>${link.host}</a>` : ""}
</div>
</div>
</div>
${showProgress ? html`
<div class="wl-split" role="img" aria-label="${this._t("wishlist.progress_label", { total: Math.min(target, saved + pledged), target })}">
<i class="wl-me" style="width:${pct(saved)}%"></i>
<i class="wl-fam" style="width:${pct(Math.min(pledged, Math.max(0, target - saved)))}%"></i>
</div>
<div class="wl-legend">
<span><span class="wl-sw wl-sw-me"></span>${this._t("wishlist.legend_me", { points: saved })}</span>
<span><span class="wl-sw wl-sw-fam"></span>${this._t("wishlist.legend_family", { points: pledged })}</span>
<span class="wl-tot">${_ltrRatio(Math.min(target, saved + pledged), target)}</span>
</div>
${Array.isArray(w.pledges) && w.pledges.length ? html`
<div class="wl-pledges">
${w.pledges.map((p) => html`<span class="wl-pl"><ha-icon icon="mdi:heart"></ha-icon>${p.name} +${p.points}</span>`)}
</div>` : ""}
` : ""}
<div class="wl-acts">${this._renderActions(w, spendable, remaining, funded)}</div>
</div>
`;
}
_renderActions(w, spendable, remaining, funded) {
if (w.status === "pending") {
return html`
<span class="wl-status warn"><ha-icon icon="mdi:timer-sand"></ha-icon>${this._t("wishlist.status_pending")}</span>
<button class="wl-btn text" @click=${() => this._call("withdraw_wish", { wish_id: w.id })}>${this._t("wishlist.withdraw")}</button>`;
}
if (w.status === "declined") {
return html`
<span class="wl-status bad"><ha-icon icon="mdi:close-circle-outline"></ha-icon>${this._t("wishlist.status_declined")}</span>
${w.decline_reason ? html`<span class="wl-reason">${w.decline_reason}</span>` : ""}
<button class="wl-btn text" @click=${() => this._call("withdraw_wish", { wish_id: w.id })}>${this._t("wishlist.dismiss")}</button>`;
}
if (w.status === "redeem_requested") {
return html`<span class="wl-status good"><ha-icon icon="mdi:gift"></ha-icon>${this._t("wishlist.status_redeem_requested")}</span>`;
}
const takeBack = (Number(w.saved) || 0) > 0
? html`<button class="wl-btn text" @click=${() => this._openTake(w)}>${this._t("wishlist.take_back")}</button>`
: "";
if (funded) {
return html`
<div class="wl-funded"><ha-icon icon="mdi:trophy"></ha-icon>${this._t("wishlist.fully_funded")}</div>
<button class="wl-btn green" @click=${() => this._openSheet({ kind: "redeem", id: w.id })}>
<ha-icon icon="mdi:gift"></ha-icon>${this._t("wishlist.ask_redeem")}</button>
${takeBack}`;
}
return html`
<button class="wl-btn accent" ?disabled=${spendable < 1} @click=${() => this._openMove(w, spendable)}>
<ha-icon icon="mdi:piggy-bank-outline"></ha-icon>${this._t("wishlist.move_points")}</button>
<span class="wl-to-go">${this._t("wishlist.to_go", { points: remaining })}</span>
<span class="wl-grow"></span>
${takeBack}`;
}
_renderSheet(wishes, spendable, pointsIcon) {
const s = this._sheet;
const wish = s.id ? wishes.find((w) => w.id === s.id) : null;
let body = "";
if (s.kind === "add") body = this._renderAddSheet(s);
else if (!wish) return "";
else if (s.kind === "move") body = this._renderAmountSheet(s, wish, Math.min(spendable, this._remaining(wish)), pointsIcon, "move", spendable);
else if (s.kind === "take") body = this._renderAmountSheet(s, wish, Number(wish.saved) || 0, pointsIcon, "take", spendable);
else if (s.kind === "redeem") body = this._renderRedeemSheet(s, wish);
return html`
<div class="wl-scrim" @click=${(e) => { if (e.target === e.currentTarget && !s.busy) this._closeSheet(); }}>
<div class="wl-sheet" role="dialog" aria-modal="true">
<div class="wl-grab"></div>
${body}
${s.error ? html`<div class="wl-error" role="alert">${s.error}</div>` : ""}
</div>
</div>`;
}
_renderAddSheet(s) {
return html`
<h3><ha-icon icon="mdi:star-shooting-outline"></ha-icon>${this._t("wishlist.add_title")}</h3>
<label class="wl-field">
<span class="wl-label">${this._t("wishlist.field_name")}</span>
<input class="wl-input" type="text" maxlength="60" .value=${s.name}
@input=${(e) => this._setSheet({ name: e.target.value })}>
</label>
<div class="wl-field">
<span class="wl-label">${this._t("wishlist.field_target")}</span>
<div class="wl-row">
<div class="wl-stepper">
<button aria-label="−" @click=${() => this._stepTarget(-TARGET_STEP)}>−</button>
<input type="number" min="1" max="${MAX_TARGET}" .value=${String(s.target)}
@input=${(e) => this._setSheet({ target: Math.max(1, Math.min(MAX_TARGET, parseInt(e.target.value, 10) || 1)) })}>
<button aria-label="+" @click=${() => this._stepTarget(TARGET_STEP)}>+</button>
</div>
<span class="wl-hint">${this._t("wishlist.target_hint")}</span>
</div>
</div>
<label class="wl-field">
<span class="wl-label">${this._t("wishlist.field_link")} <span class="wl-hint">${this._t("wishlist.optional")}</span></span>
<input class="wl-input" type="url" maxlength="500" placeholder="https://…" .value=${s.link}
@input=${(e) => this._setSheet({ link: e.target.value })}>
</label>
<div class="wl-field">
<span class="wl-label">${this._t("wishlist.field_picture")} <span class="wl-hint">${this._t("wishlist.optional")}</span></span>
<input id="wl-file" type="file" accept="image/*" hidden @change=${(e) => this._onPicture(e)}>
<button class="wl-drop" @click=${() => this._pickPicture()}>
${s.previewUrl
? html`<img src="${s.previewUrl}" alt=""><span>${this._t("wishlist.picture_change")}</span>`
: html`<ha-icon icon="mdi:image-plus"></ha-icon><span>${this._t("wishlist.picture_pick")}</span>`}
</button>
</div>
<div class="wl-sheet-acts">
<button class="wl-btn ghost" ?disabled=${s.busy} @click=${() => this._closeSheet()}>${this._t("common.cancel")}</button>
<button class="wl-btn accent" ?disabled=${s.busy || !(s.name || "").trim()} @click=${() => this._submitAdd()}>${this._t("wishlist.send")}</button>
</div>`;
}
_renderAmountSheet(s, wish, max, pointsIcon, mode, spendable) {
const amount = Math.max(0, Math.min(Number(s.amount) || 0, max));
const isMove = mode === "move";
const chips = [10, 25, 50].filter((n) => n < max);
const service = isMove ? "move_points_to_wish" : "take_points_from_wish";
return html`
<h3><ha-icon class=${isMove ? "" : "tm-rtl-flip"} icon="${isMove ? "mdi:piggy-bank-outline" : "mdi:undo-variant"}"></ha-icon>${this._t(isMove ? "wishlist.move_title" : "wishlist.take_title", { name: wish.name })}</h3>
<div class="wl-big-amt">${amount}<ha-icon icon="${pointsIcon}"></ha-icon></div>
<input class="wl-range" type="range" min="0" max="${max}" step="1" .value=${String(amount)}
@input=${(e) => this._setSheet({ amount: Number(e.target.value) })}>
<div class="wl-range-info">
<span>0</span>
<span>${isMove
? this._t("wishlist.move_info", { balance: spendable, needed: this._remaining(wish) })
: this._t("wishlist.take_info", { saved: Number(wish.saved) || 0 })}</span>
<span>${max}</span>
</div>
<div class="wl-chips">
${chips.map((n) => html`<button class="wl-chip ${amount === n ? "on" : ""}" @click=${() => this._setSheet({ amount: n })}>+${n}</button>`)}
<button class="wl-chip ${amount === max ? "on" : ""}" @click=${() => this._setSheet({ amount: max })}>
${this._t(isMove ? "wishlist.all_i_need" : "wishlist.take_all", { points: max })}</button>
</div>
<div class="wl-sheet-acts">
<button class="wl-btn ghost" ?disabled=${s.busy} @click=${() => this._closeSheet()}>${this._t("common.cancel")}</button>
<button class="wl-btn accent" ?disabled=${s.busy || amount < 1}
@click=${() => this._call(service, { wish_id: wish.id, points: amount })}>
${this._t(isMove ? "wishlist.move_confirm" : "wishlist.take_confirm", { points: amount })}</button>
</div>`;
}
_renderRedeemSheet(s, wish) {
return html`
<div class="wl-redeem">
<div class="wl-big-ic"><ha-icon icon="mdi:gift"></ha-icon></div>
<h3>${this._t("wishlist.redeem_title", { name: wish.name })}</h3>
<p>${this._t("wishlist.redeem_body")}</p>
<div class="wl-sheet-acts center">
<button class="wl-btn ghost" ?disabled=${s.busy} @click=${() => this._closeSheet()}>${this._t("wishlist.not_yet")}</button>
<button class="wl-btn green" ?disabled=${s.busy}
@click=${() => this._call("request_wish_redeem", { wish_id: wish.id })}>${this._t("wishlist.yes_please")}</button>
</div>
</div>`;
}
static get styles() {
const base = css`
:host {
display: block;
--wl-accent: var(--tmd-accent, #e91e63);
--wl-good: var(--tmd-good, #2ecc71);
--wl-gold: var(--tmd-gold, #f1c40f);
--wl-text: var(--tmd-text, var(--primary-text-color));
--wl-dim: var(--tmd-dim, var(--secondary-text-color));
--wl-surface: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
--wl-surface-2: var(--tmd-surface-2, var(--secondary-background-color, #f5f5f5));
--wl-border: var(--tmd-border, var(--divider-color, #e0e0e0));
--wl-me: var(--wl-accent);
--wl-link: var(--tmd-accent, var(--primary-color, #03a9f4));
}
/* Graphite keeps colour for meaning only: its accent is a neutral that
all but vanishes on its own dark surfaces, so the child's share, links
and the main button follow the text colour instead (as its kit does). */
:host([data-tm-design="graphite"]) {
--wl-me: var(--tmd-text);
--wl-link: var(--tmd-text);
}
:host([data-tm-design="graphite"]) .wl-btn.accent { background: var(--tmd-text); color: var(--tmd-surface); }
ha-card {
position: relative;
overflow: hidden;
background: var(--wl-surface);
color: var(--wl-text);
border-radius: var(--tmd-radius, var(--ha-card-border-radius, 12px));
font-family: var(--tmd-font-body, inherit);
}
ha-card.wl-has-sheet { min-height: 560px; }
button { font-family: inherit; }
ha-icon { --mdc-icon-size: 18px; }
.wl-header {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 14px 18px; background: var(--wl-accent); color: var(--tmd-hd-text, #fff);
}
.wl-hd-left { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1; }
.wl-hd-icon { --mdc-icon-size: 30px; flex: none; opacity: .95; }
.wl-hd-text { min-width: 0; }
.wl-hd-title {
font-size: 1.3rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
font-family: var(--tmd-font-display, inherit);
}
.wl-hd-sub { font-size: .8rem; opacity: .85; margin-top: 1px; }
.wl-hd-pill {
display: inline-flex; align-items: center; gap: 5px; flex: none; cursor: pointer;
background: rgba(255, 255, 255, .2); color: inherit; border: 0;
padding: 5px 12px; border-radius: 16px; font-size: .9rem; font-weight: 500; white-space: nowrap;
}
.wl-hd-pill[disabled] { opacity: .5; cursor: default; }
.wl-balance {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 10px 16px; font-size: .92rem;
background: color-mix(in srgb, var(--wl-accent) 10%, var(--wl-surface));
border-bottom: 1px solid var(--wl-border);
}
.wl-balance > ha-icon { color: var(--wl-accent); }
.wl-amount { display: inline-flex; align-items: center; gap: 3px; font-size: 1.05rem; color: var(--wl-gold); }
.wl-reserved { margin-inline-start: auto; font-size: .78rem; color: var(--wl-dim); }
.wl-flash { margin: 10px 16px 0; font-size: .85rem; color: var(--tmd-bad, #e74c3c); }
.wl-content { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.wl-empty {
display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center;
padding: 28px 16px; color: var(--wl-dim);
}
.wl-empty ha-icon { --mdc-icon-size: 32px; }
.wl-empty-list { padding: 18px 8px; }
.wl-wish {
background: var(--wl-surface-2); border: 1px solid var(--wl-border);
border-radius: var(--tmd-radius-sm, 14px); overflow: hidden;
}
.wl-wish.pending { border-style: dashed; opacity: .88; }
.wl-wish.declined { opacity: .8; }
.wl-wish.funded {
border: 2px solid var(--wl-good);
background: color-mix(in srgb, var(--wl-good) 8%, var(--wl-surface-2));
}
.wl-wish-top { display: flex; gap: 12px; padding: 12px; }
.wl-thumb {
width: 72px; height: 72px; flex: none; border-radius: var(--tmd-radius-sm, 12px);
object-fit: cover; display: block;
}
.wl-thumb-ph {
display: grid; place-items: center; color: rgba(255, 255, 255, .92);
background: linear-gradient(135deg, var(--wl-accent), color-mix(in srgb, var(--wl-accent) 45%, #3498db));
}
.wl-thumb-ph ha-icon { --mdc-icon-size: 34px; }
.wl-grow { flex: 1; min-width: 0; }
.wl-wish-name {
font-size: 1.1rem; font-weight: 700; line-height: 1.25; overflow-wrap: anywhere;
font-family: var(--tmd-font-display, inherit);
}
.wl-wish-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; align-items: center; }
.wl-tag, .wl-link {
display: inline-flex; align-items: center; gap: 4px; font-size: .75rem; font-weight: 600;
padding: 2px 9px; border-radius: 999px; white-space: nowrap;
}
.wl-tag { background: color-mix(in srgb, var(--wl-text) 8%, transparent); color: var(--wl-dim); }
.wl-tag ha-icon, .wl-link ha-icon { --mdc-icon-size: 13px; }
.wl-link {
color: var(--wl-link); text-decoration: none;
background: color-mix(in srgb, var(--wl-link) 14%, transparent);
max-width: 100%; overflow: hidden; text-overflow: ellipsis;
}
.wl-split {
margin: 0 12px; height: 16px; border-radius: 8px; overflow: hidden; display: flex;
background: color-mix(in srgb, var(--wl-text) 12%, transparent);
border: 2px solid color-mix(in srgb, var(--wl-text) 8%, transparent);
}
.wl-split i { display: block; height: 100%; }
.wl-me { background: var(--wl-me); }
.wl-fam { background: repeating-linear-gradient(135deg, var(--wl-gold) 0 6px, #f39c12 6px 12px); }
.wl-legend {
display: flex; gap: 12px; flex-wrap: wrap; align-items: center;
padding: 6px 12px 0; font-size: .8rem; color: var(--wl-dim);
}
.wl-sw { width: 10px; height: 10px; border-radius: 3px; display: inline-block; margin-inline-end: 4px; vertical-align: -1px; }
.wl-sw-me { background: var(--wl-me); }
.wl-sw-fam { background: var(--wl-gold); }
.wl-tot { margin-inline-start: auto; color: var(--wl-text); font-weight: 700; font-variant-numeric: tabular-nums; }
.wl-pledges { padding: 8px 12px 0; display: flex; flex-wrap: wrap; gap: 6px; }
.wl-pl {
display: inline-flex; align-items: center; gap: 5px; font-size: .78rem; border-radius: 999px; padding: 3px 9px;
background: color-mix(in srgb, var(--wl-gold) 16%, transparent); color: var(--wl-text);
}
.wl-pl ha-icon { --mdc-icon-size: 13px; color: #ff6b9d; }
.wl-acts { display: flex; gap: 8px; padding: 10px 12px 12px; flex-wrap: wrap; align-items: center; }
.wl-status {
display: inline-flex; align-items: center; gap: 5px; font-size: .8rem; font-weight: 700;
padding: 4px 10px; border-radius: 10px; line-height: 1.3;
}
.wl-status.warn { background: color-mix(in srgb, var(--tmd-warn, #f39c12) 18%, transparent); color: var(--tmd-warn, #d68910); }
.wl-status.good { background: color-mix(in srgb, var(--wl-good) 18%, transparent); color: var(--wl-good); }
.wl-status.bad { background: color-mix(in srgb, var(--tmd-bad, #e74c3c) 16%, transparent); color: var(--tmd-bad, #e74c3c); }
.wl-reason { font-size: .82rem; color: var(--wl-dim); font-style: italic; overflow-wrap: anywhere; }
.wl-funded {
display: flex; align-items: center; gap: 6px; flex: 1 0 auto;
font-weight: 700; color: var(--wl-good); white-space: nowrap;
}
.wl-funded ha-icon { --mdc-icon-size: 20px; }
.wl-to-go { font-size: .8rem; color: var(--wl-dim); }
.wl-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px; cursor: pointer;
border: 0; border-radius: var(--tmd-radius-sm, 10px); padding: 8px 14px;
font-size: .88rem; font-weight: 600; color: #fff; background: var(--wl-accent);
}
.wl-btn.accent { background: var(--wl-accent); color: var(--tmd-hd-text, #fff); }
.wl-btn.green { background: var(--wl-good); color: #fff; }
.wl-btn.ghost { background: transparent; color: var(--wl-text); border: 1px solid var(--wl-border); }
.wl-btn.text { background: transparent; color: var(--wl-dim); padding: 8px 6px; }
.wl-btn[disabled] { opacity: .45; cursor: default; }
.wl-scrim {
position: absolute; inset: 0; z-index: 5; display: flex; align-items: flex-end;
background: rgba(0, 0, 0, .5);
}
.wl-sheet {
width: 100%; box-sizing: border-box; max-height: 96%; overflow-y: auto;
background: var(--wl-surface); color: var(--wl-text);
border-radius: 18px 18px 0 0; padding: 8px 16px 16px;
box-shadow: 0 -10px 30px rgba(0, 0, 0, .35);
border-top: 1px solid var(--wl-border);
}
.wl-grab { width: 40px; height: 4px; border-radius: 2px; background: var(--wl-border); margin: 4px auto 10px; }
.wl-sheet h3 {
margin: 0 0 12px; font-size: 1.15rem; display: flex; align-items: center; gap: 8px;
font-family: var(--tmd-font-display, inherit);
}
.wl-sheet h3 ha-icon { --mdc-icon-size: 22px; color: var(--wl-accent); }
.wl-field { display: block; margin-bottom: 14px; }
.wl-label { display: block; font-size: .8rem; font-weight: 600; margin-bottom: 6px; }
.wl-hint { font-size: .75rem; color: var(--wl-dim); font-weight: 400; }
.wl-input {
width: 100%; box-sizing: border-box; padding: 10px 12px; font: inherit; font-size: .95rem;
color: var(--wl-text); background: var(--wl-surface-2);
border: 1px solid var(--wl-border); border-radius: var(--tmd-radius-sm, 8px);
}
.wl-input:focus { outline: 2px solid var(--wl-accent); outline-offset: 1px; }
.wl-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.wl-stepper {
display: inline-flex; align-items: center; overflow: hidden;
border: 1px solid var(--wl-border); border-radius: var(--tmd-radius-sm, 8px);
}
.wl-stepper button {
width: 36px; height: 36px; border: 0; cursor: pointer; font-size: 18px;
background: var(--wl-surface-2); color: var(--wl-text);
}
.wl-stepper input {
width: 72px; height: 36px; border: 0; text-align: center; font: inherit; font-weight: 700;
background: var(--wl-surface); color: var(--wl-text); -moz-appearance: textfield;
}
.wl-stepper input::-webkit-inner-spin-button { -webkit-appearance: none; }
.wl-drop {
width: 100%; display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer;
border: 2px dashed var(--wl-border); border-radius: var(--tmd-radius-sm, 12px);
padding: 14px; background: transparent; color: var(--wl-dim); font-size: .85rem;
}
.wl-drop ha-icon { --mdc-icon-size: 28px; }
.wl-drop img { width: 96px; height: 96px; object-fit: cover; border-radius: 10px; }
.wl-sheet-acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
.wl-sheet-acts.center { justify-content: center; }
.wl-big-amt {
font-size: 44px; font-weight: 800; display: flex; align-items: center; justify-content: center; gap: 6px;
color: var(--wl-gold); margin: 4px 0; font-variant-numeric: tabular-nums;
}
.wl-big-amt ha-icon { --mdc-icon-size: 34px; }
.wl-range { width: 100%; accent-color: var(--wl-accent); }
.wl-range-info {
display: flex; justify-content: space-between; gap: 8px; font-size: .75rem; color: var(--wl-dim);
margin: 4px 0 12px; text-align: center;
}
.wl-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.wl-chip {
cursor: pointer; font: inherit; font-size: .8rem; font-weight: 600; padding: 5px 12px; border-radius: 999px;
background: var(--wl-surface-2); color: var(--wl-text); border: 1px solid var(--wl-border);
}
.wl-chip.on { border-color: var(--wl-accent); color: var(--wl-accent); }
.wl-redeem { text-align: center; padding-top: 4px; }
.wl-redeem h3 { justify-content: center; }
.wl-redeem p { margin: 0 0 16px; color: var(--wl-dim); }
.wl-big-ic {
width: 64px; height: 64px; border-radius: 50%; display: grid; place-items: center; margin: 0 auto 10px;
background: color-mix(in srgb, var(--wl-good) 20%, transparent); color: var(--wl-good);
}
.wl-big-ic ha-icon { --mdc-icon-size: 34px; }
.wl-error { margin-top: 10px; font-size: .85rem; color: var(--tmd-bad, #e74c3c); }
`;
const tokens = window.__taskmate_design && window.__taskmate_design.styles
? window.__taskmate_design.styles() : null;
return tokens ? [tokens, base] : base;
}
}
// ── Card editor ────────────────────────────────────────────────────────────
class TaskMateWishlistCardEditor extends LitElement {
static get properties() {
return { hass: { type: Object }, config: { type: Object } };
}
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
static get styles() {
return css`
:host { display: block; }
ha-form { display: block; margin-bottom: 16px; }
`;
}
setConfig(config) { this.config = config; }
_buildSchema() {
const entity = this.config && this.config.entity ? this.hass && this.hass.states && this.hass.states[this.config.entity] : null;
const attrs = window.__taskmate_attrs && this.config && this.config.entity
? window.__taskmate_attrs(this.hass, this.config.entity)
: (entity && entity.attributes) || {};
const children = Array.isArray(attrs.children) ? attrs.children : [];
return [
{ name: "entity", selector: { entity: { domain: "sensor" } } },
{
name: "child_id",
selector: {
select: { options: children.map((c) => ({ value: c.id, label: c.name })), mode: "dropdown" },
},
},
{ name: "title", selector: { text: {} } },
{
name: "card_design",
selector: {
select: {
options: window.__taskmate_design
? window.__taskmate_design.editorOptions(this._t.bind(this))
: [{ value: "global", label: "Use global default" }],
mode: "dropdown",
},
},
},
];
}
_computeLabel = (entry) => {
const labels = {
entity: this._t("common.editor.overview_entity"),
child_id: this._t("wishlist.editor.child"),
title: this._t("common.editor.card_title"),
card_design: this._t("common.design.field_label"),
};
return labels[entry.name] ?? entry.name;
};
_computeHelper = (entry) => {
const helpers = {
entity: this._t("common.editor.overview_entity_helper"),
child_id: this._t("wishlist.editor.child_helper"),
title: this._t("wishlist.editor.title_helper"),
};
return helpers[entry.name] ?? "";
};
render() {
if (!this.hass || !this.config) return html``;
const data = {
entity: this.config.entity || "",
child_id: this.config.child_id || "",
title: this.config.title || "",
card_design: this.config.card_design || "global",
};
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._buildSchema()}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._formChanged}
></ha-form>
${this._renderColourPicker("header_color", DEFAULT_ACCENT)}
`;
}
_renderColourPicker(key, defaultValue) {
const d = window.__taskmate_design;
if (!d || !d.colourPicker) return html``;
return d.colourPicker({
defaultValue,
current: this.config[key] || defaultValue,
label: this._t("common.editor.header_colour"),
helper: this._t("common.editor.header_colour_helper"),
resetLabel: this._t("common.reset"),
onInput: (v) => this._updateConfig(key, v),
onPreset: (v) => this._updateConfig(key, v),
onReset: () => this._updateConfig(key, defaultValue),
});
}
_formChanged(e) {
const newValues = e.detail.value || {};
const newConfig = { ...this.config };
for (const [key, value] of Object.entries(newValues)) {
if (value === "" || value === null || value === undefined || (key === "card_design" && value === "global")) {
delete newConfig[key];
} else {
newConfig[key] = value;
}
}
this.dispatchEvent(new CustomEvent("config-changed", {
detail: { config: newConfig }, bubbles: true, composed: true,
}));
}
_updateConfig(key, value) {
const newConfig = { ...this.config, [key]: value };
if (value === null || value === "" || value === undefined) delete newConfig[key];
this.dispatchEvent(new CustomEvent("config-changed", {
detail: { config: newConfig }, bubbles: true, composed: true,
}));
}
}
customElements.define("taskmate-wishlist-card", TaskMateWishlistCard);
customElements.define("taskmate-wishlist-card-editor", TaskMateWishlistCardEditor);
window.customCards = window.customCards || [];
window.customCards.push({
type: "taskmate-wishlist-card",
name: "TaskMate Wishlist",
description: "A child's wishlist: save towards things they want, with family pledges",
preview: true,
getEntitySuggestion: (hass, entityId) =>
window.__taskmate_suggest(hass, entityId, "taskmate-wishlist-card", "overview"),
});