@vimine/sdk 0.2.0 → 0.3.3

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,340 @@
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' }
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
+ },
93
+ "zh-Hans": {
94
+ signIn: "使用 Vimine 登录",
95
+ signOut: "退出",
96
+ loading: "加载中…",
97
+ notSignedIn: "请登录以查看您的会员卡。",
98
+ noMembership: "您在这个协会没有会员卡。",
99
+ notActive: "会员卡尚未生效:生效后会显示二维码。",
100
+ error: "出现错误。",
101
+ noEvents: "暂无安排的活动。",
102
+ free: "免费",
103
+ places: "个名额",
104
+ register: "报名",
105
+ signInToRegister: "登录后报名",
106
+ joinAndBook: "加入协会并预约",
107
+ alreadyMember: "已是会员?请登录",
108
+ registered: "报名已确认",
109
+ reserved: "名额已保留:等待缴纳参与费",
110
+ details: "详情",
111
+ reason: {
112
+ sold_out: "名额已满",
113
+ event_full: "已满员",
114
+ registration_not_open: "报名尚未开始",
115
+ registration_closed: "报名已截止",
116
+ membership_required: "仅限会员",
117
+ membership_not_active: "会员卡无效",
118
+ membership_not_yet_valid: "在该日期您的会员卡尚不可用",
119
+ membership_type_not_allowed: "该会员卡类型不在允许范围内",
120
+ max_per_user: "已报名",
121
+ event_past: "活动已结束",
122
+ event_cancelled: "活动已取消",
123
+ event_not_published: "未发布",
124
+ plan_limit: "不可用",
125
+ invalid_type: "类型无效",
126
+ },
127
+ },
128
+ "zh-Hant": {
129
+ signIn: "使用 Vimine 登入",
130
+ signOut: "退出",
131
+ loading: "載入中…",
132
+ notSignedIn: "請登入以檢視您的會員卡。",
133
+ noMembership: "您在這個協會沒有會員卡。",
134
+ notActive: "會員卡尚未生效:生效後會顯示二維碼。",
135
+ error: "出現錯誤。",
136
+ noEvents: "暫無安排的活動。",
137
+ free: "免費",
138
+ places: "個名額",
139
+ register: "報名",
140
+ signInToRegister: "登入後報名",
141
+ joinAndBook: "加入協會並預約",
142
+ alreadyMember: "已是會員?請登入",
143
+ registered: "報名已確認",
144
+ reserved: "名額已保留:等待繳納參與費",
145
+ details: "詳情",
146
+ reason: {
147
+ sold_out: "名額已滿",
148
+ event_full: "已滿員",
149
+ registration_not_open: "報名尚未開始",
150
+ registration_closed: "報名已截止",
151
+ membership_required: "僅限會員",
152
+ membership_not_active: "會員卡無效",
153
+ membership_not_yet_valid: "在該日期您的會員卡尚不可用",
154
+ membership_type_not_allowed: "該會員卡類型不在允許範圍內",
155
+ max_per_user: "已報名",
156
+ event_past: "活動已結束",
157
+ event_cancelled: "活動已取消",
158
+ event_not_published: "未釋出",
159
+ plan_limit: "不可用",
160
+ invalid_type: "類型無效",
161
+ },
162
+ },
163
+ "fr": {
164
+ signIn: "Se connecter avec Vimine",
165
+ signOut: "Se déconnecter",
166
+ loading: "Chargement…",
167
+ notSignedIn: "Connectez-vous pour voir votre carte.",
168
+ noMembership: "Aucune carte dans cette association.",
169
+ notActive: "La carte n'est pas encore active : le code apparaîtra lorsqu'elle le sera.",
170
+ error: "Une erreur s'est produite.",
171
+ noEvents: "Aucun événement prévu.",
172
+ free: "gratuite",
173
+ places: "places",
174
+ register: "S'inscrire",
175
+ signInToRegister: "Se connecter pour s'inscrire",
176
+ joinAndBook: "Adhérer à l'association et réserver",
177
+ alreadyMember: "Déjà membre : se connecter",
178
+ registered: "Inscription confirmée",
179
+ reserved: "Place réservée : en attente de la contribution",
180
+ details: "Détails",
181
+ reason: {
182
+ sold_out: "plus de places",
183
+ event_full: "complet",
184
+ registration_not_open: "inscriptions pas encore ouvertes",
185
+ registration_closed: "inscriptions closes",
186
+ membership_required: "réservé aux membres",
187
+ membership_not_active: "adhésion non valable",
188
+ membership_not_yet_valid: "la carte n'est pas encore utilisable à cette date",
189
+ membership_type_not_allowed: "type d'adhésion non admis",
190
+ max_per_user: "déjà inscrit",
191
+ event_past: "événement terminé",
192
+ event_cancelled: "événement annulé",
193
+ event_not_published: "non publié",
194
+ plan_limit: "non disponible",
195
+ invalid_type: "type non valide",
196
+ },
197
+ },
198
+ "es": {
199
+ signIn: "Iniciar sesión con Vimine",
200
+ signOut: "Cerrar sesión",
201
+ loading: "Cargando…",
202
+ notSignedIn: "Inicia sesión para ver tu carné.",
203
+ noMembership: "No tienes carné en esta asociación.",
204
+ notActive: "El carné aún no está activo: el código aparece cuando lo esté.",
205
+ error: "Se ha producido un error.",
206
+ noEvents: "No hay eventos programados.",
207
+ free: "gratuita",
208
+ places: "plazas",
209
+ register: "Inscríbete",
210
+ signInToRegister: "Inicia sesión para inscribirte",
211
+ joinAndBook: "Hazte socio y reserva",
212
+ alreadyMember: "¿Ya eres socio? Inicia sesión",
213
+ registered: "Inscripción confirmada",
214
+ reserved: "Plaza reservada: pendiente de la contribución",
215
+ details: "Detalles",
216
+ reason: {
217
+ sold_out: "agotado",
218
+ event_full: "completo",
219
+ registration_not_open: "inscripciones aún no abiertas",
220
+ registration_closed: "inscripciones cerradas",
221
+ membership_required: "solo para socios",
222
+ membership_not_active: "carné no válido",
223
+ membership_not_yet_valid: "el carné aún no se puede usar en esa fecha",
224
+ membership_type_not_allowed: "tipo de carné no admitido",
225
+ max_per_user: "ya inscrito",
226
+ event_past: "evento finalizado",
227
+ event_cancelled: "evento cancelado",
228
+ event_not_published: "no publicado",
229
+ plan_limit: "no disponible",
230
+ invalid_type: "tipo no válido",
231
+ },
232
+ },
233
+ "de": {
234
+ signIn: "Mit Vimine anmelden",
235
+ signOut: "Abmelden",
236
+ loading: "Wird geladen…",
237
+ notSignedIn: "Melden Sie sich an, um Ihren Ausweis zu sehen.",
238
+ noMembership: "Keine Mitgliedschaft in diesem Verein.",
239
+ notActive: "Der Ausweis ist noch nicht aktiv: Der Code erscheint, sobald er es ist.",
240
+ error: "Ein Fehler ist aufgetreten.",
241
+ noEvents: "Keine Veranstaltungen geplant.",
242
+ free: "kostenlos",
243
+ places: "Plätze",
244
+ register: "Teilnehmen",
245
+ signInToRegister: "Zum Teilnehmen anmelden",
246
+ joinAndBook: "Dem Verein beitreten und Platz reservieren",
247
+ alreadyMember: "Schon Mitglied? Anmelden",
248
+ registered: "Anmeldung bestätigt",
249
+ reserved: "Platz reserviert: Beitrag ausstehend",
250
+ details: "Details",
251
+ reason: {
252
+ sold_out: "ausgebucht",
253
+ event_full: "voll",
254
+ registration_not_open: "Anmeldung noch nicht offen",
255
+ registration_closed: "Anmeldung geschlossen",
256
+ membership_required: "nur für Mitglieder",
257
+ membership_not_active: "Mitgliedschaft nicht gültig",
258
+ membership_not_yet_valid: "Ihr Ausweis ist an diesem Datum noch nicht nutzbar",
259
+ membership_type_not_allowed: "Mitgliedsart nicht zugelassen",
260
+ max_per_user: "bereits angemeldet",
261
+ event_past: "Veranstaltung vorbei",
262
+ event_cancelled: "Veranstaltung abgesagt",
263
+ event_not_published: "nicht veröffentlicht",
264
+ plan_limit: "nicht verfügbar",
265
+ invalid_type: "ungültige Art",
266
+ },
267
+ },
268
+ "pt-BR": {
269
+ signIn: "Entrar com Vimine",
270
+ signOut: "Sair",
271
+ loading: "Carregando…",
272
+ notSignedIn: "Entre para ver sua carteirinha.",
273
+ noMembership: "Nenhuma carteirinha nesta associação.",
274
+ notActive: "A carteirinha ainda não está ativa: o código aparece quando ela for ativada.",
275
+ error: "Ocorreu um erro.",
276
+ noEvents: "Nenhum evento programado.",
277
+ free: "gratuita",
278
+ places: "vagas",
279
+ register: "Inscrever-se",
280
+ signInToRegister: "Entre para se inscrever",
281
+ joinAndBook: "Associe-se e reserve",
282
+ alreadyMember: "Já sou associado: entrar",
283
+ registered: "Inscrição confirmada",
284
+ reserved: "Vaga reservada: aguardando a contribuição",
285
+ details: "Detalhes",
286
+ reason: {
287
+ sold_out: "esgotado",
288
+ event_full: "lotado",
289
+ registration_not_open: "inscrições ainda não abertas",
290
+ registration_closed: "inscrições encerradas",
291
+ membership_required: "exclusivo para associados",
292
+ membership_not_active: "carteirinha inválida",
293
+ membership_not_yet_valid: "a carteirinha ainda não pode ser usada nessa data",
294
+ membership_type_not_allowed: "tipo de carteirinha não admitido",
295
+ max_per_user: "já inscrito",
296
+ event_past: "evento encerrado",
297
+ event_cancelled: "evento cancelado",
298
+ event_not_published: "não publicado",
299
+ plan_limit: "indisponível",
300
+ invalid_type: "tipo inválido",
301
+ },
302
+ },
303
+ };
304
+ // Languages drafted and not yet read by a native speaker — as on the platform, which serves a draft only where its preview
305
+ // switch names it. Their strings ship, but an element speaks one only on a page that opted in
306
+ // (`defineVimineElements(options, { previewLanguages: true })`); elsewhere it speaks Italian, as the platform would.
307
+ // A language leaves this list with the platform, in an SDK release. Empty since 2026-10-20: the platform enabled its six
308
+ // drafted languages ahead of their review (the owner's decision), and the elements speak them with it.
309
+ const DRAFT_LANGS = [];
310
+ let previewLanguages = false;
311
+ // The page's language → the elements' own: the tag itself, Chinese by script (zh-TW, zh-HK → Traditional; zh, zh-CN →
312
+ // Simplified), any other variant to the language's (en-GB → en, pt-PT → pt-BR); Italian otherwise. The platform's rule.
313
+ const LANGS = Object.keys(STRINGS);
314
+ /** The elements' language for a page's tag, or null when they do not speak it (`preview`: drafts included). */
315
+ export const matchLang = (tag, preview = previewLanguages) => {
316
+ const served = LANGS.filter((l) => preview || !DRAFT_LANGS.includes(l));
317
+ const lower = tag.trim().replace(/_/g, "-").toLowerCase();
318
+ const exact = served.find((l) => l.toLowerCase() === lower);
319
+ if (exact)
320
+ return exact;
321
+ const [lang, ...rest] = lower.split("-");
322
+ if (lang === "zh") {
323
+ const script = rest.some((p) => ["hant", "tw", "hk", "mo"].includes(p)) ? "zh-Hant" : "zh-Hans";
324
+ return served.includes(script) ? script : null;
29
325
  }
326
+ return served.find((l) => l.toLowerCase().split("-")[0] === lang) || null;
30
327
  };
