@vimine/sdk 0.2.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/elements.d.ts +48 -4
- package/dist/elements.js +385 -122
- package/dist/elements.js.map +1 -1
- package/dist/generated.d.ts +60 -5
- package/dist/generated.js +3 -1
- package/dist/generated.js.map +1 -1
- package/dist/public.d.ts +12 -4
- package/dist/public.js +3 -2
- package/dist/public.js.map +1 -1
- package/dist/types.d.ts +73 -4
- package/dist/types.js.map +1 -1
- package/package.json +6 -1
package/dist/elements.js
CHANGED
|
@@ -7,32 +7,100 @@
|
|
|
7
7
|
//
|
|
8
8
|
// <vimine-sign-in scope="membership:read"></vimine-sign-in> a "Sign in with Vimine" button
|
|
9
9
|
// <vimine-card></vimine-card> the signed-in member's card (SVG + QR)
|
|
10
|
-
// <vimine-enroll slug="your-slug"></vimine-enroll> the hosted enrolment form (iframe, Embed v0)
|
|
10
|
+
// <vimine-enroll slug="your-slug"></vimine-enroll> the hosted enrolment form (iframe, Embed v0), in the
|
|
11
|
+
// element's language; someone who already has a
|
|
12
|
+
// Vimine account is offered a hand-off to the hosted
|
|
13
|
+
// page, where they join in one step
|
|
11
14
|
// <vimine-events slug="your-slug"></vimine-events> the association's events (public ones for
|
|
12
15
|
// visitors, every one for a signed-in member) with
|
|
13
16
|
// a register button per type
|
|
14
17
|
//
|
|
15
18
|
// The callback page still calls `client.handleRedirect()` (or `<vimine-sign-in>` does it for you when
|
|
16
19
|
// `handle-redirect` is set on the element and the page is the redirect URI).
|
|
17
|
-
import { Vimine } from
|
|
20
|
+
import { Vimine } from "./index.js";
|
|
18
21
|
let shared = null;
|
|
19
22
|
const STRINGS = {
|
|
20
23
|
it: {
|
|
21
|
-
signIn:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
signIn: "Accedi con Vimine",
|
|
25
|
+
signOut: "Esci",
|
|
26
|
+
loading: "Caricamento…",
|
|
27
|
+
notSignedIn: "Accedi per vedere la tua tessera.",
|
|
28
|
+
noMembership: "Nessuna tessera in questa associazione.",
|
|
29
|
+
notActive: "La tessera non è ancora attiva: il codice compare quando lo diventa.",
|
|
30
|
+
error: "Si è verificato un errore.",
|
|
31
|
+
noEvents: "Nessun evento in programma.",
|
|
32
|
+
free: "gratuita",
|
|
33
|
+
places: "posti",
|
|
34
|
+
register: "Iscriviti",
|
|
35
|
+
signInToRegister: "Accedi per iscriverti",
|
|
36
|
+
joinAndBook: "Iscriviti all’associazione e prenota",
|
|
37
|
+
alreadyMember: "Sono già socio: accedi",
|
|
38
|
+
registered: "Iscrizione confermata",
|
|
39
|
+
reserved: "Posto riservato: in attesa del contributo",
|
|
40
|
+
details: "Dettagli",
|
|
41
|
+
reason: {
|
|
42
|
+
sold_out: "esaurito",
|
|
43
|
+
event_full: "al completo",
|
|
44
|
+
registration_not_open: "iscrizioni non ancora aperte",
|
|
45
|
+
registration_closed: "iscrizioni chiuse",
|
|
46
|
+
membership_required: "riservato ai soci",
|
|
47
|
+
membership_not_active: "tessera non valida",
|
|
48
|
+
membership_not_yet_valid: "la tessera non è ancora utilizzabile a quella data",
|
|
49
|
+
membership_type_not_allowed: "tipo di tessera non ammesso",
|
|
50
|
+
max_per_user: "già iscritto",
|
|
51
|
+
event_past: "evento concluso",
|
|
52
|
+
event_cancelled: "evento annullato",
|
|
53
|
+
event_not_published: "non pubblicato",
|
|
54
|
+
plan_limit: "non disponibile",
|
|
55
|
+
invalid_type: "tipo non valido",
|
|
56
|
+
},
|
|
24
57
|
},
|
|
25
58
|
en: {
|
|
26
|
-
signIn:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
59
|
+
signIn: "Sign in with Vimine",
|
|
60
|
+
signOut: "Sign out",
|
|
61
|
+
loading: "Loading…",
|
|
62
|
+
notSignedIn: "Sign in to see your card.",
|
|
63
|
+
noMembership: "No membership in this association.",
|
|
64
|
+
notActive: "The card is not active yet: its code appears once it is.",
|
|
65
|
+
error: "Something went wrong.",
|
|
66
|
+
noEvents: "No upcoming events.",
|
|
67
|
+
free: "free",
|
|
68
|
+
places: "places",
|
|
69
|
+
register: "Register",
|
|
70
|
+
signInToRegister: "Sign in to register",
|
|
71
|
+
joinAndBook: "Join the association and book",
|
|
72
|
+
alreadyMember: "Already a member? Sign in",
|
|
73
|
+
registered: "Registration confirmed",
|
|
74
|
+
reserved: "Place reserved: awaiting the contribution",
|
|
75
|
+
details: "Details",
|
|
76
|
+
reason: {
|
|
77
|
+
sold_out: "sold out",
|
|
78
|
+
event_full: "full",
|
|
79
|
+
registration_not_open: "registrations not open yet",
|
|
80
|
+
registration_closed: "registrations closed",
|
|
81
|
+
membership_required: "members only",
|
|
82
|
+
membership_not_active: "membership not valid",
|
|
83
|
+
membership_not_yet_valid: "your card is not usable yet on that date",
|
|
84
|
+
membership_type_not_allowed: "membership type not admitted",
|
|
85
|
+
max_per_user: "already registered",
|
|
86
|
+
event_past: "event over",
|
|
87
|
+
event_cancelled: "event cancelled",
|
|
88
|
+
event_not_published: "not published",
|
|
89
|
+
plan_limit: "not available",
|
|
90
|
+
invalid_type: "invalid type",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
30
93
|
};
|
|
31
94
|
const langOf = (el) => {
|
|
32
|
-
const l = (el.getAttribute(
|
|
33
|
-
|
|
95
|
+
const l = (el.getAttribute("lang") ||
|
|
96
|
+
el.closest("[lang]")?.getAttribute("lang") ||
|
|
97
|
+
document.documentElement.lang ||
|
|
98
|
+
"it")
|
|
99
|
+
.slice(0, 2)
|
|
100
|
+
.toLowerCase();
|
|
101
|
+
return l === "en" ? "en" : "it";
|
|
34
102
|
};
|
|
35
|
-
const BASE_CSS = `
|
|
103
|
+
const BASE_CSS = /* CSS */ `
|
|
36
104
|
:host { display: inline-block; font-family: var(--vimine-font, system-ui, sans-serif); color: var(--vimine-color, #111); }
|
|
37
105
|
button { cursor: pointer; border: 0; border-radius: var(--vimine-radius, 999px); padding: var(--vimine-padding, 0.65rem 1.1rem);
|
|
38
106
|
background: var(--vimine-accent, #0a0e1a); color: var(--vimine-accent-contrast, #fff); font: inherit; font-weight: 600; }
|
|
@@ -41,28 +109,28 @@ button:disabled { opacity: .6; cursor: default; }
|
|
|
41
109
|
.error { color: var(--vimine-error, #a33); font-size: .9em; }
|
|
42
110
|
`;
|
|
43
111
|
const shadow = (el, css) => {
|
|
44
|
-
const root = el.shadowRoot || el.attachShadow({ mode:
|
|
112
|
+
const root = el.shadowRoot || el.attachShadow({ mode: "open" });
|
|
45
113
|
root.innerHTML = `<style>${BASE_CSS}${css}</style>`;
|
|
46
114
|
return root;
|
|
47
115
|
};
|
|
48
116
|
/** The client the elements use. Call once; pass your own instance to share it with the rest of the page. */
|
|
49
117
|
export function defineVimineElements(options) {
|
|
50
118
|
shared = options instanceof Vimine ? options : new Vimine(options);
|
|
51
|
-
if (typeof customElements !==
|
|
52
|
-
if (!customElements.get(
|
|
53
|
-
customElements.define(
|
|
54
|
-
if (!customElements.get(
|
|
55
|
-
customElements.define(
|
|
56
|
-
if (!customElements.get(
|
|
57
|
-
customElements.define(
|
|
58
|
-
if (!customElements.get(
|
|
59
|
-
customElements.define(
|
|
119
|
+
if (typeof customElements !== "undefined") {
|
|
120
|
+
if (!customElements.get("vimine-sign-in"))
|
|
121
|
+
customElements.define("vimine-sign-in", VimineSignIn);
|
|
122
|
+
if (!customElements.get("vimine-card"))
|
|
123
|
+
customElements.define("vimine-card", VimineCard);
|
|
124
|
+
if (!customElements.get("vimine-enroll"))
|
|
125
|
+
customElements.define("vimine-enroll", VimineEnroll);
|
|
126
|
+
if (!customElements.get("vimine-events"))
|
|
127
|
+
customElements.define("vimine-events", VimineEvents);
|
|
60
128
|
}
|
|
61
129
|
return shared;
|
|
62
130
|
}
|
|
63
131
|
const client = () => {
|
|
64
132
|
if (!shared)
|
|
65
|
-
throw new Error(
|
|
133
|
+
throw new Error("Vimine elements: call defineVimineElements({ clientId, redirectUri }) first");
|
|
66
134
|
return shared;
|
|
67
135
|
};
|
|
68
136
|
/** `<vimine-sign-in scope="…" return-to="/area" label="…" handle-redirect>` — a sign-in / sign-out button. */
|
|
@@ -77,7 +145,7 @@ export class VimineSignIn extends HTMLElement {
|
|
|
77
145
|
}
|
|
78
146
|
async boot() {
|
|
79
147
|
const mp = client();
|
|
80
|
-
if (this.hasAttribute(
|
|
148
|
+
if (this.hasAttribute("handle-redirect")) {
|
|
81
149
|
try {
|
|
82
150
|
await mp.handleRedirect();
|
|
83
151
|
}
|
|
@@ -92,28 +160,38 @@ export class VimineSignIn extends HTMLElement {
|
|
|
92
160
|
render(error) {
|
|
93
161
|
const mp = client();
|
|
94
162
|
const t = STRINGS[langOf(this)];
|
|
95
|
-
const root = shadow(this,
|
|
96
|
-
const btn = document.createElement(
|
|
97
|
-
btn.type =
|
|
163
|
+
const root = shadow(this, "");
|
|
164
|
+
const btn = document.createElement("button");
|
|
165
|
+
btn.type = "button";
|
|
98
166
|
if (mp.isSignedIn()) {
|
|
99
|
-
btn.textContent = this.getAttribute(
|
|
100
|
-
btn.addEventListener(
|
|
167
|
+
btn.textContent = this.getAttribute("label-signed-in") || t.signOut;
|
|
168
|
+
btn.addEventListener("click", () => void mp.signOut({ everywhere: this.hasAttribute("everywhere") }));
|
|
101
169
|
}
|
|
102
170
|
else {
|
|
103
|
-
btn.textContent = this.getAttribute(
|
|
104
|
-
btn.addEventListener(
|
|
105
|
-
const scopes = (this.getAttribute(
|
|
106
|
-
|
|
171
|
+
btn.textContent = this.getAttribute("label") || t.signIn;
|
|
172
|
+
btn.addEventListener("click", () => {
|
|
173
|
+
const scopes = (this.getAttribute("scope") || "")
|
|
174
|
+
.split(/[\s,]+/)
|
|
175
|
+
.filter(Boolean);
|
|
176
|
+
void mp.signIn({
|
|
177
|
+
...(scopes.length ? { scopes } : {}),
|
|
178
|
+
...(this.getAttribute("return-to")
|
|
179
|
+
? { returnTo: this.getAttribute("return-to") }
|
|
180
|
+
: {}),
|
|
181
|
+
});
|
|
107
182
|
});
|
|
108
183
|
}
|
|
109
184
|
root.appendChild(btn);
|
|
110
185
|
if (error) {
|
|
111
|
-
const e = document.createElement(
|
|
112
|
-
e.className =
|
|
186
|
+
const e = document.createElement("div");
|
|
187
|
+
e.className = "error";
|
|
113
188
|
e.textContent = error;
|
|
114
189
|
root.appendChild(e);
|
|
115
190
|
}
|
|
116
|
-
this.dispatchEvent(new CustomEvent(
|
|
191
|
+
this.dispatchEvent(new CustomEvent("vimine-change", {
|
|
192
|
+
detail: { signedIn: mp.isSignedIn() },
|
|
193
|
+
bubbles: true,
|
|
194
|
+
}));
|
|
117
195
|
}
|
|
118
196
|
}
|
|
119
197
|
/** `<vimine-card>` — the signed-in member's card as SVG (with the live QR); refreshes when the session changes. */
|
|
@@ -130,55 +208,168 @@ export class VimineCard extends HTMLElement {
|
|
|
130
208
|
async render() {
|
|
131
209
|
const mp = client();
|
|
132
210
|
const t = STRINGS[langOf(this)];
|
|
133
|
-
const root = shadow(this,
|
|
211
|
+
const root = shadow(this, ":host { display: block; max-width: var(--vimine-card-width, 600px); } svg { width: 100%; height: auto; display: block; border-radius: 24px; box-shadow: var(--vimine-card-shadow, 0 8px 30px rgba(0,0,0,.12)); }");
|
|
134
212
|
const note = (cls, text) => {
|
|
135
|
-
const d = document.createElement(
|
|
213
|
+
const d = document.createElement("div");
|
|
136
214
|
d.className = cls;
|
|
137
215
|
d.textContent = text;
|
|
138
216
|
root.appendChild(d);
|
|
139
217
|
};
|
|
140
218
|
if (!mp.isSignedIn())
|
|
141
|
-
return note(
|
|
142
|
-
note(
|
|
219
|
+
return note("muted", t.notSignedIn);
|
|
220
|
+
note("muted", t.loading);
|
|
143
221
|
try {
|
|
144
222
|
const svg = await mp.membership.cardSvg();
|
|
145
223
|
root.innerHTML = `<style>${BASE_CSS}:host { display: block; max-width: var(--vimine-card-width, 600px); } svg { width: 100%; height: auto; display: block; border-radius: 24px; box-shadow: var(--vimine-card-shadow, 0 8px 30px rgba(0,0,0,.12)); }</style>${svg}`;
|
|
146
|
-
this.dispatchEvent(new CustomEvent(
|
|
224
|
+
this.dispatchEvent(new CustomEvent("vimine-card", {
|
|
225
|
+
detail: { loaded: true },
|
|
226
|
+
bubbles: true,
|
|
227
|
+
}));
|
|
147
228
|
}
|
|
148
229
|
catch (err) {
|
|
149
230
|
root.innerHTML = `<style>${BASE_CSS}</style>`;
|
|
150
231
|
const code = err?.code;
|
|
151
232
|
// `not_active`: the membership exists but has no card (pending, suspended, revoked) — a state, not a failure.
|
|
152
|
-
const quiet = code ===
|
|
153
|
-
note(quiet ?
|
|
233
|
+
const quiet = code === "not_found" || code === "not_active";
|
|
234
|
+
note(quiet ? "muted" : "error", code === "not_found"
|
|
235
|
+
? t.noMembership
|
|
236
|
+
: code === "not_active"
|
|
237
|
+
? t.notActive
|
|
238
|
+
: t.error);
|
|
154
239
|
}
|
|
155
240
|
}
|
|
156
241
|
}
|
|
157
|
-
/**
|
|
242
|
+
/** The faces the hosted page offers — served by Vimine itself — or `system`, the device's own. They set everything. */
|
|
243
|
+
export const TYPEFACES = [
|
|
244
|
+
"system",
|
|
245
|
+
"inter",
|
|
246
|
+
"roboto",
|
|
247
|
+
"open-sans",
|
|
248
|
+
"montserrat",
|
|
249
|
+
"nunito",
|
|
250
|
+
"lora",
|
|
251
|
+
"merriweather",
|
|
252
|
+
"roboto-slab",
|
|
253
|
+
];
|
|
254
|
+
const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
255
|
+
const appearanceQuery = (a) => {
|
|
256
|
+
if (!a)
|
|
257
|
+
return "";
|
|
258
|
+
const out = [];
|
|
259
|
+
if (a.scheme === "light" || a.scheme === "dark")
|
|
260
|
+
out.push(`scheme=${a.scheme}`);
|
|
261
|
+
if (a.accent && HEX.test(a.accent))
|
|
262
|
+
out.push(`accent=${encodeURIComponent(a.accent.replace(/^#/, ""))}`);
|
|
263
|
+
if (a.background === "transparent")
|
|
264
|
+
out.push("background=transparent");
|
|
265
|
+
else if (a.background && HEX.test(a.background))
|
|
266
|
+
out.push(`background=${encodeURIComponent(a.background.replace(/^#/, ""))}`);
|
|
267
|
+
if (a.font && TYPEFACES.includes(a.font))
|
|
268
|
+
out.push(`font=${a.font}`);
|
|
269
|
+
return out.length ? `&${out.join("&")}` : "";
|
|
270
|
+
};
|
|
271
|
+
export const enrollUrl = (appUrl, slug, lang, appearance) => `${appUrl.replace(/\/+$/, "")}/enroll/${encodeURIComponent(slug)}?locale=${encodeURIComponent(lang)}${appearanceQuery(appearance)}`;
|
|
272
|
+
/**
|
|
273
|
+
* The same page, opened FROM an event: the person joins the association and asks for a place at the event in one go.
|
|
274
|
+
* The request carries the event; the place is confirmed, or held until the membership is active.
|
|
275
|
+
*/
|
|
276
|
+
export const eventEnrollUrl = (appUrl, slug, event, lang) => `${enrollUrl(appUrl, slug, lang)}&event=${encodeURIComponent(event)}`;
|
|
277
|
+
/** Any CSS colour (a name, `rgb()`, `hsl()`, a hex) as `#rrggbb`, resolved by the browser; null when it is none. */
|
|
278
|
+
const hexColour = (root, value) => {
|
|
279
|
+
const v = value.trim();
|
|
280
|
+
if (!v)
|
|
281
|
+
return null;
|
|
282
|
+
const probe = document.createElement("span");
|
|
283
|
+
probe.style.color = v;
|
|
284
|
+
if (!probe.style.color)
|
|
285
|
+
return null;
|
|
286
|
+
root.appendChild(probe);
|
|
287
|
+
const m = /rgba?\(([^)]+)\)/.exec(getComputedStyle(probe).color);
|
|
288
|
+
probe.remove();
|
|
289
|
+
const parts = (m?.[1] ?? "").split(/[\s,/]+/).filter(Boolean).map(Number);
|
|
290
|
+
if (parts.length < 3)
|
|
291
|
+
return null;
|
|
292
|
+
const [r = 0, g = 0, b = 0, alpha] = parts;
|
|
293
|
+
if (alpha === 0)
|
|
294
|
+
return null;
|
|
295
|
+
return `#${[r, g, b].map((n) => Math.round(n).toString(16).padStart(2, "0")).join("")}`;
|
|
296
|
+
};
|
|
297
|
+
/**
|
|
298
|
+
* The page's look from the host's CSS: `--vimine-scheme` (light | dark), `--vimine-accent` and `--vimine-background` (a
|
|
299
|
+
* colour, or `transparent`), `--vimine-typeface` (one of TYPEFACES) — custom properties inherit, so one rule on `:root`
|
|
300
|
+
* styles every Vimine element at once.
|
|
301
|
+
*/
|
|
302
|
+
const appearanceOf = (el, root) => {
|
|
303
|
+
const css = getComputedStyle(el);
|
|
304
|
+
const read = (name) => css.getPropertyValue(name).trim();
|
|
305
|
+
const out = {};
|
|
306
|
+
const scheme = read("--vimine-scheme");
|
|
307
|
+
if (scheme === "light" || scheme === "dark")
|
|
308
|
+
out.scheme = scheme;
|
|
309
|
+
const accent = hexColour(root, read("--vimine-accent"));
|
|
310
|
+
if (accent)
|
|
311
|
+
out.accent = accent;
|
|
312
|
+
const background = read("--vimine-background");
|
|
313
|
+
if (background === "transparent")
|
|
314
|
+
out.background = "transparent";
|
|
315
|
+
else {
|
|
316
|
+
const bg = hexColour(root, background);
|
|
317
|
+
if (bg)
|
|
318
|
+
out.background = bg;
|
|
319
|
+
}
|
|
320
|
+
const typeface = read("--vimine-typeface");
|
|
321
|
+
if (TYPEFACES.includes(typeface))
|
|
322
|
+
out.font = typeface;
|
|
323
|
+
return out;
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* `<vimine-enroll slug="…" app-url="https://app.vimine.network" height="900" lang="en">` — the hosted enrolment form.
|
|
327
|
+
*
|
|
328
|
+
* Styled like the other elements, through CSS custom properties on the element or any ancestor:
|
|
329
|
+
*
|
|
330
|
+
* vimine-enroll { --vimine-scheme: light; --vimine-accent: #1e3a5f; --vimine-background: transparent; }
|
|
331
|
+
*
|
|
332
|
+
* `--vimine-scheme` the page's scheme; `--vimine-accent` its buttons, links and the association's name (moved towards
|
|
333
|
+
* the page's ink when it would not read on its cards); `--vimine-background` the ground behind the cards;
|
|
334
|
+
* `--vimine-typeface` the face of the whole page, fields included — one of TYPEFACES, served by Vimine (a framed page
|
|
335
|
+
* cannot use the host's fonts). The frame is styled once, when the element connects.
|
|
336
|
+
*
|
|
337
|
+
* The form opens in the element's language (`lang` on the element or the page; Italian by default). Someone who already
|
|
338
|
+
* has a Vimine account need not fill it: the form offers *Continua su Vimine*, which opens the hosted page in a new
|
|
339
|
+
* tab — there their session is recognised and they join in one step (the association's statuto and privacy notice, and
|
|
340
|
+
* nothing they have already told Vimine). It is a hand-off and not a sign-in inside the frame because a browser keeps a
|
|
341
|
+
* framed page's session apart from the same site opened directly; the frame's sandbox lets that tab open as an ordinary
|
|
342
|
+
* page (`allow-popups-to-escape-sandbox`).
|
|
343
|
+
*/
|
|
158
344
|
export class VimineEnroll extends HTMLElement {
|
|
159
345
|
connectedCallback() {
|
|
160
|
-
const slug = this.getAttribute(
|
|
161
|
-
const root = shadow(this,
|
|
346
|
+
const slug = this.getAttribute("slug");
|
|
347
|
+
const root = shadow(this, ":host { display: block; } iframe { width: 100%; border: 0; border-radius: var(--vimine-radius, 16px); background: transparent; }");
|
|
162
348
|
if (!slug) {
|
|
163
|
-
const e = document.createElement(
|
|
164
|
-
e.className =
|
|
165
|
-
e.textContent =
|
|
349
|
+
const e = document.createElement("div");
|
|
350
|
+
e.className = "error";
|
|
351
|
+
e.textContent = "vimine-enroll: missing slug";
|
|
166
352
|
root.appendChild(e);
|
|
167
353
|
return;
|
|
168
354
|
}
|
|
169
|
-
const appUrl = (this.getAttribute(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
frame
|
|
173
|
-
frame.
|
|
174
|
-
frame.
|
|
175
|
-
frame.
|
|
355
|
+
const appUrl = (this.getAttribute("app-url") ||
|
|
356
|
+
client().options.appUrl ||
|
|
357
|
+
"https://app.vimine.network").replace(/\/+$/, "");
|
|
358
|
+
const frame = document.createElement("iframe");
|
|
359
|
+
frame.src = enrollUrl(appUrl, slug, langOf(this), appearanceOf(this, root));
|
|
360
|
+
frame.title = "Vimine";
|
|
361
|
+
frame.loading = "lazy";
|
|
362
|
+
frame.style.height = `${Number(this.getAttribute("height")) || 900}px`;
|
|
363
|
+
frame.setAttribute("sandbox", "allow-forms allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox");
|
|
176
364
|
root.appendChild(frame);
|
|
177
365
|
}
|
|
178
366
|
}
|
|
179
367
|
const money = (amount, currency, lang) => {
|
|
180
368
|
try {
|
|
181
|
-
return new Intl.NumberFormat(lang ===
|
|
369
|
+
return new Intl.NumberFormat(lang === "en" ? "en-GB" : "it-IT", {
|
|
370
|
+
style: "currency",
|
|
371
|
+
currency: currency || "EUR",
|
|
372
|
+
}).format(amount);
|
|
182
373
|
}
|
|
183
374
|
catch {
|
|
184
375
|
return `${amount.toFixed(2)} ${currency}`;
|
|
@@ -186,7 +377,11 @@ const money = (amount, currency, lang) => {
|
|
|
186
377
|
};
|
|
187
378
|
const when = (iso, tz, lang) => {
|
|
188
379
|
try {
|
|
189
|
-
return new Date(iso).toLocaleString(lang ===
|
|
380
|
+
return new Date(iso).toLocaleString(lang === "en" ? "en-GB" : "it-IT", {
|
|
381
|
+
timeZone: tz,
|
|
382
|
+
dateStyle: "full",
|
|
383
|
+
timeStyle: "short",
|
|
384
|
+
});
|
|
190
385
|
}
|
|
191
386
|
catch {
|
|
192
387
|
return new Date(iso).toLocaleString();
|
|
@@ -195,8 +390,13 @@ const when = (iso, tz, lang) => {
|
|
|
195
390
|
/**
|
|
196
391
|
* `<vimine-events slug="…" filter="upcoming" limit="10" detail-url="/eventi/{slug}">` — the association's events:
|
|
197
392
|
* the public ones for a visitor (no sign-in), every published one for a signed-in member, each with its registration
|
|
198
|
-
* types (contribution, places left) and a register button (`registrations:write`)
|
|
199
|
-
* `
|
|
393
|
+
* types (contribution, places left) and a register button (`registrations:write`). Fires `vimine-registration`
|
|
394
|
+
* `{ registration }` after a registration. `detail-url` (with `{slug}`) adds a details link.
|
|
395
|
+
*
|
|
396
|
+
* A VISITOR is offered a way in, not a bare sign-in: *Iscriviti all’associazione e prenota* opens the association's
|
|
397
|
+
* hosted enrolment page with the event (a new tab; `app-url` as on `<vimine-enroll>`) — they join and ask for the place
|
|
398
|
+
* in one go — and *Sono già socio: accedi* signs a member in here. The first is shown while the event has a registration
|
|
399
|
+
* that can be had and the association takes enrolment requests; otherwise the sign-in alone.
|
|
200
400
|
*/
|
|
201
401
|
export class VimineEvents extends HTMLElement {
|
|
202
402
|
unsubscribe = null;
|
|
@@ -213,117 +413,180 @@ export class VimineEvents extends HTMLElement {
|
|
|
213
413
|
const mp = client();
|
|
214
414
|
const lang = langOf(this);
|
|
215
415
|
const t = STRINGS[lang];
|
|
216
|
-
const root = shadow(this,
|
|
416
|
+
const root = shadow(this,
|
|
417
|
+
/* CSS */ `:host { display: block; }
|
|
217
418
|
.event { border: 1px solid var(--vimine-border, #e5e5e5); border-radius: var(--vimine-radius, 16px); padding: var(--vimine-padding, 1rem); margin: 0 0 1rem; }
|
|
218
419
|
.event h3 { margin: 0 0 .25rem; font-size: 1.15em; } .event img { width: 100%; border-radius: var(--vimine-radius, 16px); margin-bottom: .75rem; display: block; }
|
|
219
420
|
.type { display: flex; justify-content: space-between; align-items: center; gap: .75rem; padding: .5rem 0; border-top: 1px solid var(--vimine-border, #eee); }
|
|
220
|
-
.type .n { font-weight: 600; } .type button { padding: .45rem .9rem; } a { color: var(--vimine-accent, #0a0e1a); }
|
|
421
|
+
.type .n { font-weight: 600; } .type button { padding: .45rem .9rem; } a { color: var(--vimine-accent, #0a0e1a); }
|
|
422
|
+
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem 1rem; padding-top: .75rem; border-top: 1px solid var(--vimine-border, #eee); }
|
|
423
|
+
a.button { display: inline-block; text-decoration: none; border-radius: var(--vimine-radius, 999px); padding: var(--vimine-padding, 0.65rem 1.1rem);
|
|
424
|
+
background: var(--vimine-accent, #0a0e1a); color: var(--vimine-accent-contrast, #fff); font-weight: 600; }
|
|
425
|
+
button.link { background: none; color: var(--vimine-accent, #0a0e1a); padding: 0; border-radius: 0; text-decoration: underline; font-weight: 400; }`);
|
|
221
426
|
const note = (cls, text) => {
|
|
222
|
-
const d = document.createElement(
|
|
427
|
+
const d = document.createElement("div");
|
|
223
428
|
d.className = cls;
|
|
224
429
|
d.textContent = text;
|
|
225
430
|
root.appendChild(d);
|
|
226
431
|
};
|
|
227
|
-
note(
|
|
228
|
-
const slug = this.getAttribute(
|
|
229
|
-
const filter = (this.getAttribute(
|
|
230
|
-
const limit = Number(this.getAttribute(
|
|
432
|
+
note("muted", t.loading);
|
|
433
|
+
const slug = this.getAttribute("slug") || "";
|
|
434
|
+
const filter = (this.getAttribute("filter") || "upcoming");
|
|
435
|
+
const limit = Number(this.getAttribute("limit")) || undefined;
|
|
231
436
|
let items;
|
|
437
|
+
// For a visitor: does the association take enrolment requests? Only then is joining a way to a place. A side
|
|
438
|
+
// read — a failure leaves the sign-in alone.
|
|
439
|
+
let joinable = false;
|
|
232
440
|
try {
|
|
233
441
|
const params = { filter, ...(limit ? { limit } : {}) };
|
|
234
|
-
items = mp.isSignedIn()
|
|
442
|
+
items = mp.isSignedIn()
|
|
443
|
+
? (await mp.events.list(params)).items
|
|
444
|
+
: (await mp.public.events.list(slug, params)).items;
|
|
445
|
+
if (!mp.isSignedIn() && slug)
|
|
446
|
+
joinable = await mp.public
|
|
447
|
+
.association(slug)
|
|
448
|
+
.then((config) => config.aps.accepting_requests === true)
|
|
449
|
+
.catch(() => false);
|
|
235
450
|
}
|
|
236
451
|
catch {
|
|
237
452
|
root.innerHTML = `<style>${BASE_CSS}</style>`;
|
|
238
|
-
return note(
|
|
453
|
+
return note("error", t.error);
|
|
239
454
|
}
|
|
240
|
-
root.innerHTML = root.querySelector(
|
|
455
|
+
root.innerHTML = root.querySelector("style").outerHTML;
|
|
241
456
|
if (!items.length)
|
|
242
|
-
return note(
|
|
243
|
-
const detailUrl = this.getAttribute(
|
|
457
|
+
return note("muted", t.noEvents);
|
|
458
|
+
const detailUrl = this.getAttribute("detail-url");
|
|
459
|
+
const join = joinable
|
|
460
|
+
? {
|
|
461
|
+
slug,
|
|
462
|
+
appUrl: (this.getAttribute("app-url") ||
|
|
463
|
+
mp.options.appUrl ||
|
|
464
|
+
"https://app.vimine.network").replace(/\/+$/, ""),
|
|
465
|
+
}
|
|
466
|
+
: null;
|
|
244
467
|
for (const ev of items)
|
|
245
|
-
root.appendChild(this.card(ev, mp, lang, detailUrl));
|
|
468
|
+
root.appendChild(this.card(ev, mp, lang, detailUrl, join));
|
|
246
469
|
}
|
|
247
|
-
card(ev, mp, lang, detailUrl) {
|
|
470
|
+
card(ev, mp, lang, detailUrl, join) {
|
|
248
471
|
const t = STRINGS[lang];
|
|
249
|
-
const el = document.createElement(
|
|
250
|
-
el.className =
|
|
472
|
+
const el = document.createElement("article");
|
|
473
|
+
el.className = "event";
|
|
251
474
|
if (ev.cover_url) {
|
|
252
|
-
const img = document.createElement(
|
|
475
|
+
const img = document.createElement("img");
|
|
253
476
|
img.src = ev.cover_url;
|
|
254
|
-
img.alt =
|
|
477
|
+
img.alt = "";
|
|
255
478
|
el.appendChild(img);
|
|
256
479
|
}
|
|
257
|
-
const h = document.createElement(
|
|
480
|
+
const h = document.createElement("h3");
|
|
258
481
|
h.textContent = ev.title;
|
|
259
482
|
el.appendChild(h);
|
|
260
|
-
const meta = document.createElement(
|
|
261
|
-
meta.className =
|
|
262
|
-
meta.textContent = `${when(ev.starts_at, ev.timezone, lang)}${ev.location ? ` · ${ev.location.name || ev.location.address ||
|
|
483
|
+
const meta = document.createElement("div");
|
|
484
|
+
meta.className = "muted";
|
|
485
|
+
meta.textContent = `${when(ev.starts_at, ev.timezone, lang)}${ev.location ? ` · ${ev.location.name || ev.location.address || ""}` : ""}`;
|
|
263
486
|
el.appendChild(meta);
|
|
264
487
|
if (ev.summary) {
|
|
265
|
-
const p = document.createElement(
|
|
488
|
+
const p = document.createElement("p");
|
|
266
489
|
p.textContent = ev.summary;
|
|
267
490
|
el.appendChild(p);
|
|
268
491
|
}
|
|
269
492
|
if (detailUrl) {
|
|
270
|
-
const a = document.createElement(
|
|
271
|
-
a.href = detailUrl.replace(
|
|
493
|
+
const a = document.createElement("a");
|
|
494
|
+
a.href = detailUrl.replace("{slug}", encodeURIComponent(ev.slug));
|
|
272
495
|
a.textContent = t.details;
|
|
273
496
|
el.appendChild(a);
|
|
274
497
|
}
|
|
275
498
|
const mine = this.state[ev.id];
|
|
276
499
|
if (mine) {
|
|
277
|
-
const d = document.createElement(
|
|
278
|
-
d.className =
|
|
279
|
-
d.textContent = mine.status ===
|
|
500
|
+
const d = document.createElement("div");
|
|
501
|
+
d.className = "muted";
|
|
502
|
+
d.textContent = mine.status === "confirmed" ? t.registered : t.reserved;
|
|
280
503
|
el.appendChild(d);
|
|
281
504
|
return el;
|
|
282
505
|
}
|
|
283
|
-
if (ev.status ===
|
|
506
|
+
if (ev.status === "published")
|
|
284
507
|
for (const tp of ev.registration_types)
|
|
285
508
|
el.appendChild(this.typeRow(ev, tp, mp, lang));
|
|
509
|
+
// A visitor: one call for the event, under its registrations — join and book on the hosted page, or sign in here.
|
|
510
|
+
if (ev.status === "published" &&
|
|
511
|
+
!mp.isSignedIn() &&
|
|
512
|
+
ev.registration_types.some((tp) => tp.open)) {
|
|
513
|
+
const actions = document.createElement("div");
|
|
514
|
+
actions.className = "actions";
|
|
515
|
+
const signIn = document.createElement("button");
|
|
516
|
+
signIn.type = "button";
|
|
517
|
+
signIn.addEventListener("click", () => void mp.signIn({
|
|
518
|
+
scopes: [
|
|
519
|
+
"events:read",
|
|
520
|
+
"registrations:read",
|
|
521
|
+
"registrations:write",
|
|
522
|
+
],
|
|
523
|
+
}));
|
|
524
|
+
if (join) {
|
|
525
|
+
const a = document.createElement("a");
|
|
526
|
+
a.className = "button";
|
|
527
|
+
a.href = eventEnrollUrl(join.appUrl, join.slug, ev.slug, lang);
|
|
528
|
+
a.target = "_blank";
|
|
529
|
+
a.rel = "noopener";
|
|
530
|
+
a.textContent = t.joinAndBook;
|
|
531
|
+
actions.appendChild(a);
|
|
532
|
+
signIn.className = "link";
|
|
533
|
+
signIn.textContent = t.alreadyMember;
|
|
534
|
+
}
|
|
535
|
+
else
|
|
536
|
+
signIn.textContent = t.signInToRegister;
|
|
537
|
+
actions.appendChild(signIn);
|
|
538
|
+
el.appendChild(actions);
|
|
539
|
+
}
|
|
286
540
|
return el;
|
|
287
541
|
}
|
|
288
542
|
typeRow(ev, tp, mp, lang) {
|
|
289
543
|
const t = STRINGS[lang];
|
|
290
|
-
const row = document.createElement(
|
|
291
|
-
row.className =
|
|
292
|
-
const left = document.createElement(
|
|
293
|
-
const n = document.createElement(
|
|
294
|
-
n.className =
|
|
544
|
+
const row = document.createElement("div");
|
|
545
|
+
row.className = "type";
|
|
546
|
+
const left = document.createElement("div");
|
|
547
|
+
const n = document.createElement("div");
|
|
548
|
+
n.className = "n";
|
|
295
549
|
n.textContent = `${tp.name} · ${tp.amount > 0 ? money(tp.amount, tp.currency, lang) : t.free}`;
|
|
296
550
|
left.appendChild(n);
|
|
297
|
-
const s = document.createElement(
|
|
298
|
-
s.className =
|
|
299
|
-
s.textContent = [
|
|
551
|
+
const s = document.createElement("div");
|
|
552
|
+
s.className = "muted";
|
|
553
|
+
s.textContent = [
|
|
554
|
+
tp.remaining != null ? `${tp.remaining} ${t.places}` : "",
|
|
555
|
+
tp.reason ? t.reason[tp.reason] || tp.reason : "",
|
|
556
|
+
]
|
|
557
|
+
.filter(Boolean)
|
|
558
|
+
.join(" · ");
|
|
300
559
|
left.appendChild(s);
|
|
301
560
|
row.appendChild(left);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
btn.disabled =
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
561
|
+
// A visitor has the event's own call below the registrations (`card`): no button per registration.
|
|
562
|
+
if (!mp.isSignedIn())
|
|
563
|
+
return row;
|
|
564
|
+
const btn = document.createElement("button");
|
|
565
|
+
btn.type = "button";
|
|
566
|
+
btn.textContent = t.register;
|
|
567
|
+
btn.disabled = !tp.open;
|
|
568
|
+
btn.addEventListener("click", async () => {
|
|
569
|
+
btn.disabled = true;
|
|
570
|
+
try {
|
|
571
|
+
const reg = await mp.registrations.create(ev.slug, {
|
|
572
|
+
registration_type: tp.uid,
|
|
573
|
+
});
|
|
574
|
+
this.state[ev.id] = reg;
|
|
575
|
+
this.dispatchEvent(new CustomEvent("vimine-registration", {
|
|
576
|
+
detail: { registration: reg },
|
|
577
|
+
bubbles: true,
|
|
578
|
+
}));
|
|
579
|
+
void this.render();
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
btn.disabled = false;
|
|
583
|
+
const reasons = (err?.errors || [])
|
|
584
|
+
.map((e) => t.reason[e.code || ""] || e.code)
|
|
585
|
+
.filter(Boolean);
|
|
586
|
+
s.textContent = reasons.length ? reasons.join(", ") : t.error;
|
|
587
|
+
s.className = "error";
|
|
588
|
+
}
|
|
589
|
+
});
|
|
327
590
|
row.appendChild(btn);
|
|
328
591
|
return row;
|
|
329
592
|
}
|