@tekkare/romulus 0.1.243 → 0.1.245
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/module.json +1 -1
- package/layer/app/app.config.ts +6 -0
- package/layer/app/components/OipShell.vue +3 -0
- package/layer/app/components/layout/alertBrandSync.vue +143 -0
- package/layer/app/components/layout/tool/adminTemplates.vue +261 -0
- package/layer/app/components/layout/tool/alerts.vue +969 -0
- package/layer/app/components/layout/tool/export.vue +590 -0
- package/layer/app/components/layout/tool/views.vue +933 -0
- package/layer/app/components/layout/viewEmptyState.vue +278 -0
- package/layer/app/components/layout/viewPill.vue +550 -0
- package/layer/app/components/panels/OipPanelStatistics.vue +447 -0
- package/layer/app/composables/useAdminAccess.ts +86 -0
- package/layer/app/composables/useAlertBrandSync.ts +85 -0
- package/layer/app/composables/useAlertSource.ts +210 -0
- package/layer/app/composables/useAlerts.ts +345 -0
- package/layer/app/composables/useExportSource.ts +236 -0
- package/layer/app/composables/useHubrEmbedded.ts +13 -0
- package/layer/app/composables/usePermalink.ts +108 -0
- package/layer/app/composables/useSavedExports.ts +286 -0
- package/layer/app/composables/useViewCardActions.ts +500 -0
- package/layer/app/composables/useViewTemplates.ts +369 -0
- package/layer/app/composables/useViews.ts +574 -0
- package/layer/app/oip-defaults/alerts.ts +34 -0
- package/layer/app/oip-defaults/api.ts +18 -0
- package/layer/app/oip-defaults/export.ts +23 -0
- package/layer/app/oip-defaults/ui.ts +21 -0
- package/layer/app/oip-defaults/views.ts +59 -0
- package/layer/app/types/stats.ts +49 -0
- package/layer/app/utils/alertFieldLabels.ts +191 -0
- package/layer/app/utils/oipRail.ts +29 -3
- package/layer/app/utils/oipScopeStore.ts +112 -0
- package/layer/app/utils/oipScopes.ts +376 -0
- package/layer/app/utils/viewPages.ts +30 -0
- package/layer/app/utils/viewTemplateCriteria.ts +215 -0
- package/layer/app/utils/viewTemplates.ts +116 -0
- package/layer/nuxt.config.ts +18 -0
- package/layer/server/api/oip/notes.get.ts +1 -1
- package/layer/server/api/oip/stats/users.get.ts +80 -0
- package/layer/server/api/oip/stats.get.ts +88 -0
- package/layer/server/utils/oipPosthog.ts +134 -0
- package/layer/server/utils/oipWorkspaces.ts +51 -0
- package/package.json +3 -2
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useViewTemplates - les templates Tekkare d'une page, prets a afficher.
|
|
3
|
+
*
|
|
4
|
+
* Deux ecrans les proposent : l'onglet « Templates Tekkare » du panneau du
|
|
5
|
+
* rail, et l'etat vide des pages a selection de produits (c'est justement la
|
|
6
|
+
* qu'un panier pret a l'emploi sert le plus). Les deux les mettent en forme et
|
|
7
|
+
* les demarrent de la meme facon, donc la mise en carte et le demarrage vivent
|
|
8
|
+
* ici, pas en double.
|
|
9
|
+
*
|
|
10
|
+
* Ils viennent de deux endroits, et rien en aval ne fait la difference :
|
|
11
|
+
* - le catalogue en dur de `~/utils/viewTemplates`, socle offert a tout le
|
|
12
|
+
* monde ;
|
|
13
|
+
* - ceux qu'un superadmin a publies depuis l'app pour des espaces clients
|
|
14
|
+
* choisis (`GET /view-templates`).
|
|
15
|
+
*
|
|
16
|
+
* Les seconds sont convertis dans la forme des premiers des leur arrivee :
|
|
17
|
+
* mise en carte, demarrage et lien profond restent ecrits une fois.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
normalizePage,
|
|
21
|
+
viewTemplateById,
|
|
22
|
+
viewTemplatesFor,
|
|
23
|
+
VIEW_TEMPLATE_QUERY_KEYS,
|
|
24
|
+
VIEW_TEMPLATE_QUERY_PARAM,
|
|
25
|
+
VIEW_TEMPLATE_QUERY_RESET,
|
|
26
|
+
} from "../utils/viewTemplates";
|
|
27
|
+
import type { ViewTemplate, ViewTemplateCriterion } from "../utils/viewTemplates";
|
|
28
|
+
import { decodeCriteria } from "../utils/viewTemplateCriteria";
|
|
29
|
+
import type { StoredCriterion } from "../utils/viewTemplateCriteria";
|
|
30
|
+
import { useOipApi } from "#oip-app/api";
|
|
31
|
+
import { PUBLISHED_TEMPLATE_NAMESPACE, resolveTemplateBrands } from "#oip-app/views";
|
|
32
|
+
|
|
33
|
+
/** Icone des templates publies depuis l'app : ils n'en portent pas. */
|
|
34
|
+
const PUBLISHED_ICON = "Sparkle";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Criteres qui decrivent un panier par sa CLASSE, et non par les produits
|
|
38
|
+
* qu'elle resout aujourd'hui.
|
|
39
|
+
*
|
|
40
|
+
* Une vue du portail porte les deux : la liste de marques dépliée et la
|
|
41
|
+
* classe qui l'a produite (cf. `useDrugsViews`). Pour une vue enregistree,
|
|
42
|
+
* c'est la liste qui fait foi — son proprietaire veut retrouver la meme
|
|
43
|
+
* analyse. Pour un template, c'est l'inverse : une classe se reexpanse a
|
|
44
|
+
* chaque ouverture, une liste figee ignore la prochaine AMM.
|
|
45
|
+
*/
|
|
46
|
+
const SCOPE_CLASS_FILTERS = ["atc5", "atc4", "company"];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Les templates publies pour cet espace, charges une fois par session.
|
|
50
|
+
*
|
|
51
|
+
* Tout l'espace d'un coup, et le tri par page se fait ici : une vue est
|
|
52
|
+
* enregistree sous `route.path`, donc sous `/en/...` pour qui lit en anglais.
|
|
53
|
+
* Demander la page a l'API reviendrait a ne rien rendre a la moitie des
|
|
54
|
+
* lecteurs. `normalizePage` retire ce prefixe des deux cotes, exactement
|
|
55
|
+
* comme pour le catalogue en dur.
|
|
56
|
+
*
|
|
57
|
+
* Etat partage plutot que `useAsyncData` : deux ecrans lisent la meme liste
|
|
58
|
+
* (le panneau du rail et l'etat vide), et le panneau suit la page declaree,
|
|
59
|
+
* qui change en naviguant sans qu'un composant soit remonte.
|
|
60
|
+
*/
|
|
61
|
+
function publishedStore() {
|
|
62
|
+
return {
|
|
63
|
+
list: useState<ViewTemplate[] | null>("viewTemplates:published", () => null),
|
|
64
|
+
pending: useState<boolean>("viewTemplates:pending", () => false),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Un template publie, dans la forme du catalogue en dur.
|
|
70
|
+
*
|
|
71
|
+
* Le titre et la description sont ranges dans une seule langue, celle de qui
|
|
72
|
+
* a publie : les deux faces pointent donc sur la meme chaine. Ce sont les
|
|
73
|
+
* CRITERES qui se traduisent, quand leur cle est connue (cf.
|
|
74
|
+
* `viewTemplateCriteria`).
|
|
75
|
+
*
|
|
76
|
+
* `deepLink` n'est pas range non plus, il se deduit : un template dont tous
|
|
77
|
+
* les criteres sont des cles de perimetre produit doit passer par l'URL, pour
|
|
78
|
+
* que le middleware `03.scope-from-query` les expanse.
|
|
79
|
+
*/
|
|
80
|
+
export function toTemplate(row: {
|
|
81
|
+
id: string;
|
|
82
|
+
title: string;
|
|
83
|
+
description?: string | null;
|
|
84
|
+
page: string;
|
|
85
|
+
criteria: any;
|
|
86
|
+
forWorkspace?: boolean;
|
|
87
|
+
}): ViewTemplate {
|
|
88
|
+
const stored: StoredCriterion[] = Array.isArray(row.criteria) ? row.criteria : [];
|
|
89
|
+
const text = { fr: row.title, en: row.title };
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
id: `published-${row.id}`,
|
|
93
|
+
page: normalizePage(row.page),
|
|
94
|
+
icon: PUBLISHED_ICON,
|
|
95
|
+
tone: "primary",
|
|
96
|
+
title: text,
|
|
97
|
+
description: { fr: row.description ?? "", en: row.description ?? "" },
|
|
98
|
+
deepLink:
|
|
99
|
+
stored.length > 0 &&
|
|
100
|
+
stored.every((item) => VIEW_TEMPLATE_QUERY_KEYS[item.filter] !== undefined),
|
|
101
|
+
forYou: row.forWorkspace === true,
|
|
102
|
+
criteria: (ctx) =>
|
|
103
|
+
decodeCriteria(stored, {
|
|
104
|
+
lang: ctx.lang,
|
|
105
|
+
t: ctx.t,
|
|
106
|
+
te: ctx.te,
|
|
107
|
+
namespace: PUBLISHED_TEMPLATE_NAMESPACE,
|
|
108
|
+
}) as ViewTemplateCriterion[],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Valeurs d'un critere de template. La periode est gardee en chaine par le
|
|
114
|
+
* selecteur de dates (c'est ce qui la garde relative), la ou les autres
|
|
115
|
+
* filtres tiennent un tableau : les deux se lisent ici.
|
|
116
|
+
*
|
|
117
|
+
* Les bornes vides sont retirees : une periode sans borne vaut `[null, null]`,
|
|
118
|
+
* que `String` rendait « null, null » sur la carte du template et repassait
|
|
119
|
+
* telle quelle dans l'URL au demarrage (`period=null,null`).
|
|
120
|
+
*/
|
|
121
|
+
export function viewTemplateValues(item: ViewTemplateCriterion): string[] {
|
|
122
|
+
const raw = Array.isArray(item?.values) ? item.values : item?.values ? [item.values] : [];
|
|
123
|
+
return raw
|
|
124
|
+
.map((value) => (value === null || value === undefined ? "" : String(value)))
|
|
125
|
+
.filter((value) => value !== "" && value !== "null" && value !== "undefined");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param page chemin de la page dont on veut les templates. Relu a chaque
|
|
130
|
+
* acces : le panneau du rail suit la page declaree, qui change en naviguant.
|
|
131
|
+
*/
|
|
132
|
+
export function useViewTemplates(page: () => string) {
|
|
133
|
+
const { locale, t, te } = useI18n();
|
|
134
|
+
const route = useRoute();
|
|
135
|
+
const views = useViews();
|
|
136
|
+
const elysiaApi = useOipApi();
|
|
137
|
+
const filterLabel = useViewFilterLabels(() => views.current.value?.filterNamespace);
|
|
138
|
+
const published = publishedStore();
|
|
139
|
+
|
|
140
|
+
const lang = computed<"fr" | "en">(() => (locale.value === "en" ? "en" : "fr"));
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Les criteres se construisent avec la langue de l'ecran : valeurs de
|
|
144
|
+
* filtres traduites, et sur la page Prix le nom du filtre lui-meme (cf.
|
|
145
|
+
* `viewTemplates`).
|
|
146
|
+
*/
|
|
147
|
+
const context = computed(() => ({
|
|
148
|
+
lang: lang.value,
|
|
149
|
+
t,
|
|
150
|
+
te: (key: string) => te(key),
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Charge les templates publies. Rien au rendu serveur : la route demande la
|
|
155
|
+
* session, et le panneau ne s'ouvre que cote client.
|
|
156
|
+
*/
|
|
157
|
+
async function loadPublished() {
|
|
158
|
+
if (!import.meta.client) return;
|
|
159
|
+
if (published.list.value || published.pending.value) return;
|
|
160
|
+
|
|
161
|
+
published.pending.value = true;
|
|
162
|
+
try {
|
|
163
|
+
const res = await elysiaApi.viewTemplates.list();
|
|
164
|
+
published.list.value = (res?.data ?? []).map(toTemplate);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
console.error("[useViewTemplates] Templates publies indisponibles:", err);
|
|
167
|
+
published.list.value = [];
|
|
168
|
+
} finally {
|
|
169
|
+
published.pending.value = false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
onMounted(loadPublished);
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Les publies d'abord : ce sont eux qui visent cet espace, le catalogue en
|
|
177
|
+
* dur est le socle offert a tout le monde. Parmi eux, ceux crees pour cet
|
|
178
|
+
* espace nommement passent en tete.
|
|
179
|
+
*/
|
|
180
|
+
const templates = computed<ViewTemplate[]>(() => {
|
|
181
|
+
const wanted = normalizePage(page());
|
|
182
|
+
const mine = (published.list.value ?? []).filter((item) => item.page === wanted);
|
|
183
|
+
return [
|
|
184
|
+
...mine.filter((item) => item.forYou),
|
|
185
|
+
...mine.filter((item) => !item.forYou),
|
|
186
|
+
...viewTemplatesFor(page()),
|
|
187
|
+
];
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Ce que la pastille d'un critere affiche. Un critere sans valeur utilisable
|
|
192
|
+
* se dit, plutot que de se taire : un template peut porter une periode sans
|
|
193
|
+
* borne, et une pastille « Periode » vide laisserait croire a un bug.
|
|
194
|
+
*/
|
|
195
|
+
function pillValue(item: ViewTemplateCriterion) {
|
|
196
|
+
const values = viewTemplateValues(item);
|
|
197
|
+
if (values.length) return values.join(", ");
|
|
198
|
+
if (item.filter === "Period") return lang.value === "en" ? "No period" : "Aucune période";
|
|
199
|
+
return lang.value === "en" ? "None" : "Aucune valeur";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Un template en carte, dans la forme de `RmSavedViewCard`. Expose : l'onglet
|
|
204
|
+
* « Admin templates » met en carte des templates qui ne sont pas offerts a
|
|
205
|
+
* l'espace du lecteur, donc absents de `templates`.
|
|
206
|
+
*/
|
|
207
|
+
function cardOf(template: ViewTemplate) {
|
|
208
|
+
const criteria = template.criteria(context.value);
|
|
209
|
+
return {
|
|
210
|
+
id: template.id,
|
|
211
|
+
title: template.title[lang.value],
|
|
212
|
+
description: template.description[lang.value],
|
|
213
|
+
icon: template.icon,
|
|
214
|
+
tone: template.tone ?? "primary",
|
|
215
|
+
actionLabel: lang.value === "en" ? "Start" : "Démarrer",
|
|
216
|
+
forYou: template.forYou === true,
|
|
217
|
+
// Garder le template sans le demarrer : il fallait l'ouvrir, puis le
|
|
218
|
+
// nommer et l'enregistrer comme n'importe quelle selection, et rien ne
|
|
219
|
+
// disait que c'etait possible.
|
|
220
|
+
menu: [
|
|
221
|
+
{
|
|
222
|
+
key: "save-template",
|
|
223
|
+
label: lang.value === "en" ? "Add to my views" : "Ajouter à mes vues",
|
|
224
|
+
icon: "BookmarkSimple",
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
config: [
|
|
228
|
+
{
|
|
229
|
+
icon: "Funnel",
|
|
230
|
+
key: `${lang.value === "en" ? "Filters" : "Filtres"} (${criteria.length})`,
|
|
231
|
+
pills: criteria.map((item) => ({
|
|
232
|
+
k: filterLabel(item.filter),
|
|
233
|
+
v: pillValue(item),
|
|
234
|
+
})),
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Les templates de la page en cartes. */
|
|
241
|
+
const cards = computed(() => templates.value.map(cardOf));
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Demarre un template : ses filtres passent a l'ecran, rien n'est enregistre
|
|
245
|
+
* (la vue est temporaire, cf. `useViews.applyTemporary`).
|
|
246
|
+
*
|
|
247
|
+
* Un panier de produits du portail passe, lui, par l'URL : c'est le
|
|
248
|
+
* middleware `03.scope-from-query` qui expanse un code ATC en liste de
|
|
249
|
+
* marques et remplit les listes par source, ce que la page ne sait pas faire
|
|
250
|
+
* elle-meme.
|
|
251
|
+
*/
|
|
252
|
+
function start(id: string | ViewTemplate) {
|
|
253
|
+
// Un template se passe aussi tel quel : ceux de l'onglet « Admin
|
|
254
|
+
// templates » ne sont pas forcement offerts a l'espace du lecteur.
|
|
255
|
+
const template =
|
|
256
|
+
typeof id === "string" ? templates.value.find((item) => item.id === id) : id;
|
|
257
|
+
if (!template) return;
|
|
258
|
+
|
|
259
|
+
const criteria = template.criteria(context.value);
|
|
260
|
+
|
|
261
|
+
if (template.deepLink) {
|
|
262
|
+
// La classe l'emporte sur la liste qu'elle avait resolue : les deux
|
|
263
|
+
// partiraient en `should` dans la requete du perimetre, donc en union,
|
|
264
|
+
// et la liste figee ramenerait les produits que la classe a depuis
|
|
265
|
+
// perdus. Un template dit une classe, pas un etat du referentiel.
|
|
266
|
+
const byClass = criteria.some((item) => SCOPE_CLASS_FILTERS.includes(item.filter));
|
|
267
|
+
const posed = byClass
|
|
268
|
+
? criteria.filter((item) => item.filter !== "brands")
|
|
269
|
+
: criteria;
|
|
270
|
+
|
|
271
|
+
const query: Record<string, any> = { ...route.query };
|
|
272
|
+
VIEW_TEMPLATE_QUERY_RESET.forEach((key) => delete query[key]);
|
|
273
|
+
posed.forEach((item) => {
|
|
274
|
+
const key = VIEW_TEMPLATE_QUERY_KEYS[item.filter];
|
|
275
|
+
const values = viewTemplateValues(item);
|
|
276
|
+
// Un critere sans valeur ne pose rien : `?period=` serait lu comme une
|
|
277
|
+
// borne vide par la page, pas comme une absence de filtre.
|
|
278
|
+
if (key && values.length) query[key] = values.join(",");
|
|
279
|
+
});
|
|
280
|
+
// Le template se nomme dans l'URL : la vue temporaire que le middleware
|
|
281
|
+
// pose en le lisant porte son identifiant, et la pastille le nomme.
|
|
282
|
+
query[VIEW_TEMPLATE_QUERY_PARAM] = template.id;
|
|
283
|
+
// La vue nommee active cede la place avant la navigation : le middleware
|
|
284
|
+
// garde une vue enregistree de la page (`keepsNamedView`), et le
|
|
285
|
+
// template partait sous le nom de la vue de l'utilisateur, banniere
|
|
286
|
+
// « ecraser » comprise. Meme forme que la vue qu'il posera lui-meme.
|
|
287
|
+
views.scopes.value().setSelectedScope({
|
|
288
|
+
id: null,
|
|
289
|
+
title: "",
|
|
290
|
+
template: template.id,
|
|
291
|
+
page: route.path,
|
|
292
|
+
criteria: [],
|
|
293
|
+
});
|
|
294
|
+
navigateTo({ path: route.path, query });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
views.applyTemporary(criteria, template.title[lang.value], template.id);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Le titre d'un template par identifiant, sur toutes les pages : une vue
|
|
303
|
+
* demarree depuis un template sur le marche mensuel garde son nom sur le
|
|
304
|
+
* KPI, alors que l'identifiant porte la page de depart.
|
|
305
|
+
*/
|
|
306
|
+
function titleOf(id: unknown): string | null {
|
|
307
|
+
const wanted = String(id ?? "");
|
|
308
|
+
const template =
|
|
309
|
+
templates.value.find((item) => item.id === wanted) ??
|
|
310
|
+
(published.list.value ?? []).find((item) => item.id === wanted) ??
|
|
311
|
+
viewTemplateById(wanted);
|
|
312
|
+
return template ? template.title[lang.value] : null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Enregistre un template dans les vues du lecteur, sans l'ouvrir. Il garde
|
|
317
|
+
* son titre et sa description ; la vue est rangee sous la page courante,
|
|
318
|
+
* comme une vue composee ici.
|
|
319
|
+
*
|
|
320
|
+
* Un panier du portail est une classe : la vue enregistree porte la liste de
|
|
321
|
+
* marques qu'elle resout aujourd'hui (c'est ce qu'une vue du portail sait
|
|
322
|
+
* appliquer, cf. useDrugsViews) ET la classe, pour dire comment elle a ete
|
|
323
|
+
* composee. Meme resolution que le middleware du lien profond.
|
|
324
|
+
*/
|
|
325
|
+
async function save(id: string) {
|
|
326
|
+
const template = templates.value.find((item) => item.id === id);
|
|
327
|
+
if (!template) return;
|
|
328
|
+
|
|
329
|
+
const page = route.path;
|
|
330
|
+
const criteria = template.criteria(context.value);
|
|
331
|
+
let stored: any[] = criteria.map((item) => ({
|
|
332
|
+
filter: item.filter,
|
|
333
|
+
page,
|
|
334
|
+
values: Array.isArray(item.values) ? item.values : [item.values],
|
|
335
|
+
}));
|
|
336
|
+
|
|
337
|
+
if (template.deepLink) {
|
|
338
|
+
const of = (filter: string) =>
|
|
339
|
+
stored.filter((item) => item.filter === filter).flatMap((item) => item.values as string[]);
|
|
340
|
+
// Un panier est une classe : la vue porte la liste de marques qu'elle
|
|
341
|
+
// resout aujourd'hui. La resolution est propre a l'app (son repertoire
|
|
342
|
+
// de marques), cf. contrat #oip-app/views.
|
|
343
|
+
const brands = await resolveTemplateBrands(
|
|
344
|
+
{
|
|
345
|
+
// `brand` (singulier) : exception ponctuelle d'un panier, a fondre
|
|
346
|
+
// dans la meme liste resolue que `brands`.
|
|
347
|
+
brands: [...of("brands"), ...of("brand")],
|
|
348
|
+
atc5: of("atc5"),
|
|
349
|
+
atc4: of("atc4"),
|
|
350
|
+
companies: of("company"),
|
|
351
|
+
},
|
|
352
|
+
`viewTemplateSave:${id}`,
|
|
353
|
+
);
|
|
354
|
+
stored = [
|
|
355
|
+
{ filter: "brands", page, values: brands },
|
|
356
|
+
...stored.filter((item) => item.filter !== "brands"),
|
|
357
|
+
];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
await views.scopes.value().createScope({
|
|
361
|
+
title: template.title[lang.value],
|
|
362
|
+
description: template.description[lang.value],
|
|
363
|
+
criteria: stored,
|
|
364
|
+
currentPage: page,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return { lang, templates, cards, cardOf, start, save, titleOf };
|
|
369
|
+
}
|