31
- 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';
328
+ const langOf = (el) => matchLang(el.getAttribute("lang") ||
329
+ el.closest("[lang]")?.getAttribute("lang") ||
330
+ document.documentElement.lang ||
331
+ "it") || "it";
332
+ // Dates and amounts in the reader's conventions: English with day-month order, Chinese naming its script.
333
+ const INTL = {
334
+ it: "it-IT",
335
+ en: "en-GB",
336
+ "zh-Hans": "zh-Hans-CN",
337
+ "zh-Hant": "zh-Hant-TW",
338
+ fr: "fr-FR",
339
+ es: "es-ES",
340
+ de: "de-DE",
341
+ "pt-BR": "pt-BR",
34
342
  };
35
- const BASE_CSS = `
343
+ const BASE_CSS = /* CSS */ `
36
344
  :host { display: inline-block; font-family: var(--vimine-font, system-ui, sans-serif); color: var(--vimine-color, #111); }
37
345
  button { cursor: pointer; border: 0; border-radius: var(--vimine-radius, 999px); padding: var(--vimine-padding, 0.65rem 1.1rem);
38
346
  background: var(--vimine-accent, #0a0e1a); color: var(--vimine-accent-contrast, #fff); font: inherit; font-weight: 600; }
@@ -41,28 +349,29 @@ button:disabled { opacity: .6; cursor: default; }
41
349
  .error { color: var(--vimine-error, #a33); font-size: .9em; }
42
350
  `;
43
351
  const shadow = (el, css) => {
44
- const root = el.shadowRoot || el.attachShadow({ mode: 'open' });
352
+ const root = el.shadowRoot || el.attachShadow({ mode: "open" });
45
353
  root.innerHTML = `<style>${BASE_CSS}${css}</style>`;
46
354
  return root;
47
355
  };
48
356
  /** The client the elements use. Call once; pass your own instance to share it with the rest of the page. */
49
- export function defineVimineElements(options) {
357
+ export function defineVimineElements(options, elements = {}) {
50
358
  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);
359
+ previewLanguages = !!elements.previewLanguages;
360
+ if (typeof customElements !== "undefined") {
361
+ if (!customElements.get("vimine-sign-in"))
362
+ customElements.define("vimine-sign-in", VimineSignIn);
363
+ if (!customElements.get("vimine-card"))
364
+ customElements.define("vimine-card", VimineCard);
365
+ if (!customElements.get("vimine-enroll"))
366
+ customElements.define("vimine-enroll", VimineEnroll);
367
+ if (!customElements.get("vimine-events"))
368
+ customElements.define("vimine-events", VimineEvents);
60
369
  }
61
370
  return shared;
62
371
  }
63
372
  const client = () => {
64
373
  if (!shared)
65
- throw new Error('Vimine elements: call defineVimineElements({ clientId, redirectUri }) first');
374
+ throw new Error("Vimine elements: call defineVimineElements({ clientId, redirectUri }) first");
66
375
  return shared;
67
376
  };
68
377
  /** `<vimine-sign-in scope="…" return-to="/area" label="…" handle-redirect>` — a sign-in / sign-out button. */
@@ -77,7 +386,7 @@ export class VimineSignIn extends HTMLElement {
77
386
  }
78
387
  async boot() {
79
388
  const mp = client();
80
- if (this.hasAttribute('handle-redirect')) {
389
+ if (this.hasAttribute("handle-redirect")) {
81
390
  try {
82
391
  await mp.handleRedirect();
83
392
  }
@@ -92,28 +401,38 @@ export class VimineSignIn extends HTMLElement {
92
401
  render(error) {
93
402
  const mp = client();
94
403
  const t = STRINGS[langOf(this)];
95
- const root = shadow(this, '');
96
- const btn = document.createElement('button');
97
- btn.type = 'button';
404
+ const root = shadow(this, "");
405
+ const btn = document.createElement("button");
406
+ btn.type = "button";
98
407
  if (mp.isSignedIn()) {
99
- btn.textContent = this.getAttribute('label-signed-in') || t.signOut;
100
- btn.addEventListener('click', () => void mp.signOut({ everywhere: this.hasAttribute('everywhere') }));
408
+ btn.textContent = this.getAttribute("label-signed-in") || t.signOut;
409
+ btn.addEventListener("click", () => void mp.signOut({ everywhere: this.hasAttribute("everywhere") }));
101
410
  }
102
411
  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') } : {}) });
412
+ btn.textContent = this.getAttribute("label") || t.signIn;
413
+ btn.addEventListener("click", () => {
414
+ const scopes = (this.getAttribute("scope") || "")
415
+ .split(/[\s,]+/)
416
+ .filter(Boolean);
417
+ void mp.signIn({
418
+ ...(scopes.length ? { scopes } : {}),
419
+ ...(this.getAttribute("return-to")
420
+ ? { returnTo: this.getAttribute("return-to") }
421
+ : {}),
422
+ });
107
423
  });
108
424
  }
109
425
  root.appendChild(btn);
110
426
  if (error) {
111
- const e = document.createElement('div');
112
- e.className = 'error';
427
+ const e = document.createElement("div");
428
+ e.className = "error";
113
429
  e.textContent = error;
114
430
  root.appendChild(e);
115
431
  }
116
- this.dispatchEvent(new CustomEvent('vimine-change', { detail: { signedIn: mp.isSignedIn() }, bubbles: true }));
432
+ this.dispatchEvent(new CustomEvent("vimine-change", {
433
+ detail: { signedIn: mp.isSignedIn() },
434
+ bubbles: true,
435
+ }));
117
436
  }
118
437
  }
119
438
  /** `<vimine-card>` — the signed-in member's card as SVG (with the live QR); refreshes when the session changes. */
@@ -130,55 +449,168 @@ export class VimineCard extends HTMLElement {
130
449
  async render() {
131
450
  const mp = client();
132
451
  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)); }');
452
+ 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
453
  const note = (cls, text) => {
135
- const d = document.createElement('div');
454
+ const d = document.createElement("div");
136
455
  d.className = cls;
137
456
  d.textContent = text;
138
457
  root.appendChild(d);
139
458
  };
140
459
  if (!mp.isSignedIn())
141
- return note('muted', t.notSignedIn);
142
- note('muted', t.loading);
460
+ return note("muted", t.notSignedIn);
461
+ note("muted", t.loading);
143
462
  try {
144
463
  const svg = await mp.membership.cardSvg();
145
464
  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 }));
465
+ this.dispatchEvent(new CustomEvent("vimine-card", {
466
+ detail: { loaded: true },
467
+ bubbles: true,
468
+ }));
147
469
  }
148
470
  catch (err) {
149
471
  root.innerHTML = `<style>${BASE_CSS}</style>`;
150
472
  const code = err?.code;
151
473
  // `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);
474
+ const quiet = code === "not_found" || code === "not_active";
475
+ note(quiet ? "muted" : "error", code === "not_found"
476
+ ? t.noMembership
477
+ : code === "not_active"
478
+ ? t.notActive
479
+ : t.error);
154
480
  }
155
481
  }
156
482
  }
157
- /** `<vimine-enroll slug="…" app-url="https://app.vimine.network" height="900">` — the hosted enrolment form. */
483
+ /** The faces the hosted page offers — served by Vimine itself — or `system`, the device's own. They set everything. */
484
+ export const TYPEFACES = [
485
+ "system",
486
+ "inter",
487
+ "roboto",
488
+ "open-sans",
489
+ "montserrat",
490
+ "nunito",
491
+ "lora",
492
+ "merriweather",
493
+ "roboto-slab",
494
+ ];
495
+ const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
496
+ const appearanceQuery = (a) => {
497
+ if (!a)
498
+ return "";
499
+ const out = [];
500
+ if (a.scheme === "light" || a.scheme === "dark")
501
+ out.push(`scheme=${a.scheme}`);
502
+ if (a.accent && HEX.test(a.accent))
503
+ out.push(`accent=${encodeURIComponent(a.accent.replace(/^#/, ""))}`);
504
+ if (a.background === "transparent")
505
+ out.push("background=transparent");
506
+ else if (a.background && HEX.test(a.background))
507
+ out.push(`background=${encodeURIComponent(a.background.replace(/^#/, ""))}`);
508
+ if (a.font && TYPEFACES.includes(a.font))
509
+ out.push(`font=${a.font}`);
510
+ return out.length ? `&${out.join("&")}` : "";
511
+ };
512
+ export const enrollUrl = (appUrl, slug, lang, appearance) => `${appUrl.replace(/\/+$/, "")}/enroll/${encodeURIComponent(slug)}?locale=${encodeURIComponent(lang)}${appearanceQuery(appearance)}`;
513
+ /**
514
+ * The same page, opened FROM an event: the person joins the association and asks for a place at the event in one go.
515
+ * The request carries the event; the place is confirmed, or held until the membership is active.
516
+ */
517
+ export const eventEnrollUrl = (appUrl, slug, event, lang) => `${enrollUrl(appUrl, slug, lang)}&event=${encodeURIComponent(event)}`;
518
+ /** Any CSS colour (a name, `rgb()`, `hsl()`, a hex) as `#rrggbb`, resolved by the browser; null when it is none. */
519
+ const hexColour = (root, value) => {
520
+ const v = value.trim();
521
+ if (!v)
522
+ return null;
523
+ const probe = document.createElement("span");
524
+ probe.style.color = v;
525
+ if (!probe.style.color)
526
+ return null;
527
+ root.appendChild(probe);
528
+ const m = /rgba?\(([^)]+)\)/.exec(getComputedStyle(probe).color);
529
+ probe.remove();
530
+ const parts = (m?.[1] ?? "").split(/[\s,/]+/).filter(Boolean).map(Number);
531
+ if (parts.length < 3)
532
+ return null;
533
+ const [r = 0, g = 0, b = 0, alpha] = parts;
534
+ if (alpha === 0)
535
+ return null;
536
+ return `#${[r, g, b].map((n) => Math.round(n).toString(16).padStart(2, "0")).join("")}`;
537
+ };
538
+ /**
539
+ * The page's look from the host's CSS: `--vimine-scheme` (light | dark), `--vimine-accent` and `--vimine-background` (a
540
+ * colour, or `transparent`), `--vimine-typeface` (one of TYPEFACES) — custom properties inherit, so one rule on `:root`
541
+ * styles every Vimine element at once.
542
+ */
543
+ const appearanceOf = (el, root) => {
544
+ const css = getComputedStyle(el);
545
+ const read = (name) => css.getPropertyValue(name).trim();
546
+ const out = {};
547
+ const scheme = read("--vimine-scheme");
548
+ if (scheme === "light" || scheme === "dark")
549
+ out.scheme = scheme;
550
+ const accent = hexColour(root, read("--vimine-accent"));
551
+ if (accent)
552
+ out.accent = accent;
553
+ const background = read("--vimine-background");
554
+ if (background === "transparent")
555
+ out.background = "transparent";
556
+ else {
557
+ const bg = hexColour(root, background);
558
+ if (bg)
559
+ out.background = bg;
560
+ }
561
+ const typeface = read("--vimine-typeface");
562
+ if (TYPEFACES.includes(typeface))
563
+ out.font = typeface;
564
+ return out;
565
+ };
566
+ /**
567
+ * `<vimine-enroll slug="…" app-url="https://app.vimine.network" height="900" lang="en">` — the hosted enrolment form.
568
+ *
569
+ * Styled like the other elements, through CSS custom properties on the element or any ancestor:
570
+ *
571
+ * vimine-enroll { --vimine-scheme: light; --vimine-accent: #1e3a5f; --vimine-background: transparent; }
572
+ *
573
+ * `--vimine-scheme` the page's scheme; `--vimine-accent` its buttons, links and the association's name (moved towards
574
+ * the page's ink when it would not read on its cards); `--vimine-background` the ground behind the cards;
575
+ * `--vimine-typeface` the face of the whole page, fields included — one of TYPEFACES, served by Vimine (a framed page
576
+ * cannot use the host's fonts). The frame is styled once, when the element connects.
577
+ *
578
+ * The form opens in the element's language (`lang` on the element or the page; Italian by default). Someone who already
579
+ * has a Vimine account need not fill it: the form offers *Continua su Vimine*, which opens the hosted page in a new
580
+ * tab — there their session is recognised and they join in one step (the association's statuto and privacy notice, and
581
+ * nothing they have already told Vimine). It is a hand-off and not a sign-in inside the frame because a browser keeps a
582
+ * framed page's session apart from the same site opened directly; the frame's sandbox lets that tab open as an ordinary
583
+ * page (`allow-popups-to-escape-sandbox`).
584
+ */
158
585
  export class VimineEnroll extends HTMLElement {
159
586
  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; }');
587
+ const slug = this.getAttribute("slug");
588
+ const root = shadow(this, ":host { display: block; } iframe { width: 100%; border: 0; border-radius: var(--vimine-radius, 16px); background: transparent; }");
162
589
  if (!slug) {
163
- const e = document.createElement('div');
164
- e.className = 'error';
165
- e.textContent = 'vimine-enroll: missing slug';
590
+ const e = document.createElement("div");
591
+ e.className = "error";
592
+ e.textContent = "vimine-enroll: missing slug";
166
593
  root.appendChild(e);
167
594
  return;
168
595
  }
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');
596
+ const appUrl = (this.getAttribute("app-url") ||
597
+ client().options.appUrl ||
598
+ "https://app.vimine.network").replace(/\/+$/, "");
599
+ const frame = document.createElement("iframe");
600
+ frame.src = enrollUrl(appUrl, slug, langOf(this), appearanceOf(this, root));
601
+ frame.title = "Vimine";
602
+ frame.loading = "lazy";
603
+ frame.style.height = `${Number(this.getAttribute("height")) || 900}px`;
604
+ frame.setAttribute("sandbox", "allow-forms allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox");
176
605
  root.appendChild(frame);
177
606
  }
