@kaikybrofc/omnizap-system 2.2.3 → 2.2.4

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.
@@ -161,6 +161,23 @@ const formatVisibilityLabel = (visibility) => {
161
161
  return '🔒 Privado';
162
162
  };
163
163
 
164
+ /**
165
+ * Detecta packs automáticos para ocultar em listagens do usuário.
166
+ *
167
+ * @param {object|null|undefined} pack Pack retornado pelo serviço.
168
+ * @returns {boolean} Verdadeiro quando o pack é automático.
169
+ */
170
+ const isAutomaticPack = (pack) => {
171
+ if (!pack || typeof pack !== 'object') return false;
172
+ if (pack.is_auto_pack === true || Number(pack.is_auto_pack || 0) === 1) return true;
173
+
174
+ const name = String(pack.name || '').trim();
175
+ if (/^\[auto\]/i.test(name)) return true;
176
+
177
+ const description = String(pack.description || '').toLowerCase();
178
+ return description.includes('[auto-theme:') || description.includes('[auto-tag:');
179
+ };
180
+
164
181
  /**
165
182
  * Monta mensagem visual padronizada para comandos de pack.
166
183
  *
@@ -683,13 +700,14 @@ export async function handlePackCommand({
683
700
 
684
701
  case 'list': {
685
702
  const packs = await stickerPackService.listPacks({ ownerJid, limit: 100 });
703
+ const manualPacks = packs.filter((pack) => !isAutomaticPack(pack));
686
704
 
687
705
  await sendReply({
688
706
  sock,
689
707
  remoteJid,
690
708
  messageInfo,
691
709
  expirationMessage,
692
- text: formatPackList(packs, commandPrefix),
710
+ text: formatPackList(manualPacks, commandPrefix),
693
711
  });
694
712
  return;
695
713
  }
@@ -344,7 +344,10 @@ export const extractSenderInfoFromMessage = (msg) => {
344
344
  } else {
345
345
  if (isWhatsAppJid(remoteJid)) jid = remoteJid;
346
346
  if (!jid && isWhatsAppJid(participant)) jid = participant;
347
+ if (!jid && isWhatsAppJid(participantAlt)) jid = participantAlt;
347
348
  if (isLidJid(participant)) lid = participant;
349
+ if (!lid && isLidJid(remoteJid)) lid = remoteJid;
350
+ if (!lid && isLidJid(participantAlt)) lid = participantAlt;
348
351
  }
349
352
 
350
353
  return { lid, jid, participantAlt, remoteJid, groupMessage };
@@ -541,7 +544,7 @@ export const extractUserIdInfo = (value) => {
541
544
  const participantAlt = typeof value.participantAlt === 'string' ? value.participantAlt : null;
542
545
  const participant = typeof value.participant === 'string' ? value.participant : null;
543
546
  const jidCandidate = value.jid || value.id || participantAlt || participant || null;
544
- const lidCandidate = value.lid || participant || null;
547
+ const lidCandidate = value.lid || participant || participantAlt || value.id || value.jid || null;
545
548
 
546
549
  return {
547
550
  lid: pickLid(lidCandidate, participantAlt, participant),
@@ -0,0 +1,232 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { URL } from 'node:url';
3
+ import { getJidServer, getJidUser, normalizeJid } from '../config/baileysConfig.js';
4
+
5
+ const WHATSAPP_USER_SERVERS = new Set(['s.whatsapp.net', 'c.us', 'hosted']);
6
+ const DEFAULT_LOGIN_BASE_URL = 'https://omnizap.shop';
7
+ const SIGNING_SECRET = String(process.env.WHATSAPP_LOGIN_LINK_SECRET || '').trim();
8
+ const SIGNED_LINKS_ENABLED = Boolean(SIGNING_SECRET);
9
+ const REQUIRE_SIGNATURE = parseEnvBool(process.env.WHATSAPP_LOGIN_REQUIRE_SIGNATURE, SIGNED_LINKS_ENABLED);
10
+ const LOGIN_TTL_SECONDS = Math.max(60, Number(process.env.WHATSAPP_LOGIN_LINK_TTL_SECONDS) || 15 * 60);
11
+ const LOGIN_PATH = normalizeLoginPath(process.env.WHATSAPP_LOGIN_PATH || '/login/');
12
+
13
+ function parseEnvBool(value, fallback) {
14
+ if (value === undefined || value === null || value === '') return fallback;
15
+ const normalized = String(value).trim().toLowerCase();
16
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
17
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
18
+ return fallback;
19
+ }
20
+
21
+ function normalizePhoneDigits(value) {
22
+ return String(value || '').replace(/\D+/g, '');
23
+ }
24
+
25
+ function normalizeLoginPath(value) {
26
+ const raw = String(value || '').trim();
27
+ if (!raw) return '/login/';
28
+ const withLeadingSlash = raw.startsWith('/') ? raw : `/${raw}`;
29
+ return withLeadingSlash.endsWith('/') ? withLeadingSlash : `${withLeadingSlash}/`;
30
+ }
31
+
32
+ function sanitizeTimestamp(value) {
33
+ const parsed = Number(value);
34
+ if (!Number.isFinite(parsed) || parsed <= 0) return 0;
35
+ return Math.floor(parsed);
36
+ }
37
+
38
+ function buildSignaturePayload(phoneDigits, tsSeconds) {
39
+ return `${phoneDigits}.${tsSeconds}`;
40
+ }
41
+
42
+ function buildHintSignature(phoneDigits, tsSeconds) {
43
+ if (!SIGNED_LINKS_ENABLED) return '';
44
+ return createHmac('sha256', SIGNING_SECRET).update(buildSignaturePayload(phoneDigits, tsSeconds)).digest('hex');
45
+ }
46
+
47
+ function safeHexCompare(left, right) {
48
+ const leftHex = String(left || '').trim().toLowerCase();
49
+ const rightHex = String(right || '').trim().toLowerCase();
50
+ if (!leftHex || !rightHex || leftHex.length !== rightHex.length) return false;
51
+
52
+ try {
53
+ const leftBuffer = Buffer.from(leftHex, 'hex');
54
+ const rightBuffer = Buffer.from(rightHex, 'hex');
55
+ if (!leftBuffer.length || leftBuffer.length !== rightBuffer.length) return false;
56
+ return timingSafeEqual(leftBuffer, rightBuffer);
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ function resolveLoginBaseUrl(explicitBaseUrl = '') {
63
+ const candidates = [
64
+ explicitBaseUrl,
65
+ process.env.WHATSAPP_LOGIN_BASE_URL,
66
+ process.env.SITE_ORIGIN,
67
+ process.env.PUBLIC_WEB_BASE_URL,
68
+ DEFAULT_LOGIN_BASE_URL,
69
+ ];
70
+
71
+ for (const candidate of candidates) {
72
+ const raw = String(candidate || '').trim();
73
+ if (!raw) continue;
74
+ try {
75
+ const url = new URL(raw);
76
+ return `${url.origin}`;
77
+ } catch (error) {
78
+ void error;
79
+ }
80
+ }
81
+
82
+ return DEFAULT_LOGIN_BASE_URL;
83
+ }
84
+
85
+ export const toWhatsAppPhoneDigits = (value) => {
86
+ const raw = String(value || '').trim();
87
+ if (!raw) return '';
88
+
89
+ if (raw.includes('@')) {
90
+ const normalizedJid = normalizeJid(raw);
91
+ const server = getJidServer(normalizedJid);
92
+ if (!WHATSAPP_USER_SERVERS.has(server)) return '';
93
+ const jidUser = String(getJidUser(normalizedJid) || '').split(':')[0];
94
+ const digits = normalizePhoneDigits(jidUser);
95
+ return digits.length >= 10 && digits.length <= 15 ? digits : '';
96
+ }
97
+
98
+ const digits = normalizePhoneDigits(raw);
99
+ return digits.length >= 10 && digits.length <= 15 ? digits : '';
100
+ };
101
+
102
+ export const toWhatsAppOwnerJid = (value) => {
103
+ const digits = toWhatsAppPhoneDigits(value);
104
+ if (!digits) return '';
105
+ return normalizeJid(`${digits}@s.whatsapp.net`) || '';
106
+ };
107
+
108
+ export const buildWhatsAppLoginHint = (value, { nowMs = Date.now() } = {}) => {
109
+ const phoneDigits = toWhatsAppPhoneDigits(value);
110
+ if (!phoneDigits) return null;
111
+
112
+ const tsSeconds = Math.floor(nowMs / 1000);
113
+ const hint = {
114
+ wa: phoneDigits,
115
+ wa_ts: String(tsSeconds),
116
+ };
117
+
118
+ const signature = buildHintSignature(phoneDigits, tsSeconds);
119
+ if (signature) hint.wa_sig = signature;
120
+
121
+ return hint;
122
+ };
123
+
124
+ export const buildWhatsAppGoogleLoginUrl = ({ userId, baseUrl } = {}) => {
125
+ const hint = buildWhatsAppLoginHint(userId);
126
+ if (!hint) return '';
127
+
128
+ const root = resolveLoginBaseUrl(baseUrl);
129
+ const url = new URL(LOGIN_PATH, root);
130
+ url.searchParams.set('wa', hint.wa);
131
+ url.searchParams.set('wa_ts', hint.wa_ts);
132
+ if (hint.wa_sig) url.searchParams.set('wa_sig', hint.wa_sig);
133
+ return url.toString();
134
+ };
135
+
136
+ export const extractWhatsAppLoginHint = (payload = {}) => {
137
+ const source = payload && typeof payload === 'object' ? payload : {};
138
+ const nested = source.whatsapp_login && typeof source.whatsapp_login === 'object' ? source.whatsapp_login : {};
139
+ return {
140
+ wa: String(source.wa ?? source.whatsapp_phone ?? source.owner_phone ?? nested.wa ?? nested.phone ?? '').trim(),
141
+ wa_ts: String(source.wa_ts ?? source.whatsapp_ts ?? source.owner_phone_ts ?? nested.wa_ts ?? nested.ts ?? '').trim(),
142
+ wa_sig: String(source.wa_sig ?? source.whatsapp_sig ?? source.owner_phone_sig ?? nested.wa_sig ?? nested.sig ?? '').trim(),
143
+ };
144
+ };
145
+
146
+ export const resolveWhatsAppOwnerJidFromLoginPayload = (payload, { nowMs = Date.now() } = {}) => {
147
+ const hint = extractWhatsAppLoginHint(payload);
148
+ const hasPayload = Boolean(hint.wa || hint.wa_ts || hint.wa_sig);
149
+ if (!hasPayload) {
150
+ return {
151
+ hasPayload: false,
152
+ ownerJid: '',
153
+ verified: false,
154
+ signed: false,
155
+ reason: '',
156
+ };
157
+ }
158
+
159
+ const phoneDigits = toWhatsAppPhoneDigits(hint.wa);
160
+ const ownerJid = toWhatsAppOwnerJid(phoneDigits);
161
+ if (!ownerJid) {
162
+ return {
163
+ hasPayload: true,
164
+ ownerJid: '',
165
+ verified: false,
166
+ signed: false,
167
+ reason: 'invalid_phone',
168
+ };
169
+ }
170
+
171
+ if (!SIGNED_LINKS_ENABLED) {
172
+ return {
173
+ hasPayload: true,
174
+ ownerJid,
175
+ verified: false,
176
+ signed: false,
177
+ reason: '',
178
+ };
179
+ }
180
+
181
+ const tsSeconds = sanitizeTimestamp(hint.wa_ts);
182
+ const hasSignature = Boolean(hint.wa_sig);
183
+
184
+ if (!tsSeconds || !hasSignature) {
185
+ if (REQUIRE_SIGNATURE) {
186
+ return {
187
+ hasPayload: true,
188
+ ownerJid: '',
189
+ verified: false,
190
+ signed: false,
191
+ reason: 'missing_signature',
192
+ };
193
+ }
194
+ return {
195
+ hasPayload: true,
196
+ ownerJid,
197
+ verified: false,
198
+ signed: false,
199
+ reason: '',
200
+ };
201
+ }
202
+
203
+ const nowSeconds = Math.floor(nowMs / 1000);
204
+ if (Math.abs(nowSeconds - tsSeconds) > LOGIN_TTL_SECONDS) {
205
+ return {
206
+ hasPayload: true,
207
+ ownerJid: '',
208
+ verified: false,
209
+ signed: true,
210
+ reason: 'expired',
211
+ };
212
+ }
213
+
214
+ const expectedSignature = buildHintSignature(phoneDigits, tsSeconds);
215
+ if (!safeHexCompare(expectedSignature, hint.wa_sig)) {
216
+ return {
217
+ hasPayload: true,
218
+ ownerJid: '',
219
+ verified: false,
220
+ signed: true,
221
+ reason: 'invalid_signature',
222
+ };
223
+ }
224
+
225
+ return {
226
+ hasPayload: true,
227
+ ownerJid,
228
+ verified: true,
229
+ signed: true,
230
+ reason: '',
231
+ };
232
+ };
@@ -0,0 +1,83 @@
1
+ SET @db_name = DATABASE();
2
+
3
+ SET @has_user_owner_phone := (
4
+ SELECT COUNT(*)
5
+ FROM INFORMATION_SCHEMA.COLUMNS
6
+ WHERE TABLE_SCHEMA = @db_name
7
+ AND TABLE_NAME = 'sticker_web_google_user'
8
+ AND COLUMN_NAME = 'owner_phone'
9
+ );
10
+ SET @sql_user_owner_phone := IF(
11
+ @has_user_owner_phone = 0,
12
+ 'ALTER TABLE sticker_web_google_user ADD COLUMN owner_phone VARCHAR(20) NULL AFTER owner_jid',
13
+ 'SELECT 1'
14
+ );
15
+ PREPARE stmt_user_owner_phone FROM @sql_user_owner_phone;
16
+ EXECUTE stmt_user_owner_phone;
17
+ DEALLOCATE PREPARE stmt_user_owner_phone;
18
+
19
+ SET @has_user_owner_phone_idx := (
20
+ SELECT COUNT(*)
21
+ FROM INFORMATION_SCHEMA.STATISTICS
22
+ WHERE TABLE_SCHEMA = @db_name
23
+ AND TABLE_NAME = 'sticker_web_google_user'
24
+ AND INDEX_NAME = 'idx_sticker_web_google_user_owner_phone'
25
+ );
26
+ SET @sql_user_owner_phone_idx := IF(
27
+ @has_user_owner_phone_idx = 0,
28
+ 'ALTER TABLE sticker_web_google_user ADD INDEX idx_sticker_web_google_user_owner_phone (owner_phone)',
29
+ 'SELECT 1'
30
+ );
31
+ PREPARE stmt_user_owner_phone_idx FROM @sql_user_owner_phone_idx;
32
+ EXECUTE stmt_user_owner_phone_idx;
33
+ DEALLOCATE PREPARE stmt_user_owner_phone_idx;
34
+
35
+ SET @has_session_owner_phone := (
36
+ SELECT COUNT(*)
37
+ FROM INFORMATION_SCHEMA.COLUMNS
38
+ WHERE TABLE_SCHEMA = @db_name
39
+ AND TABLE_NAME = 'sticker_web_google_session'
40
+ AND COLUMN_NAME = 'owner_phone'
41
+ );
42
+ SET @sql_session_owner_phone := IF(
43
+ @has_session_owner_phone = 0,
44
+ 'ALTER TABLE sticker_web_google_session ADD COLUMN owner_phone VARCHAR(20) NULL AFTER owner_jid',
45
+ 'SELECT 1'
46
+ );
47
+ PREPARE stmt_session_owner_phone FROM @sql_session_owner_phone;
48
+ EXECUTE stmt_session_owner_phone;
49
+ DEALLOCATE PREPARE stmt_session_owner_phone;
50
+
51
+ SET @has_session_owner_phone_idx := (
52
+ SELECT COUNT(*)
53
+ FROM INFORMATION_SCHEMA.STATISTICS
54
+ WHERE TABLE_SCHEMA = @db_name
55
+ AND TABLE_NAME = 'sticker_web_google_session'
56
+ AND INDEX_NAME = 'idx_sticker_web_google_session_owner_phone'
57
+ );
58
+ SET @sql_session_owner_phone_idx := IF(
59
+ @has_session_owner_phone_idx = 0,
60
+ 'ALTER TABLE sticker_web_google_session ADD INDEX idx_sticker_web_google_session_owner_phone (owner_phone)',
61
+ 'SELECT 1'
62
+ );
63
+ PREPARE stmt_session_owner_phone_idx FROM @sql_session_owner_phone_idx;
64
+ EXECUTE stmt_session_owner_phone_idx;
65
+ DEALLOCATE PREPARE stmt_session_owner_phone_idx;
66
+
67
+ UPDATE sticker_web_google_user
68
+ SET owner_phone = CASE
69
+ WHEN SUBSTRING_INDEX(SUBSTRING_INDEX(owner_jid, '@', 1), ':', 1) REGEXP '^[0-9]{10,20}$'
70
+ THEN SUBSTRING_INDEX(SUBSTRING_INDEX(owner_jid, '@', 1), ':', 1)
71
+ ELSE NULL
72
+ END
73
+ WHERE (owner_phone IS NULL OR owner_phone = '')
74
+ AND owner_jid IS NOT NULL;
75
+
76
+ UPDATE sticker_web_google_session
77
+ SET owner_phone = CASE
78
+ WHEN SUBSTRING_INDEX(SUBSTRING_INDEX(owner_jid, '@', 1), ':', 1) REGEXP '^[0-9]{10,20}$'
79
+ THEN SUBSTRING_INDEX(SUBSTRING_INDEX(owner_jid, '@', 1), ':', 1)
80
+ ELSE NULL
81
+ END
82
+ WHERE (owner_phone IS NULL OR owner_phone = '')
83
+ AND owner_jid IS NOT NULL;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaikybrofc/omnizap-system",
3
- "version": "2.2.3",
3
+ "version": "2.2.4",
4
4
  "description": "Sistema profissional de automação WhatsApp com tecnologia Baileys",
5
5
  "main": "index.js",
6
6
  "publishConfig": {
package/public/index.html CHANGED
@@ -138,8 +138,63 @@
138
138
 
139
139
  .nav { display: flex; gap: 10px; flex-wrap: wrap; }
140
140
 
141
- .nav-toggle {
142
- display: none;
141
+ .nav-user-chip {
142
+ display: inline-flex;
143
+ align-items: center;
144
+ gap: 8px;
145
+ padding: 6px 10px 6px 6px;
146
+ max-width: 270px;
147
+ border-color: #3f5f8f;
148
+ background: linear-gradient(120deg, #132544d6, #10203ad6);
149
+ }
150
+
151
+ .nav-user-avatar-bubble {
152
+ display: inline-flex;
153
+ width: 30px;
154
+ height: 30px;
155
+ border-radius: 999px;
156
+ border: 1px solid #4a6fa4;
157
+ background: #132340;
158
+ box-shadow: 0 0 0 2px #1a2e4b, 0 0 16px #6aaaf236;
159
+ overflow: hidden;
160
+ flex: 0 0 auto;
161
+ }
162
+
163
+ .nav-user-photo {
164
+ width: 100%;
165
+ height: 100%;
166
+ object-fit: cover;
167
+ display: block;
168
+ }
169
+
170
+ .nav-user-name-bubble {
171
+ display: inline-flex;
172
+ align-items: center;
173
+ gap: 6px;
174
+ min-width: 0;
175
+ padding: 4px 8px;
176
+ border-radius: 999px;
177
+ border: 1px solid #34547f;
178
+ background: #12213ac9;
179
+ }
180
+
181
+ .nav-user-icon {
182
+ font-size: 12px;
183
+ color: #9bc6f5;
184
+ flex: 0 0 auto;
185
+ }
186
+
187
+ .nav-user-name {
188
+ font-size: 12px;
189
+ font-weight: 700;
190
+ color: #dbe8fb;
191
+ white-space: nowrap;
192
+ overflow: hidden;
193
+ text-overflow: ellipsis;
194
+ }
195
+
196
+ .nav-toggle {
197
+ display: none;
143
198
  width: 42px;
144
199
  height: 42px;
145
200
  border-radius: 10px;
@@ -178,12 +233,12 @@
178
233
 
179
234
  .btn:hover { transform: translateY(-1px); border-color: #3f567f; }
180
235
 
181
- .btn.primary {
182
- color: #042511;
183
- border-color: transparent;
184
- background: linear-gradient(90deg, var(--primary), #16a34a);
185
- box-shadow: 0 8px 22px #22c55e33;
186
- }
236
+ .btn.primary {
237
+ color: #042511;
238
+ border-color: transparent;
239
+ background: linear-gradient(90deg, var(--primary), #16a34a);
240
+ box-shadow: 0 8px 22px #22c55e33;
241
+ }
187
242
 
188
243
  @media (max-width: 860px) {
189
244
  .top-inner {
@@ -228,6 +283,38 @@
228
283
  border-radius: 10px;
229
284
  }
230
285
 
286
+ .nav-user-chip {
287
+ justify-content: flex-start;
288
+ }
289
+
290
+ body.home-authenticated #main-nav #nav-auth-link.nav-user-chip {
291
+ width: 44px;
292
+ min-width: 44px;
293
+ max-width: 44px;
294
+ height: 44px;
295
+ padding: 4px;
296
+ border-radius: 999px;
297
+ justify-content: center;
298
+ justify-self: center;
299
+ align-self: center;
300
+ background: linear-gradient(120deg, #132544f0, #10203af0);
301
+ }
302
+
303
+ body.home-authenticated #main-nav #nav-auth-link.nav-user-chip .nav-user-name-bubble {
304
+ display: none;
305
+ }
306
+
307
+ body.home-authenticated #main-nav #nav-auth-link.nav-user-chip .nav-user-avatar-bubble {
308
+ width: 34px;
309
+ height: 34px;
310
+ border: 1px solid #4a6fa4;
311
+ box-shadow: 0 0 0 1px #1a2e4b, 0 0 12px #6aaaf236;
312
+ }
313
+
314
+ body.home-authenticated #main-nav #nav-scheduler-link {
315
+ display: none;
316
+ }
317
+
231
318
  }
232
319
 
233
320
  @media (max-width: 420px) {
@@ -1057,7 +1144,8 @@
1057
1144
  >☰</button>
1058
1145
  </div>
1059
1146
  <nav id="main-nav" class="nav">
1060
- <a class="btn" href="/api-docs/"><i class="fa-solid fa-code icon-inline" aria-hidden="true"></i>API</a>
1147
+ <a id="nav-scheduler-link" class="btn" href="/api-docs/"><i class="fa-solid fa-code icon-inline" aria-hidden="true"></i>API</a>
1148
+ <a id="nav-auth-link" class="btn" href="/login/"><i class="fa-solid fa-right-to-bracket icon-inline" aria-hidden="true"></i>Login</a>
1061
1149
  <a class="btn" href="https://github.com/Kaikygr/omnizap-system" target="_blank" rel="noreferrer noopener"><i class="fa-brands fa-github icon-inline" aria-hidden="true"></i>GitHub</a>
1062
1150
  </nav>
1063
1151
  </div>
@@ -1072,6 +1160,8 @@
1072
1160
  <div class="hero-cta">
1073
1161
  <a class="btn primary" href="https://github.com/Kaikygr/omnizap-system" target="_blank" rel="noreferrer noopener"><i class="fa-brands fa-github icon-inline" aria-hidden="true"></i>Ver no GitHub</a>
1074
1162
  <a class="btn" href="/api-docs/"><i class="fa-solid fa-book icon-inline" aria-hidden="true"></i>Documentação da API</a>
1163
+ <a id="hero-login-cta" class="btn" href="/login/"><i class="fa-solid fa-right-to-bracket icon-inline" aria-hidden="true"></i>Login</a>
1164
+ <a class="btn" href="/stickers/"><i class="fa-solid fa-icons icon-inline" aria-hidden="true"></i>Ir para Stickers</a>
1075
1165
  <a class="btn" href="#infra-arquitetura"><i class="fa-solid fa-diagram-project icon-inline" aria-hidden="true"></i>Ver Arquitetura</a>
1076
1166
  </div>
1077
1167
  <section class="hero-proof">
@@ -1269,6 +1359,7 @@ curl -sS https://omnizap.shop/api/sticker-packs/system-summary | jq</code></pre>
1269
1359
  <div class="foot-col">
1270
1360
  <h4><i class="fa-solid fa-cube icon-inline" aria-hidden="true"></i>Produto</h4>
1271
1361
  <a href="/"><i class="fa-solid fa-house icon-inline" aria-hidden="true"></i>Home</a>
1362
+ <a href="/login/"><i class="fa-solid fa-right-to-bracket icon-inline" aria-hidden="true"></i>Login</a>
1272
1363
  <a href="/stickers/"><i class="fa-solid fa-icons icon-inline" aria-hidden="true"></i>Stickers</a>
1273
1364
  <a href="/stickers/create/"><i class="fa-solid fa-plus icon-inline" aria-hidden="true"></i>Criar Pack</a>
1274
1365
  <a href="/api-docs/"><i class="fa-solid fa-book icon-inline" aria-hidden="true"></i>API Docs</a>
@@ -1305,7 +1396,7 @@ curl -sS https://omnizap.shop/api/sticker-packs/system-summary | jq</code></pre>
1305
1396
  ><i class="fa-brands fa-whatsapp" aria-hidden="true"></i></a>
1306
1397
 
1307
1398
  <div id="home-react-root" hidden></div>
1308
- <script type="module" src="/js/apps/homeApp.js?v=20260227c"></script>
1399
+ <script type="module" src="/js/apps/homeApp.js?v=20260228-mobile-user-bubble2"></script>
1309
1400
  <script type="module" src="/js/github-panel/index.js?v=20260226a"></script>
1310
1401
  </body>
1311
1402
  </html>