@thanku/counter-badge
Version:
The ThankU Counter Badge is a Web Component that requests and displays the number of sent and collected ThankUs of a specific user on www.thanku.social
549 lines (496 loc) • 14.7 kB
JavaScript
// CONFIG
const ORIGIN = "https://www.thanku.social";
const STEP_COUNT = 4;
// HELPER
const log = (() => {
let hasDebug = false;
try {
hasDebug = localStorage.getItem("debug") === "thanku";
} catch (_) {
// ignore
}
return hasDebug
? (...args) => {
console.log(...args);
}
: Function.prototype;
})();
function isValidLang(lang) {
return ["de", "en"].includes(lang);
}
function isValidSlug(slug) {
return typeof slug === "string" && /[a-z0-9][a-z0-9-]+[a-z0-9]/.test(slug);
}
function toProfileUrl({ slug, lang }) {
return `https://thx.to/:${slug}/${lang}`;
}
function toError({ message, id }) {
const error = new Error(message);
error.id = id;
return error;
}
function range(from, to) {
return Array.from({ length: Math.abs(from - to) + 1 }, (_, i) => from + i);
}
function htmlEscape(string) {
return string
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/'/g, "'")
.replace(/</g, "<")
.replace(/>/g, ">");
}
function html(strings, ...args) {
return strings
.map((str, i) =>
i < args.length
? str +
(args[i].__html
? [].concat(args[i].__html).join("")
: htmlEscape(String(args[i])))
: str
)
.join("");
}
function createStore({ getState, setState, update, onUpdate }) {
const dispatch = (action) =>
requestAnimationFrame(() => {
const prevState = { ...getState() };
const [nextState, cmd] = update(prevState, action);
if (prevState !== nextState) {
log("update:state", action, { prevState, nextState });
setState(nextState);
onUpdate({ ...nextState }, prevState);
}
cmd({ ...nextState }, dispatch);
});
return dispatch;
}
// API CLIENT
const Api = {
fetchProfileData(slug) {
return fetch(`${ORIGIN}/api/profile/${slug}`, {
headers: { "Content-Type": "application/json" },
}).then(
(res) => {
if (res.status >= 400) {
throw toError({
message: "Data not available",
id: "dataNotAvailable",
});
}
return res.json().catch(() => {
throw toError({ message: "Data malformed", id: "dataMalformed" });
});
},
() => {
throw toError({
message: "Connection problems",
id: "connectionProblems",
});
}
);
},
};
// STYLES
const styles = html`
<style>
:host {
--size: 100px;
--color-darkblue: #202c55;
--color-red: #e33429;
--color-teal: #5fc2c5;
display: block;
}
.container {
align-items: center;
background-color: var(--color-darkblue);
border-radius: 50%;
color: white;
display: flex;
font-family: "Exo", sans-serif;
font-size: calc(var(--size) / 6.25); /* 16px at 100px size */
height: var(--size);
justify-content: center;
line-height: 1.25;
overflow: hidden;
text-decoration: none;
width: var(--size);
}
.container--error {
background-color: var(--color-red);
cursor: not-allowed;
}
.container--loading {
cursor: wait;
}
.container--steps {
position: relative;
}
.step {
height: 100%;
opacity: 0;
position: absolute;
transition: opacity 300ms;
width: 100%;
z-index: 1;
}
.step--current {
opacity: 1;
z-index: 2;
}
.content {
align-items: center;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
}
.content--counter {
background-color: var(--color-teal);
}
.content--logo {
background-color: white;
}
.content--signet {
background-color: var(--color-darkblue);
}
.counter-value {
color: white;
font-size: 200%;
line-height: 1;
padding-top: 3%;
}
.counter-label {
color: var(--color-darkblue);
font-size: 80%;
font-weight: 600;
line-height: 1;
padding-top: 3%;
}
.error {
background-color: white;
height: 0;
overflow: hidden;
padding-top: 16%;
width: 72%;
}
</style>
`;
// TRANSLATIONS
const translations = {
de: {
profileLinkTitle: ({ nickname }) => `ThankU-Seite von ${nickname} besuchen`,
collected: "gesammelt",
sent: "gesendet",
error: {
headline: "Datenabruf fehlgeschlagen",
dataNotAvailable: "Daten sind nicht verfügbar",
dataMalformed: "Daten sind fehlerhaft",
connectionProblems: "Verbindungsprobleme",
},
},
en: {
profileLinkTitle: ({ nickname }) => `Visit ${nickname}'s ThankU wallet`,
collected: "collected",
sent: "sent",
error: {
headline: "Fetching data failed",
dataNotAvailable: "Data is not available",
dataMalformed: "Data is malformed",
connectionProblems: "Connection problems",
},
},
};
// RENDER HELPER
const signet = html`
<svg
viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg"
fill-rule="evenodd"
clip-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
>
<path
d="M68.238 144.482H55.055c-8.824 0-15.988-7.164-15.988-15.988V73.45c0-8.824 7.164-15.988 15.988-15.988h89.89c8.824 0 15.988 7.164 15.988 15.988v55.044c0 8.824-7.164 15.988-15.988 15.988H87.44l-19.443 14.056.241-14.056z"
fill="#54c1c8"
/>
<path
d="M100.056 89.509c6.458-9.263 19.373-9.263 25.831-4.631 6.458 4.631 6.458 13.894 0 23.157-4.52 6.947-16.144 13.894-25.831 18.525-9.686-4.631-21.31-11.578-25.831-18.525-6.457-9.263-6.457-18.526 0-23.157 6.458-4.632 19.373-4.632 25.831 4.631z"
fill="#fff"
/>
</svg>
`;
const logo = html`
<svg
viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg"
fill-rule="evenodd"
clip-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
>
<path
d="M33.707 90.199H25v-5.705h23.806v5.705h-8.75v26.036h-6.349V90.199zm18.23-6.649h6.349v12.011c.858-.744 1.966-1.387 3.324-1.931 1.358-.543 2.695-.815 4.01-.815 4.719 0 7.078 2.731 7.078 8.193v15.227H66.35v-14.412c0-1.058-.301-1.873-.901-2.445-.601-.572-1.401-.858-2.402-.858-1.573 0-3.16.572-4.761 1.716v15.999h-6.349V83.55zm30.412 32.943c-1.773 0-3.195-.522-4.268-1.566-1.072-1.044-1.608-2.466-1.608-4.268V108.6c0-1.83.636-3.288 1.909-4.375 1.272-1.087 3.167-1.63 5.683-1.63h5.876v-1.587c0-.772-.128-1.38-.386-1.823-.257-.443-.743-.772-1.458-.987-.715-.214-1.773-.321-3.174-.321h-6.906v-3.689c2.831-.858 5.905-1.287 9.222-1.287 3.031 0 5.298.572 6.799 1.716 1.501 1.144 2.252 3.131 2.252 5.962v15.656h-5.019l-1.029-2.445c-.315.315-.872.687-1.673 1.116-.801.429-1.752.8-2.853 1.115a12.217 12.217 0 01-3.367.472zm3.303-4.418c.658 0 1.473-.15 2.445-.451.972-.3 1.587-.522 1.844-.665v-5.361l-3.86.257c-2.173.172-3.26 1.115-3.26 2.831v.944c0 1.63.944 2.445 2.831 2.445zm15.399-18.874h5.233l1.115 2.36c.944-.773 2.073-1.423 3.389-1.952 1.315-.529 2.602-.794 3.86-.794 2.603 0 4.447.744 5.534 2.231 1.086 1.487 1.63 3.488 1.63 6.005v15.184h-6.349v-14.369c0-1.087-.293-1.916-.879-2.488-.586-.572-1.394-.858-2.424-.858-1.572 0-3.159.572-4.761 1.716v15.999h-6.348V93.201zm25.522-9.694h6.348v17.673h2.703l5.061-7.979h6.434l-6.777 10.852 7.463 12.182h-6.434l-5.833-9.351h-2.617v9.351h-6.348V83.507z"
fill="#202c55"
fill-rule="nonzero"
/>
<path
d="M162.604 116.45c-3.975 0-7.035-.751-9.18-2.252-2.144-1.501-3.217-4.225-3.217-8.171V84.494h6.349v21.49c0 1.687.507 2.902 1.522 3.646 1.016.743 2.524 1.115 4.526 1.115 2.001 0 3.503-.372 4.504-1.115 1-.744 1.501-1.959 1.501-3.646v-21.49H175v21.533c0 3.946-1.08 6.67-3.238 8.171-2.16 1.501-5.212 2.252-9.158 2.252z"
fill="#5fc2c5"
fill-rule="nonzero"
/>
</svg>
`;
function renderData({
t,
lang,
step,
data: {
user: { nickname, slug },
thankus: { collected, sent },
},
}) {
return html`
<a
href="${toProfileUrl({ slug, lang })}"
title="${t.profileLinkTitle({ nickname })}"
class="container container--steps"
>
${{
__html: range(0, STEP_COUNT - 1).map(
(i) =>
html`<div class="${step === i ? "step step--current" : "step"}">
${{ __html: renderStep({ t, step: i, collected, sent }) }}
</div>`
),
}}
</a>
`;
}
function renderStep({ t, step, collected, sent }) {
switch (step) {
case 3:
return html`<div class="content content--counter">
<span class="counter-value">${collected}</span>
<span class="counter-label">${t.collected}</span>
</div>`;
case 2:
return html`<div class="content content--logo">${{ __html: logo }}</div>`;
case 1:
return html`<div class="content content--counter">
<span class="counter-value">${sent}</span>
<span class="counter-label">${t.sent}</span>
</div>`;
case 0:
default:
return html`<div class="content content--signet">
${{ __html: signet }}
</div>`;
}
}
function renderLoading() {
return html`
<div class="container container--loading">${{ __html: signet }}</div>
`;
}
function renderError({ error, t }) {
const errorMessage = [
t.error.headline,
t.error[error.id] || error.message,
].join(" - ");
return html`
<div class="container container--error" title="${errorMessage}">
<div class="error">${errorMessage}</div>
</div>
`;
}
// COMMANDS
const noCmd = Function.prototype;
function loadDataCmd(state, dispatch) {
dispatch({ name: "LOAD_DATA_PENDING" });
Api.fetchProfileData(state.slug).then(
(data) => dispatch({ name: "LOAD_DATA_SUCCEEDED", payload: data }),
(error) => dispatch({ name: "LOAD_DATA_FAILED", payload: { error } })
);
}
function startStepperCmd(state, dispatch) {
if (!state.intervalId) {
const intervalId = setInterval(
() => dispatch({ name: "NEXT_STEP_WANTED" }),
state.duration
);
dispatch({ name: "INTERVAL_ID_UPDATED", payload: intervalId });
}
}
function stopStepperCmd(state, dispatch) {
if (state.intervalId) {
clearInterval(state.intervalId);
dispatch({ name: "INTERVAL_ID_UPDATED", payload: null });
}
}
function restartStepperCmd(state, dispatch) {
if (state.intervalId) {
clearInterval(state.intervalId);
}
startStepperCmd({ ...state, intervalId: null }, dispatch);
}
// UPDATE
function update(state, { name, payload }) {
switch (name) {
case "LOAD_DATA_PENDING": {
return [{ ...state, profile: { loading: true } }, noCmd];
}
case "LOAD_DATA_FAILED": {
return [{ ...state, profile: { error: payload.error } }, stopStepperCmd];
}
case "LOAD_DATA_SUCCEEDED": {
return [
{ ...state, profile: { data: payload } },
state.isConnected ? startStepperCmd : noCmd,
];
}
case "INTERVAL_ID_UPDATED": {
return [{ ...state, intervalId: payload }, noCmd];
}
case "NEXT_STEP_WANTED": {
return [
{ ...state, step: state.step < STEP_COUNT - 1 ? state.step + 1 : 0 },
noCmd,
];
}
case "LANG_UPDATED": {
return [{ ...state, lang: payload }, noCmd];
}
case "SLUG_UPDATED": {
return [
{ ...state, slug: payload },
state.isConnected ? loadDataCmd : noCmd,
];
}
case "DURATION_UPDATED": {
return [
{ ...state, duration: payload },
state.isConnected ? restartStepperCmd : noCmd,
];
}
case "CONNECTED": {
return [{ ...state, isConnected: true }, loadDataCmd];
}
case "DISCONNECTED": {
return [{ ...state, isConnected: false }, stopStepperCmd];
}
default: {
return [state, noCmd];
}
}
}
// RENDER
const render = (elem) => (state, prevState) => {
const hasChanged = (attr) => prevState[attr] !== state[attr];
if (hasChanged("profile") || hasChanged("lang")) {
const t = translations[state.lang] || translations.en;
switch (true) {
case !!state.profile.loading:
elem.innerHTML = renderLoading();
break;
case !!state.profile.error:
elem.innerHTML = renderError({ t, error: state.profile.error });
break;
case !!state.profile.data:
elem.innerHTML = renderData({
t,
lang: state.lang,
step: state.step,
data: state.profile.data,
});
break;
default:
// do nothing
}
} else if ("data" in state.profile && hasChanged("step")) {
elem.querySelectorAll(".step").forEach(($step, i) => {
$step.classList.toggle("step--current", i === state.step);
});
}
};
// WEB COMPONENT
const STATE = Symbol("STATE");
class ThankUCounterBadge extends HTMLElement {
static get observedAttributes() {
return ["slug", "lang", "duration"];
}
[STATE] = {
step: 0,
isConnected: false,
intervalId: null,
profile: { notAsked: true },
// attributes
slug: "universe",
lang: "en",
duration: 2000,
};
get slug() {
return this[STATE].slug;
}
set slug(slug) {
if (isValidSlug(slug) && this.slug !== slug) {
this.dispatch({ name: "SLUG_UPDATED", payload: slug });
}
}
get lang() {
return this[STATE].lang;
}
set lang(lang) {
if (isValidLang(lang) && this.lang !== lang) {
this.dispatch({ name: "LANG_UPDATED", payload: lang });
}
}
get duration() {
return this[STATE].duration;
}
set duration(duration) {
const payload = Number.parseInt(duration);
if (payload > 0 && this.duration !== duration) {
this.dispatch({ name: "DURATION_UPDATED", payload });
}
}
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = styles + `<div id="wrapper"></div>`;
const $wrapper = shadow.getElementById("wrapper");
this.dispatch = createStore({
getState: () => this[STATE],
setState: (state) => (this[STATE] = state),
update,
onUpdate: render($wrapper),
});
}
connectedCallback() {
if (this.isConnected) {
this.dispatch({ name: "CONNECTED" });
}
}
disconnectedCallback() {
this.dispatch({ name: "DISCONNECTED" });
}
attributeChangedCallback(name, oldValue, newValue) {
if (newValue === oldValue) return;
switch (name) {
case "slug":
this.slug = newValue;
break;
case "lang":
this.lang = newValue;
break;
case "duration":
this.duration = newValue;
break;
}
}
}
customElements.define("thanku-counter-badge", ThankUCounterBadge);
export { ThankUCounterBadge };