@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/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 './index.js';
20
+ import { Vimine } from "./index.js";
18
21
  let shared = null;
19
22
  const STRINGS = {
20
23
  it: {
21
- signIn: 'Accedi con Vimine', signOut: 'Esci', loading: 'Caricamento…', notSignedIn: 'Accedi per vedere la tua tessera.', noMembership: 'Nessuna tessera in questa associazione.', notActive: 'La tessera non è ancora attiva: il codice compare quando lo diventa.', error: 'Si è verificato un errore.',
22
- noEvents: 'Nessun evento in programma.', free: 'gratuita', places: 'posti', register: 'Iscriviti', signInToRegister: 'Accedi per iscriverti', registered: 'Iscrizione confermata', reserved: 'Posto riservato: in attesa del contributo', details: 'Dettagli',
23
- reason: { sold_out: 'esaurito', event_full: 'al completo', registration_not_open: 'iscrizioni non ancora aperte', registration_closed: 'iscrizioni chiuse', membership_required: 'riservato ai soci', membership_not_active: 'tessera non valida', membership_not_yet_valid: 'la tessera non è ancora utilizzabile a quella data', membership_type_not_allowed: 'tipo di tessera non ammesso', max_per_user: 'già iscritto', event_past: 'evento concluso', event_cancelled: 'evento annullato', event_not_published: 'non pubblicato', plan_limit: 'non disponibile', invalid_type: 'tipo non valido' }
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: 'Sign in with Vimine', signOut: 'Sign out', loading: 'Loading…', notSignedIn: 'Sign in to see your card.', noMembership: 'No membership in this association.', notActive: 'The card is not active yet: its code appears once it is.', error: 'Something went wrong.',
27
- noEvents: 'No upcoming events.', free: 'free', places: 'places', register: 'Register', signInToRegister: 'Sign in to register', registered: 'Registration confirmed', reserved: 'Place reserved: awaiting the contribution', details: 'Details',
28
- reason: { sold_out: 'sold out', event_full: 'full', registration_not_open: 'registrations not open yet', registration_closed: 'registrations closed', membership_required: 'members only', membership_not_active: 'membership not valid', membership_not_yet_valid: 'your card is not usable yet on that date', membership_type_not_allowed: 'membership type not admitted', max_per_user: 'already registered', event_past: 'event over', event_cancelled: 'event cancelled', event_not_published: 'not published', plan_limit: 'not available', invalid_type: 'invalid type' }
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('lang') || el.closest('[lang]')?.getAttribute('lang') || document.documentElement.lang || 'it').slice(0, 2).toLowerCase();
33
- return l === 'en' ? 'en' : 'it';
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: 'open' });
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 !== 'undefined') {
52
- if (!customElements.get('vimine-sign-in'))
53
- customElements.define('vimine-sign-in', VimineSignIn);
54
- if (!customElements.get('vimine-card'))
55
- customElements.define('vimine-card', VimineCard);
56
- if (!customElements.get('vimine-enroll'))
57
- customElements.define('vimine-enroll', VimineEnroll);
58
- if (!customElements.get('vimine-events'))
59
- customElements.define('vimine-events', VimineEvents);
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('Vimine elements: call defineVimineElements({ clientId, redirectUri }) first');
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('handle-redirect')) {
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('button');
97
- btn.type = 'button';
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('label-signed-in') || t.signOut;
100
- btn.addEventListener('click', () => void mp.signOut({ everywhere: this.hasAttribute('everywhere') }));
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('label') || t.signIn;
104
- btn.addEventListener('click', () => {
105
- const scopes = (this.getAttribute('scope') || '').split(/[\s,]+/).filter(Boolean);
106
- void mp.signIn({ ...(scopes.length ? { scopes } : {}), ...(this.getAttribute('return-to') ? { returnTo: this.getAttribute('return-to') } : {}) });
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('div');
112
- e.className = 'error';
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('vimine-change', { detail: { signedIn: mp.isSignedIn() }, bubbles: true }));
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, ':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)); }');
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('div');
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('muted', t.notSignedIn);
142
- note('muted', t.loading);
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('vimine-card', { detail: { loaded: true }, bubbles: true }));
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 === 'not_found' || code === 'not_active';
153
- note(quiet ? 'muted' : 'error', code === 'not_found' ? t.noMembership : code === 'not_active' ? t.notActive : t.error);
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
- /** `<vimine-enroll slug="…" app-url="https://app.vimine.network" height="900">` — the hosted enrolment form. */
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('slug');
161
- const root = shadow(this, ':host { display: block; } iframe { width: 100%; border: 0; border-radius: var(--vimine-radius, 16px); background: transparent; }');
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('div');
164
- e.className = 'error';
165
- e.textContent = 'vimine-enroll: missing slug';
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('app-url') || client().options.appUrl || 'https://app.vimine.network').replace(/\/+$/, '');
170
- const frame = document.createElement('iframe');
171
- frame.src = `${appUrl}/enroll/${encodeURIComponent(slug)}`;
172
- frame.title = 'Vimine';
173
- frame.loading = 'lazy';
174
- frame.style.height = `${Number(this.getAttribute('height')) || 900}px`;
175
- frame.setAttribute('sandbox', 'allow-forms allow-scripts allow-same-origin allow-popups');
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 === 'en' ? 'en-GB' : 'it-IT', { style: 'currency', currency: currency || 'EUR' }).format(amount);
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 === 'en' ? 'en-GB' : 'it-IT', { timeZone: tz, dateStyle: 'full', timeStyle: 'short' });
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`) — or a sign-in button. Fires
199
- * `vimine-registration` `{ registration }` after a registration. `detail-url` (with `{slug}`) adds a details link.
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, `:host { display: block; }
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('div');
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('muted', t.loading);
228
- const slug = this.getAttribute('slug') || '';
229
- const filter = (this.getAttribute('filter') || 'upcoming');
230
- const limit = Number(this.getAttribute('limit')) || undefined;
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() ? (await mp.events.list(params)).items : (await mp.public.events.list(slug, params)).items;
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('error', t.error);
453
+ return note("error", t.error);
239
454
  }
