estela 0.1.3 → 0.1.6
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/billing/sessionize.d.ts +10 -1
- package/dist/billing/sessionize.d.ts.map +1 -1
- package/dist/billing/sessionize.js +0 -0
- package/dist/billing/sessionize.js.map +1 -1
- package/dist/billing/sessionize.test.js +28 -0
- package/dist/billing/sessionize.test.js.map +1 -1
- package/dist/billing.d.ts +16 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +71 -0
- package/dist/billing.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +345 -93
- package/dist/cli.js.map +1 -1
- package/dist/cloud/client.d.ts +6 -0
- package/dist/cloud/client.d.ts.map +1 -1
- package/dist/cloud/client.js +26 -0
- package/dist/cloud/client.js.map +1 -1
- package/dist/db/store.d.ts +56 -0
- package/dist/db/store.d.ts.map +1 -1
- package/dist/db/store.js +101 -0
- package/dist/db/store.js.map +1 -1
- package/dist/export/panel.d.ts +3 -1
- package/dist/export/panel.d.ts.map +1 -1
- package/dist/export/panel.js +24 -19
- package/dist/export/panel.js.map +1 -1
- package/dist/export/panel.test.js +46 -5
- package/dist/export/panel.test.js.map +1 -1
- package/dist/metrics/team.d.ts +9 -0
- package/dist/metrics/team.d.ts.map +1 -1
- package/dist/metrics/team.js +0 -0
- package/dist/metrics/team.js.map +1 -1
- package/dist/publish.d.ts +37 -0
- package/dist/publish.d.ts.map +1 -0
- package/dist/publish.js +185 -0
- package/dist/publish.js.map +1 -0
- package/dist/publish.test.d.ts +2 -0
- package/dist/publish.test.d.ts.map +1 -0
- package/dist/publish.test.js +91 -0
- package/dist/publish.test.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +88 -58
- package/dist/server.js.map +1 -1
- package/dist/sync/team.d.ts +21 -0
- package/dist/sync/team.d.ts.map +1 -0
- package/dist/sync/team.js +68 -0
- package/dist/sync/team.js.map +1 -0
- package/dist/sync/team.test.d.ts +2 -0
- package/dist/sync/team.test.d.ts.map +1 -0
- package/dist/sync/team.test.js +102 -0
- package/dist/sync/team.test.js.map +1 -0
- package/dist/sync.d.ts +20 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +79 -0
- package/dist/sync.js.map +1 -0
- package/dist/sync.test.d.ts +2 -0
- package/dist/sync.test.d.ts.map +1 -0
- package/dist/sync.test.js +151 -0
- package/dist/sync.test.js.map +1 -0
- package/dist/team.d.ts +51 -0
- package/dist/team.d.ts.map +1 -0
- package/dist/team.js +122 -0
- package/dist/team.js.map +1 -0
- package/dist/team.test.d.ts +2 -0
- package/dist/team.test.d.ts.map +1 -0
- package/dist/team.test.js +163 -0
- package/dist/team.test.js.map +1 -0
- package/dist/web-ui.test.js +14 -1
- package/dist/web-ui.test.js.map +1 -1
- package/package.json +1 -1
- package/web/app.css +74 -16
- package/web/app.js +198 -38
- package/web/apple-touch-icon.png +0 -0
- package/web/favicon.png +0 -0
- package/web/index.html +18 -2
- package/web/logo.png +0 -0
- package/web/logo-mark.png +0 -0
package/web/app.js
CHANGED
|
@@ -36,7 +36,15 @@ async function api(path, options) {
|
|
|
36
36
|
...options,
|
|
37
37
|
});
|
|
38
38
|
const body = await res.json();
|
|
39
|
-
if (!res.ok)
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
// El cuerpo entero viaja con el error, no solo el mensaje: quien llama a
|
|
41
|
+
// veces necesita distinguir un tipo de fallo de otro (por ejemplo,
|
|
42
|
+
// "falta vincular cuenta" de "cuota agotada"), y con solo el texto no
|
|
43
|
+
// hay forma fiable de hacerlo sin analizar frases.
|
|
44
|
+
const error = new Error(body.error || `Error ${res.status}`);
|
|
45
|
+
error.body = body;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
40
48
|
return body;
|
|
41
49
|
}
|
|
42
50
|
|
|
@@ -937,38 +945,55 @@ function wireProjects() {
|
|
|
937
945
|
document.querySelectorAll(".share-btn").forEach((btn) => {
|
|
938
946
|
btn.addEventListener("click", async () => {
|
|
939
947
|
const card = btn.closest(".pcard");
|
|
948
|
+
const projectId = card.dataset.project;
|
|
940
949
|
const out = card.querySelector(".share-out");
|
|
941
950
|
btn.disabled = true;
|
|
942
951
|
try {
|
|
952
|
+
// El token ya vive en la base de datos local (una fila por proyecto,
|
|
953
|
+
// se lee de state.projects.publications): guardarlo también en
|
|
954
|
+
// localStorage sería una segunda copia que puede desincronizarse de
|
|
955
|
+
// la real, y lo hizo — un token de una prueba vieja en el navegador
|
|
956
|
+
// pisó el que de verdad estaba publicado.
|
|
957
|
+
const known = state.projects?.publications?.find((p) => p.projectId === projectId);
|
|
943
958
|
const r = await api("/api/publish", {
|
|
944
959
|
method: "POST",
|
|
945
|
-
body: JSON.stringify({
|
|
946
|
-
projectId: card.dataset.project,
|
|
947
|
-
baseUrl: localStorage.getItem("estela.baseUrl") || "",
|
|
948
|
-
token: localStorage.getItem("estela.token." + card.dataset.project) || "",
|
|
949
|
-
}),
|
|
960
|
+
body: JSON.stringify({ projectId, token: known?.token || "" }),
|
|
950
961
|
});
|
|
951
|
-
// Guardar el token: republicar con otro le rompería el enlace al cliente.
|
|
952
|
-
localStorage.setItem("estela.token." + card.dataset.project, r.token);
|
|
953
962
|
|
|
954
963
|
out.hidden = false;
|
|
955
964
|
out.innerHTML =
|
|
956
|
-
'<p class="share-line"><b>
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
965
|
+
'<p class="share-line"><b>' + (r.adopted ? "Panel vinculado" : "Panel publicado") +
|
|
966
|
+
'</b></p>' +
|
|
967
|
+
'<p class="share-line">Enlace: <code>' + esc(r.url) + '</code></p>' +
|
|
968
|
+
'<p class="share-warn">Quien tenga el enlace, entra: no hay contraseña.</p>';
|
|
969
|
+
toast(r.adopted ? "Vinculado a tu cuenta" : "Panel publicado");
|
|
970
|
+
|
|
971
|
+
// Publicar pone a cero los "bloques nuevos desde que lo publicaste",
|
|
972
|
+
// pero ese número vive en `state`, que se quedaba con el valor de
|
|
973
|
+
// antes: el aviso seguía ahí después de resolverlo, y solo se iba
|
|
974
|
+
// recargando la página a mano. Se vuelve a pedir el estado y se
|
|
975
|
+
// repinta, conservando el mensaje del enlace recién generado.
|
|
976
|
+
const recien = out.innerHTML;
|
|
977
|
+
await loadProjects();
|
|
978
|
+
const card2 = document.querySelector(`.pcard[data-project="${CSS.escape(projectId)}"]`);
|
|
979
|
+
const out3 = card2?.querySelector(".share-out");
|
|
980
|
+
if (out3) { out3.hidden = false; out3.innerHTML = recien; }
|
|
981
|
+
} catch (error) {
|
|
982
|
+
const out2 = card.querySelector(".share-out");
|
|
983
|
+
// "Sin cuenta" no es un error cualquiera: se enseña con el comando
|
|
984
|
+
// exacto para resolverlo, no como un aviso genérico que hay que
|
|
985
|
+
// interpretar. `needsLogin` viaja en el cuerpo desde /api/publish.
|
|
986
|
+
if (error.body?.needsLogin) {
|
|
987
|
+
out2.hidden = false;
|
|
988
|
+
out2.innerHTML =
|
|
989
|
+
'<p class="share-line"><b>Hace falta vincular una cuenta</b></p>' +
|
|
990
|
+
'<p class="share-line">Publicar aloja el panel en getestela.dev, y eso exige sesión:</p>' +
|
|
991
|
+
'<code class="cmd">estela login --email tu@correo.com</code>' +
|
|
992
|
+
'<p class="share-warn">Se ejecuta una vez, en la terminal. Luego vuelve a pulsar aquí.</p>';
|
|
993
|
+
} else {
|
|
994
|
+
toast(error.message);
|
|
995
|
+
}
|
|
996
|
+
} finally { btn.disabled = false; }
|
|
972
997
|
});
|
|
973
998
|
});
|
|
974
999
|
|
|
@@ -996,11 +1021,24 @@ function wireProjects() {
|
|
|
996
1021
|
|
|
997
1022
|
// ── Informes ───────────────────────────────────────────────────────────
|
|
998
1023
|
|
|
1024
|
+
/* Plegado por defecto: fecha y botón de descarga solo aparecen al pulsar el
|
|
1025
|
+
proyecto, igual que un bloque de Mi día. Con muchos proyectos, la lista
|
|
1026
|
+
entera desplegada era una columna larga sin forma de encontrar nada. */
|
|
1027
|
+
const REPORT_ROWS = 5;
|
|
1028
|
+
state.reportsQuery = "";
|
|
1029
|
+
state.reportsAll = false;
|
|
1030
|
+
state.reportsOpen = new Set();
|
|
1031
|
+
|
|
999
1032
|
async function loadReports() {
|
|
1000
1033
|
// Mismo periodo que Resumen: si las dos pantallas contestan a la misma
|
|
1001
1034
|
// pregunta, tienen que dar el mismo número.
|
|
1002
1035
|
const { from, to } = periodRange(state.period);
|
|
1003
|
-
|
|
1036
|
+
state.reportsOverview = await api(`/api/overview?from=${from}&to=${to}`);
|
|
1037
|
+
renderReports();
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function renderReports() {
|
|
1041
|
+
const overview = state.reportsOverview;
|
|
1004
1042
|
const parts = [];
|
|
1005
1043
|
|
|
1006
1044
|
parts.push(`<div class="filters" id="rep-periods">
|
|
@@ -1026,19 +1064,42 @@ async function loadReports() {
|
|
|
1026
1064
|
<code>estela status</code>
|
|
1027
1065
|
</div>`);
|
|
1028
1066
|
} else {
|
|
1029
|
-
|
|
1067
|
+
// Filtra en el propio navegador: ya se tiene la lista entera cargada, y
|
|
1068
|
+
// un buscador que hiciera una petición por letra tecleada sería más lento
|
|
1069
|
+
// que no tenerlo.
|
|
1070
|
+
const q = state.reportsQuery.trim().toLowerCase();
|
|
1071
|
+
const matches = !q ? overview.unbilled : overview.unbilled.filter((row) =>
|
|
1072
|
+
row.projectName.toLowerCase().includes(q) || row.clientName.toLowerCase().includes(q));
|
|
1073
|
+
|
|
1074
|
+
if (overview.unbilled.length > REPORT_ROWS || q) {
|
|
1075
|
+
parts.push(`<input type="search" class="search" id="rep-search"
|
|
1076
|
+
placeholder="Buscar proyecto o cliente…" value="${esc(state.reportsQuery)}">`);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (q && matches.length === 0) {
|
|
1080
|
+
parts.push(`<p class="note">Ningún proyecto coincide con «${esc(state.reportsQuery)}».</p>`);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const shown = state.reportsAll ? matches : matches.slice(0, REPORT_ROWS);
|
|
1084
|
+
|
|
1085
|
+
for (const row of shown) {
|
|
1030
1086
|
const amount = fmtMoney(row.amountMinor, row.currency);
|
|
1087
|
+
const open = state.reportsOpen.has(row.projectId);
|
|
1031
1088
|
parts.push(`
|
|
1032
|
-
<div class="report">
|
|
1033
|
-
<
|
|
1034
|
-
<div class="report-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
<div class="report-
|
|
1040
|
-
|
|
1041
|
-
|
|
1089
|
+
<div class="report${open ? " is-open" : ""}">
|
|
1090
|
+
<button type="button" class="report-head" data-toggle-report="${esc(row.projectId)}" aria-expanded="${open}">
|
|
1091
|
+
<div class="report-text">
|
|
1092
|
+
<div class="report-name">${esc(row.projectName)}</div>
|
|
1093
|
+
<div class="report-client">${esc(row.clientName)} · ${row.blocks} bloques ·
|
|
1094
|
+
todo lo pendiente desde ${esc(row.firstDay)}</div>
|
|
1095
|
+
</div>
|
|
1096
|
+
<div class="report-figs">
|
|
1097
|
+
<div class="report-hours">${fmtDuration(row.seconds)}</div>
|
|
1098
|
+
<div class="report-amount">${amount || "—"}</div>
|
|
1099
|
+
</div>
|
|
1100
|
+
<span class="chev">›</span>
|
|
1101
|
+
</button>
|
|
1102
|
+
${open ? `
|
|
1042
1103
|
<form class="report-actions" data-project="${esc(row.projectId)}">
|
|
1043
1104
|
<label class="field"><span>Desde</span>
|
|
1044
1105
|
<input type="date" class="rep-from" value="${esc(row.firstDay)}"></label>
|
|
@@ -1049,9 +1110,17 @@ async function loadReports() {
|
|
|
1049
1110
|
<label class="checkline"><input type="checkbox" class="rep-amounts">
|
|
1050
1111
|
<span>Incluir importes</span></label>
|
|
1051
1112
|
<button type="submit" class="btn-main">Descargar informe</button>
|
|
1052
|
-
</form
|
|
1113
|
+
</form>` : ""}
|
|
1053
1114
|
</div>`);
|
|
1054
1115
|
}
|
|
1116
|
+
|
|
1117
|
+
if (!state.reportsAll && matches.length > REPORT_ROWS) {
|
|
1118
|
+
parts.push(`<button type="button" class="more" id="reports-more">
|
|
1119
|
+
Ver ${matches.length - REPORT_ROWS} proyecto${matches.length - REPORT_ROWS === 1 ? "" : "s"} más</button>`);
|
|
1120
|
+
} else if (state.reportsAll && matches.length > REPORT_ROWS) {
|
|
1121
|
+
parts.push(`<button type="button" class="more" id="reports-more">Ver menos</button>`);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1055
1124
|
parts.push(`<p class="note">El informe recoge horas y commits. No es una factura:
|
|
1056
1125
|
Estela no emite documentos fiscales, adjunta este respaldo a la tuya.</p>`);
|
|
1057
1126
|
}
|
|
@@ -1059,7 +1128,35 @@ async function loadReports() {
|
|
|
1059
1128
|
$("#reports-body").innerHTML = parts.join("");
|
|
1060
1129
|
|
|
1061
1130
|
document.querySelectorAll("#rep-periods .period").forEach((btn) => {
|
|
1062
|
-
btn.addEventListener("click", () => {
|
|
1131
|
+
btn.addEventListener("click", () => {
|
|
1132
|
+
state.period = btn.dataset.period; state.reportsAll = false; loadReports();
|
|
1133
|
+
});
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
const search = $("#rep-search");
|
|
1137
|
+
search?.addEventListener("input", () => {
|
|
1138
|
+
state.reportsQuery = search.value;
|
|
1139
|
+
state.reportsAll = false;
|
|
1140
|
+
renderReports();
|
|
1141
|
+
// renderReports() reconstruye el input entero: sin esto, el foco salta
|
|
1142
|
+
// fuera de la caja de búsqueda con cada letra que se escribe.
|
|
1143
|
+
const again = $("#rep-search");
|
|
1144
|
+
again.focus();
|
|
1145
|
+
again.setSelectionRange(again.value.length, again.value.length);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
$("#reports-more")?.addEventListener("click", () => {
|
|
1149
|
+
state.reportsAll = !state.reportsAll;
|
|
1150
|
+
renderReports();
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
document.querySelectorAll("[data-toggle-report]").forEach((btn) => {
|
|
1154
|
+
btn.addEventListener("click", () => {
|
|
1155
|
+
const id = btn.dataset.toggleReport;
|
|
1156
|
+
if (state.reportsOpen.has(id)) state.reportsOpen.delete(id);
|
|
1157
|
+
else state.reportsOpen.add(id);
|
|
1158
|
+
renderReports();
|
|
1159
|
+
});
|
|
1063
1160
|
});
|
|
1064
1161
|
|
|
1065
1162
|
document.querySelectorAll(".report-actions").forEach((form) => {
|
|
@@ -1239,11 +1336,74 @@ document.addEventListener("keydown", (e) => {
|
|
|
1239
1336
|
if (e.key === "ArrowRight" && !$("#next-day").disabled) moveDay(1);
|
|
1240
1337
|
});
|
|
1241
1338
|
|
|
1339
|
+
// ── Upgrade y banner de ofertas ───────────────────────────────────────
|
|
1340
|
+
|
|
1341
|
+
async function loadAccountUpgrade() {
|
|
1342
|
+
try {
|
|
1343
|
+
const account = await api("/api/account");
|
|
1344
|
+
$("#upgrade-wrap").hidden = account.plan === "pro" || account.plan === "teams";
|
|
1345
|
+
} catch {
|
|
1346
|
+
// Si no se puede saber el plan, mejor no ofrecer un botón que no va a
|
|
1347
|
+
// completar la compra.
|
|
1348
|
+
$("#upgrade-wrap").hidden = true;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
$("#upgrade-btn").addEventListener("click", () => {
|
|
1353
|
+
$("#upgrade-menu").hidden = !$("#upgrade-menu").hidden;
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
document.querySelectorAll(".upgrade-option").forEach((btn) => {
|
|
1357
|
+
btn.addEventListener("click", async () => {
|
|
1358
|
+
btn.disabled = true;
|
|
1359
|
+
try {
|
|
1360
|
+
const { url } = await api("/api/billing/checkout", {
|
|
1361
|
+
method: "POST", body: JSON.stringify({ plan: btn.dataset.plan }),
|
|
1362
|
+
});
|
|
1363
|
+
window.open(url, "_blank", "noopener");
|
|
1364
|
+
$("#upgrade-menu").hidden = true;
|
|
1365
|
+
} catch (error) {
|
|
1366
|
+
// needsLogin viaja en el cuerpo desde /api/billing/checkout, mismo
|
|
1367
|
+
// criterio que ya usa /api/publish para distinguir "hace falta
|
|
1368
|
+
// vincular cuenta" de un error genérico.
|
|
1369
|
+
toast(error.body?.needsLogin
|
|
1370
|
+
? "Vincula tu cuenta primero: estela login --email tu@correo.com"
|
|
1371
|
+
: error.message);
|
|
1372
|
+
} finally {
|
|
1373
|
+
btn.disabled = false;
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
async function loadBanner() {
|
|
1379
|
+
try {
|
|
1380
|
+
const banner = await api("/api/banner");
|
|
1381
|
+
if (!banner.enabled || !banner.text) return;
|
|
1382
|
+
// Recordado por texto, no por un id: si el admin cambia el mensaje,
|
|
1383
|
+
// vuelve a aparecer aunque el anterior ya se hubiera cerrado.
|
|
1384
|
+
if (localStorage.getItem("estela.bannerDismissed") === banner.text) return;
|
|
1385
|
+
|
|
1386
|
+
$("#banner-text").textContent = banner.text;
|
|
1387
|
+
const link = $("#banner-link");
|
|
1388
|
+
if (banner.link) { link.href = banner.link; link.hidden = false; } else { link.hidden = true; }
|
|
1389
|
+
$("#banner-promo").hidden = false;
|
|
1390
|
+
} catch {
|
|
1391
|
+
// Sin conexión, o getestela.dev caído: el banner simplemente no sale.
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
$("#banner-close").addEventListener("click", () => {
|
|
1396
|
+
localStorage.setItem("estela.bannerDismissed", $("#banner-text").textContent);
|
|
1397
|
+
$("#banner-promo").hidden = true;
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1242
1400
|
// ── Arranque ───────────────────────────────────────────────────────────
|
|
1243
1401
|
|
|
1244
1402
|
/* Arranca en el resumen: la primera pregunta al abrir es "¿cómo va todo?",
|
|
1245
1403
|
no "¿qué hice hoy?". El día concreto está a un clic. */
|
|
1246
1404
|
(async function boot() {
|
|
1405
|
+
loadAccountUpgrade();
|
|
1406
|
+
loadBanner();
|
|
1247
1407
|
await loadSummary();
|
|
1248
1408
|
|
|
1249
1409
|
/* Si hoy no tiene actividad, "Mi día" apunta al último día con trabajo:
|
package/web/apple-touch-icon.png
CHANGED
|
Binary file
|
package/web/favicon.png
CHANGED
|
Binary file
|
package/web/index.html
CHANGED
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
<header class="top">
|
|
14
14
|
<div class="brand">
|
|
15
|
-
<img class="
|
|
16
|
-
Estela
|
|
15
|
+
<img class="brand-logo" src="/logo.png" alt="getestela.dev">
|
|
17
16
|
</div>
|
|
18
17
|
<nav class="tabs" role="tablist">
|
|
19
18
|
<button type="button" class="tab is-on" data-view="summary" role="tab">Resumen</button>
|
|
@@ -23,6 +22,17 @@
|
|
|
23
22
|
<button type="button" class="tab" data-view="projects" role="tab">Proyectos</button>
|
|
24
23
|
</nav>
|
|
25
24
|
<div class="top-right" id="top-right">
|
|
25
|
+
<div class="upgrade-wrap" id="upgrade-wrap" hidden>
|
|
26
|
+
<button type="button" class="btn-main btn-upgrade" id="upgrade-btn">Actualizar a Pro o Teams</button>
|
|
27
|
+
<div class="upgrade-menu" id="upgrade-menu" hidden>
|
|
28
|
+
<button type="button" class="upgrade-option" data-plan="pro">
|
|
29
|
+
<b>Pro</b><span>9€/mes · paneles y sync ilimitados</span>
|
|
30
|
+
</button>
|
|
31
|
+
<button type="button" class="upgrade-option" data-plan="teams">
|
|
32
|
+
<b>Teams</b><span>15€/persona/mes · horas de equipo medidas</span>
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
26
36
|
<span class="sync-warn" id="sync-warn" hidden></span>
|
|
27
37
|
<button type="button" class="sync" id="sync" title="Buscar trabajo nuevo (R)">
|
|
28
38
|
<span class="sync-dot" aria-hidden="true"></span>
|
|
@@ -31,6 +41,12 @@
|
|
|
31
41
|
</div>
|
|
32
42
|
</header>
|
|
33
43
|
|
|
44
|
+
<div class="banner-promo" id="banner-promo" hidden>
|
|
45
|
+
<span id="banner-text"></span>
|
|
46
|
+
<a id="banner-link" href="#" target="_blank" rel="noopener" hidden>Ver más →</a>
|
|
47
|
+
<button type="button" id="banner-close" aria-label="Cerrar">×</button>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
34
50
|
<main>
|
|
35
51
|
<!-- ══════════════ EQUIPO — quién trabaja en qué ══════════════ -->
|
|
36
52
|
<section class="view" id="view-team">
|
package/web/logo.png
ADDED
|
Binary file
|
package/web/logo-mark.png
DELETED
|
Binary file
|