178
607
  }
179
608
  const money = (amount, currency, lang) => {
180
609
  try {
181
- return new Intl.NumberFormat(lang === 'en' ? 'en-GB' : 'it-IT', { style: 'currency', currency: currency || 'EUR' }).format(amount);
610
+ return new Intl.NumberFormat(INTL[lang], {
611
+ style: "currency",
612
+ currency: currency || "EUR",
613
+ }).format(amount);
182
614
  }
183
615
  catch {
184
616
  return `${amount.toFixed(2)} ${currency}`;
@@ -186,7 +618,11 @@ const money = (amount, currency, lang) => {
186
618
  };
187
619
  const when = (iso, tz, lang) => {
188
620
  try {
189
- return new Date(iso).toLocaleString(lang === 'en' ? 'en-GB' : 'it-IT', { timeZone: tz, dateStyle: 'full', timeStyle: 'short' });
621
+ return new Date(iso).toLocaleString(INTL[lang], {
622
+ timeZone: tz,
623
+ dateStyle: "full",
624
+ timeStyle: "short",
625
+ });
190
626
  }
191
627
  catch {
192
628
  return new Date(iso).toLocaleString();
@@ -195,8 +631,13 @@ const when = (iso, tz, lang) => {
195
631
  /**
196
632
  * `<vimine-events slug="…" filter="upcoming" limit="10" detail-url="/eventi/{slug}">` — the association's events:
197
633
  * 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.
634
+ * types (contribution, places left) and a register button (`registrations:write`). Fires `vimine-registration`
635
+ * `{ registration }` after a registration. `detail-url` (with `{slug}`) adds a details link.
636
+ *
637
+ * A VISITOR is offered a way in, not a bare sign-in: *Iscriviti all’associazione e prenota* opens the association's
638
+ * hosted enrolment page with the event (a new tab; `app-url` as on `<vimine-enroll>`) — they join and ask for the place
639
+ * in one go — and *Sono già socio: accedi* signs a member in here. The first is shown while the event has a registration
640
+ * that can be had and the association takes enrolment requests; otherwise the sign-in alone.
200
641
  */
201
642
  export class VimineEvents extends HTMLElement {
202
643
  unsubscribe = null;
@@ -213,117 +654,180 @@ export class VimineEvents extends HTMLElement {
213
654
  const mp = client();
214
655
  const lang = langOf(this);
215
656
  const t = STRINGS[lang];
216
- const root = shadow(this, `:host { display: block; }
657
+ const root = shadow(this,
658
+ /* CSS */ `:host { display: block; }
217
659
  .event { border: 1px solid var(--vimine-border, #e5e5e5); border-radius: var(--vimine-radius, 16px); padding: var(--vimine-padding, 1rem); margin: 0 0 1rem; }
218
660
  .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
661
  .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); }`);
662
+ .type .n { font-weight: 600; } .type button { padding: .45rem .9rem; } a { color: var(--vimine-accent, #0a0e1a); }
663
+ .actions { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem 1rem; padding-top: .75rem; border-top: 1px solid var(--vimine-border, #eee); }
664
+ a.button { display: inline-block; text-decoration: none; border-radius: var(--vimine-radius, 999px); padding: var(--vimine-padding, 0.65rem 1.1rem);
665
+ background: var(--vimine-accent, #0a0e1a); color: var(--vimine-accent-contrast, #fff); font-weight: 600; }
666
+ button.link { background: none; color: var(--vimine-accent, #0a0e1a); padding: 0; border-radius: 0; text-decoration: underline; font-weight: 400; }`);
221
667
  const note = (cls, text) => {
222
- const d = document.createElement('div');
668
+ const d = document.createElement("div");
223
669
  d.className = cls;
224
670
  d.textContent = text;
225
671
  root.appendChild(d);
226
672
  };
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;
673
+ note("muted", t.loading);
674
+ const slug = this.getAttribute("slug") || "";
675
+ const filter = (this.getAttribute("filter") || "upcoming");
676
+ const limit = Number(this.getAttribute("limit")) || undefined;
231
677
  let items;
678
+ // For a visitor: does the association take enrolment requests? Only then is joining a way to a place. A side
679
+ // read — a failure leaves the sign-in alone.
680
+ let joinable = false;
232
681
  try {
233
682
  const params = { filter, ...(limit ? { limit } : {}) };
234
- items = mp.isSignedIn() ? (await mp.events.list(params)).items : (await mp.public.events.list(slug, params)).items;
683
+ items = mp.isSignedIn()
684
+ ? (await mp.events.list(params)).items
685
+ : (await mp.public.events.list(slug, params)).items;
686
+ if (!mp.isSignedIn() && slug)
687
+ joinable = await mp.public
688
+ .association(slug)
689
+ .then((config) => config.aps.accepting_requests === true)
690
+ .catch(() => false);
235
691
  }
236
692
  catch {
237
693
  root.innerHTML = `<style>${BASE_CSS}</style>`;
238
- return note('error', t.error);
694
+ return note("error", t.error);
239
695
  }
240
- root.innerHTML = root.querySelector('style').outerHTML;
696
+ root.innerHTML = root.querySelector("style").outerHTML;
241
697
  if (!items.length)
242
- return note('muted', t.noEvents);
243
- const detailUrl = this.getAttribute('detail-url');
698
+ return note("muted", t.noEvents);
699
+ const detailUrl = this.getAttribute("detail-url");
700
+ const join = joinable
701
+ ? {
702
+ slug,
703
+ appUrl: (this.getAttribute("app-url") ||
704
+ mp.options.appUrl ||
705
+ "https://app.vimine.network").replace(/\/+$/, ""),
706
+ }
707
+ : null;
244
708
  for (const ev of items)
245
- root.appendChild(this.card(ev, mp, lang, detailUrl));
709
+ root.appendChild(this.card(ev, mp, lang, detailUrl, join));
246
710
  }
247
- card(ev, mp, lang, detailUrl) {
711
+ card(ev, mp, lang, detailUrl, join) {
248
712
  const t = STRINGS[lang];
249
- const el = document.createElement('article');
250
- el.className = 'event';
713
+ const el = document.createElement("article");
714
+ el.className = "event";
251
715
  if (ev.cover_url) {
252
- const img = document.createElement('img');
716
+ const img = document.createElement("img");
253
717
  img.src = ev.cover_url;
254
- img.alt = '';
718
+ img.alt = "";
255
719
  el.appendChild(img);
256
720
  }
257
- const h = document.createElement('h3');
721
+ const h = document.createElement("h3");
258
722
  h.textContent = ev.title;
259
723
  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 || ''}` : ''}`;
724
+ const meta = document.createElement("div");
725
+ meta.className = "muted";
726
+ meta.textContent = `${when(ev.starts_at, ev.timezone, lang)}${ev.location ? ` · ${ev.location.name || ev.location.address || ""}` : ""}`;
263
727
  el.appendChild(meta);
264
728
  if (ev.summary) {
265
- const p = document.createElement('p');
729
+ const p = document.createElement("p");
266
730
  p.textContent = ev.summary;
267
731
  el.appendChild(p);
268
732
  }
269
733
  if (detailUrl) {
270
- const a = document.createElement('a');
271
- a.href = detailUrl.replace('{slug}', encodeURIComponent(ev.slug));
734
+ const a = document.createElement("a");
735
+ a.href = detailUrl.replace("{slug}", encodeURIComponent(ev.slug));
272
736
  a.textContent = t.details;
273
737
  el.appendChild(a);
274
738
  }
275
739
  const mine = this.state[ev.id];
276
740
  if (mine) {
277
- const d = document.createElement('div');
278
- d.className = 'muted';
279
- d.textContent = mine.status === 'confirmed' ? t.registered : t.reserved;
741
+ const d = document.createElement("div");
742
+ d.className = "muted";
743
+ d.textContent = mine.status === "confirmed" ? t.registered : t.reserved;
280
744
  el.appendChild(d);
281
745
  return el;
282
746
  }
283
- if (ev.status === 'published')
747
+ if (ev.status === "published")
284
748
  for (const tp of ev.registration_types)
285
749
  el.appendChild(this.typeRow(ev, tp, mp, lang));
750
+ // A visitor: one call for the event, under its registrations — join and book on the hosted page, or sign in here.
751
+ if (ev.status === "published" &&
752
+ !mp.isSignedIn() &&
753
+ ev.registration_types.some((tp) => tp.open)) {
754
+ const actions = document.createElement("div");
755
+ actions.className = "actions";
756
+ const signIn = document.createElement("button");
757
+ signIn.type = "button";
758
+ signIn.addEventListener("click", () => void mp.signIn({
759
+ scopes: [
760
+ "events:read",
761
+ "registrations:read",
762
+ "registrations:write",
763
+ ],
764
+ }));
765
+ if (join) {
766
+ const a = document.createElement("a");
767
+ a.className = "button";
768
+ a.href = eventEnrollUrl(join.appUrl, join.slug, ev.slug, lang);
769
+ a.target = "_blank";
770
+ a.rel = "noopener";
771
+ a.textContent = t.joinAndBook;
772
+ actions.appendChild(a);
773
+ signIn.className = "link";
774
+ signIn.textContent = t.alreadyMember;
775
+ }
776
+ else
777
+ signIn.textContent = t.signInToRegister;
778
+ actions.appendChild(signIn);
779
+ el.appendChild(actions);
780
+ }
286
781
  return el;
287
782
  }
288
783
  typeRow(ev, tp, mp, lang) {
289
784
  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';
785
+ const row = document.createElement("div");
786
+ row.className = "type";
787
+ const left = document.createElement("div");
788
+ const n = document.createElement("div");
789
+ n.className = "n";
295
790
  n.textContent = `${tp.name} · ${tp.amount > 0 ? money(tp.amount, tp.currency, lang) : t.free}`;
296
791
  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(' · ');
792
+ const s = document.createElement("div");
793
+ s.className = "muted";
794
+ s.textContent = [
795
+ tp.remaining != null ? `${tp.remaining} ${t.places}` : "",
796
+ tp.reason ? t.reason[tp.reason] || tp.reason : "",
797
+ ]
798
+ .filter(Boolean)
799
+ .join(" · ");
300
800
  left.appendChild(s);
301
801
  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
- }
802
+ // A visitor has the event's own call below the registrations (`card`): no button per registration.
803
+ if (!mp.isSignedIn())
804
+ return row;
805
+ const btn = document.createElement("button");
806
+ btn.type = "button";
807
+ btn.textContent = t.register;
808
+ btn.disabled = !tp.open;
809
+ btn.addEventListener("click", async () => {
810
+ btn.disabled = true;
811
+ try {
812
+ const reg = await mp.registrations.create(ev.slug, {
813
+ registration_type: tp.uid,
814
+ });
815
+ this.state[ev.id] = reg;
816
+ this.dispatchEvent(new CustomEvent("vimine-registration", {
817
+ detail: { registration: reg },
818
+ bubbles: true,
819
+ }));
820
+ void this.render();
821
+ }
822
+ catch (err) {
823
+ btn.disabled = false;
824
+ const reasons = (err?.errors || [])
825
+ .map((e) => t.reason[e.code || ""] || e.code)
826
+ .filter(Boolean);
827
+ s.textContent = reasons.length ? reasons.join(", ") : t.error;
828
+ s.className = "error";
829
+ }
830
+ });
327
831
  row.appendChild(btn);
328
832
  return row;
329
833
  }