240
- root.innerHTML = root.querySelector('style').outerHTML;
455
+ root.innerHTML = root.querySelector("style").outerHTML;
241
456
  if (!items.length)
242
- return note('muted', t.noEvents);
243
- const detailUrl = this.getAttribute('detail-url');
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('article');
250
- el.className = 'event';
472
+ const el = document.createElement("article");
473
+ el.className = "event";
251
474
  if (ev.cover_url) {
252
- const img = document.createElement('img');
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('h3');
480
+ const h = document.createElement("h3");
258
481
  h.textContent = ev.title;
259
482
  el.appendChild(h);
260
- const meta = document.createElement('div');
261
- meta.className = 'muted';
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('p');
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('a');
271
- a.href = detailUrl.replace('{slug}', encodeURIComponent(ev.slug));
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('div');
278
- d.className = 'muted';
279
- d.textContent = mine.status === 'confirmed' ? t.registered : t.reserved;
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 === 'published')
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('div');
291
- row.className = 'type';
292
- const left = document.createElement('div');
293
- const n = document.createElement('div');
294
- n.className = 'n';
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('div');
298
- s.className = 'muted';
299
- s.textContent = [tp.remaining != null ? `${tp.remaining} ${t.places}` : '', tp.reason ? t.reason[tp.reason] || tp.reason : ''].filter(Boolean).join(' · ');
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
- const btn = document.createElement('button');
303
- btn.type = 'button';
304
- if (!mp.isSignedIn()) {
305
- btn.textContent = t.signInToRegister;
306
- btn.addEventListener('click', () => void mp.signIn({ scopes: ['events:read', 'registrations:read', 'registrations:write'] }));
307
- }
308
- else {
309
- btn.textContent = t.register;
310
- btn.disabled = !tp.open;
311
- btn.addEventListener('click', async () => {
312
- btn.disabled = true;
313
- try {
314
- const reg = await mp.registrations.create(ev.slug, { registration_type: tp.uid });
315
- this.state[ev.id] = reg;
316
- this.dispatchEvent(new CustomEvent('vimine-registration', { detail: { registration: reg }, bubbles: true }));
317
- void this.render();
318
- }
319
- catch (err) {
320
- btn.disabled = false;
321
- const reasons = (err?.errors || []).map((e) => t.reason[e.code || ''] || e.code).filter(Boolean);
322
- s.textContent = reasons.length ? reasons.join(', ') : t.error;
323
- s.className = 'error';
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
  }