@kaikybrofc/omnizap-system 2.1.8

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.
Files changed (166) hide show
  1. package/.env.example +534 -0
  2. package/LICENSE +21 -0
  3. package/README.md +431 -0
  4. package/RELEASE-v2.1.2.md +83 -0
  5. package/app/config/adminIdentity.js +87 -0
  6. package/app/config/baileysConfig.js +693 -0
  7. package/app/config/groupUtils.js +388 -0
  8. package/app/connection/socketController.js +992 -0
  9. package/app/controllers/messageController.js +354 -0
  10. package/app/modules/adminModule/groupCommandHandlers.js +1294 -0
  11. package/app/modules/adminModule/groupEventHandlers.js +355 -0
  12. package/app/modules/aiModule/catCommand.js +1006 -0
  13. package/app/modules/broadcastModule/noticeCommand.js +416 -0
  14. package/app/modules/gameModule/diceCommand.js +67 -0
  15. package/app/modules/menuModule/common.js +311 -0
  16. package/app/modules/menuModule/menus.js +59 -0
  17. package/app/modules/playModule/playCommand.js +1615 -0
  18. package/app/modules/quoteModule/quoteCommand.js +851 -0
  19. package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +786 -0
  20. package/app/modules/rpgPokemonModule/rpgBattleService.js +2082 -0
  21. package/app/modules/rpgPokemonModule/rpgBattleService.test.js +760 -0
  22. package/app/modules/rpgPokemonModule/rpgEvolutionUtils.js +22 -0
  23. package/app/modules/rpgPokemonModule/rpgPokemonCommand.js +172 -0
  24. package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +192 -0
  25. package/app/modules/rpgPokemonModule/rpgPokemonDomain.test.js +93 -0
  26. package/app/modules/rpgPokemonModule/rpgPokemonEvolution.test.js +46 -0
  27. package/app/modules/rpgPokemonModule/rpgPokemonMessages.js +746 -0
  28. package/app/modules/rpgPokemonModule/rpgPokemonRepository.js +1859 -0
  29. package/app/modules/rpgPokemonModule/rpgPokemonService.js +6738 -0
  30. package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +354 -0
  31. package/app/modules/statsModule/globalRankingCommand.js +65 -0
  32. package/app/modules/statsModule/noMessageCommand.js +288 -0
  33. package/app/modules/statsModule/rankingCommand.js +60 -0
  34. package/app/modules/statsModule/rankingCommon.js +889 -0
  35. package/app/modules/stickerModule/addStickerMetadata.js +239 -0
  36. package/app/modules/stickerModule/convertToWebp.js +390 -0
  37. package/app/modules/stickerModule/stickerCommand.js +454 -0
  38. package/app/modules/stickerModule/stickerConvertCommand.js +156 -0
  39. package/app/modules/stickerModule/stickerTextCommand.js +657 -0
  40. package/app/modules/stickerPackModule/autoPackCollectorRuntime.js +20 -0
  41. package/app/modules/stickerPackModule/autoPackCollectorService.js +284 -0
  42. package/app/modules/stickerPackModule/semanticReclassificationEngine.js +466 -0
  43. package/app/modules/stickerPackModule/semanticReclassificationEngine.test.js +88 -0
  44. package/app/modules/stickerPackModule/semanticThemeClusterService.js +571 -0
  45. package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +449 -0
  46. package/app/modules/stickerPackModule/stickerAssetRepository.js +400 -0
  47. package/app/modules/stickerPackModule/stickerAssetReprocessQueueRepository.js +180 -0
  48. package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +4078 -0
  49. package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +598 -0
  50. package/app/modules/stickerPackModule/stickerClassificationService.js +588 -0
  51. package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +102 -0
  52. package/app/modules/stickerPackModule/stickerPackCatalogHttp.js +7506 -0
  53. package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +1095 -0
  54. package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +108 -0
  55. package/app/modules/stickerPackModule/stickerPackErrors.js +30 -0
  56. package/app/modules/stickerPackModule/stickerPackInteractionEventRepository.js +110 -0
  57. package/app/modules/stickerPackModule/stickerPackItemRepository.js +440 -0
  58. package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +337 -0
  59. package/app/modules/stickerPackModule/stickerPackMessageService.js +296 -0
  60. package/app/modules/stickerPackModule/stickerPackRepository.js +442 -0
  61. package/app/modules/stickerPackModule/stickerPackService.js +788 -0
  62. package/app/modules/stickerPackModule/stickerPackServiceRuntime.js +51 -0
  63. package/app/modules/stickerPackModule/stickerPackUtils.js +97 -0
  64. package/app/modules/stickerPackModule/stickerStorageService.js +507 -0
  65. package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +233 -0
  66. package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +205 -0
  67. package/app/modules/systemMetricsModule/pingCommand.js +421 -0
  68. package/app/modules/tiktokModule/tiktokCommand.js +798 -0
  69. package/app/modules/userModule/userCommand.js +1217 -0
  70. package/app/modules/waifuPicsModule/waifuPicsCommand.js +177 -0
  71. package/app/observability/metrics.js +734 -0
  72. package/app/services/captchaService.js +492 -0
  73. package/app/services/dbWriteQueue.js +572 -0
  74. package/app/services/groupMetadataService.js +279 -0
  75. package/app/services/lidMapService.js +663 -0
  76. package/app/services/messagePersistenceService.js +56 -0
  77. package/app/services/newsBroadcastService.js +351 -0
  78. package/app/services/pokeApiService.js +398 -0
  79. package/app/services/queueUtils.js +57 -0
  80. package/app/services/socketState.js +7 -0
  81. package/app/store/aiPromptStore.js +38 -0
  82. package/app/store/groupConfigStore.js +58 -0
  83. package/app/store/premiumUserStore.js +36 -0
  84. package/app/utils/antiLink/antiLinkModule.js +804 -0
  85. package/app/utils/http/getImageBufferModule.js +18 -0
  86. package/app/utils/json/jsonSanitizer.js +113 -0
  87. package/app/utils/json/jsonSanitizer.test.js +40 -0
  88. package/app/utils/logger/loggerModule.js +262 -0
  89. package/app/utils/systemMetrics/systemMetricsModule.js +91 -0
  90. package/database/index.js +2052 -0
  91. package/database/init.js +516 -0
  92. package/database/migrations/20260203_0001_sticker_packs.sql +54 -0
  93. package/database/migrations/20260210_0003_rpg_pokemon.sql +58 -0
  94. package/database/migrations/20260210_0004_rpg_shiny_biome.sql +9 -0
  95. package/database/migrations/20260210_0005_rpg_missions.sql +14 -0
  96. package/database/migrations/20260210_0006_rpg_world_pokedex_traits.sql +27 -0
  97. package/database/migrations/20260210_0007_rpg_raid_pvp.sql +56 -0
  98. package/database/migrations/20260210_0008_rpg_social_system.sql +195 -0
  99. package/database/migrations/20260211_0009_rpg_social_xp.sql +36 -0
  100. package/database/migrations/20260222_0010_remove_message_xp.sql +2 -0
  101. package/database/migrations/20260226_0011_sticker_asset_classification.sql +17 -0
  102. package/database/migrations/20260226_0012_sticker_pack_engagement.sql +16 -0
  103. package/database/migrations/20260226_0013_sticker_marketplace_intelligence.sql +19 -0
  104. package/database/migrations/20260226_0014_sticker_pack_publish_flow.sql +30 -0
  105. package/database/migrations/20260226_0014_sticker_worker_queues.sql +42 -0
  106. package/database/migrations/20260226_0015_sticker_auto_pack_curation_integrity.sql +18 -0
  107. package/database/migrations/20260226_0016_sticker_web_google_auth_persistence.sql +34 -0
  108. package/database/migrations/20260226_0017_sticker_web_admin_ban.sql +22 -0
  109. package/database/migrations/20260226_0018_sticker_web_admin_moderator.sql +18 -0
  110. package/database/migrations/20260227_0019_sticker_classification_v2_signals.sql +12 -0
  111. package/database/migrations/20260227_0020_semantic_theme_clusters.sql +35 -0
  112. package/docker-compose.yml +103 -0
  113. package/ecosystem.prod.config.cjs +35 -0
  114. package/eslint.config.js +61 -0
  115. package/index.js +437 -0
  116. package/ml/clip_classifier/Dockerfile +16 -0
  117. package/ml/clip_classifier/README.md +120 -0
  118. package/ml/clip_classifier/adaptive_scoring.py +40 -0
  119. package/ml/clip_classifier/classifier.py +654 -0
  120. package/ml/clip_classifier/embedding_store.py +481 -0
  121. package/ml/clip_classifier/env_loader.py +15 -0
  122. package/ml/clip_classifier/llm_label_expander.py +144 -0
  123. package/ml/clip_classifier/main.py +213 -0
  124. package/ml/clip_classifier/requirements.txt +10 -0
  125. package/ml/clip_classifier/similarity_engine.py +74 -0
  126. package/observability/alert-rules.yml +60 -0
  127. package/observability/grafana/dashboards/omnizap-mysql.json +136 -0
  128. package/observability/grafana/dashboards/omnizap-overview.json +170 -0
  129. package/observability/grafana/provisioning/dashboards/dashboards.yml +11 -0
  130. package/observability/grafana/provisioning/datasources/datasources.yml +15 -0
  131. package/observability/loki-config.yml +38 -0
  132. package/observability/mysql-exporter.cnf +5 -0
  133. package/observability/mysql-setup.sql +46 -0
  134. package/observability/prometheus.yml +32 -0
  135. package/observability/promtail-config.yml +84 -0
  136. package/package.json +109 -0
  137. package/public/api-docs/index.html +144 -0
  138. package/public/css/github-project-panel.css +297 -0
  139. package/public/css/stickers-admin.css +1272 -0
  140. package/public/css/styles.css +671 -0
  141. package/public/index.html +1311 -0
  142. package/public/js/apps/apiDocsApp.js +310 -0
  143. package/public/js/apps/createPackApp.js +2069 -0
  144. package/public/js/apps/homeApp.js +396 -0
  145. package/public/js/apps/stickersAdminApp.js +1744 -0
  146. package/public/js/apps/stickersApp.js +4830 -0
  147. package/public/js/catalog.js +1019 -0
  148. package/public/js/github-panel/components/CommitList.js +34 -0
  149. package/public/js/github-panel/components/ErrorState.js +16 -0
  150. package/public/js/github-panel/components/GithubProjectPanel.js +106 -0
  151. package/public/js/github-panel/components/ReleaseList.js +38 -0
  152. package/public/js/github-panel/components/SkeletonPanel.js +22 -0
  153. package/public/js/github-panel/components/StatCard.js +15 -0
  154. package/public/js/github-panel/index.js +15 -0
  155. package/public/js/github-panel/useGithubRepoData.js +154 -0
  156. package/public/js/github-panel/vendor/react.js +11 -0
  157. package/public/js/runtime/react-runtime.js +19 -0
  158. package/public/licenca/index.html +106 -0
  159. package/public/stickers/admin/index.html +23 -0
  160. package/public/stickers/create/index.html +47 -0
  161. package/public/stickers/index.html +48 -0
  162. package/public/termos-de-uso/index.html +125 -0
  163. package/scripts/cache-bust.mjs +107 -0
  164. package/scripts/deploy.sh +458 -0
  165. package/scripts/github-deploy-notify.mjs +174 -0
  166. package/scripts/release.sh +129 -0
@@ -0,0 +1,2069 @@
1
+ import { React, createRoot, useMemo, useState, useEffect, useRef } from '../runtime/react-runtime.js?v=20260226-googlefix1';
2
+ import htm from 'https://esm.sh/htm@3.1.1';
3
+
4
+ const html = htm.bind(React.createElement);
5
+ const CREATE_PACK_DRAFT_KEY = 'omnizap_create_pack_draft_v1';
6
+ const CREATE_PACK_DRAFT_MAX_CHARS = 3_500_000;
7
+ const PACK_UPLOAD_TASK_KEY = 'omnizap_pack_upload_task_v1';
8
+ const GOOGLE_AUTH_CACHE_KEY = 'omnizap_google_web_auth_cache_v1';
9
+ const GOOGLE_AUTH_CACHE_MAX_STALE_MS = 8 * 24 * 60 * 60 * 1000;
10
+ const MAX_MANUAL_TAGS = 8;
11
+ const DEFAULT_SUGGESTED_TAGS = ['anime', 'meme', 'game', 'texto', 'nsfw', 'dark', 'cartoon', 'foto-real', 'cyberpunk'];
12
+ const GOOGLE_GSI_SCRIPT_SRC = 'https://accounts.google.com/gsi/client';
13
+ const PACK_STATUS_PUBLISHED = 'published';
14
+ const FIXED_UPLOAD_QUEUE_CONCURRENCY = 3;
15
+ const UPLOAD_AUTO_RETRY_ATTEMPTS = 2;
16
+ const UPLOAD_RETRY_BASE_DELAY_MS = 700;
17
+
18
+ const DEFAULT_LIMITS = {
19
+ pack_name_max_length: 120,
20
+ publisher_max_length: 120,
21
+ description_max_length: 1024,
22
+ stickers_per_pack: 30,
23
+ packs_per_owner: 50,
24
+ sticker_upload_max_bytes: 2 * 1024 * 1024,
25
+ sticker_upload_source_max_bytes: 20 * 1024 * 1024,
26
+ };
27
+ const UPLOAD_REQUEST_TIMEOUT_MS = 8 * 60 * 1000;
28
+
29
+ const STEPS = [
30
+ { id: 1, title: 'Informações' },
31
+ { id: 2, title: 'Stickers' },
32
+ { id: 3, title: 'Publicação' },
33
+ ];
34
+
35
+ const clampText = (value, maxLength) =>
36
+ String(value || '')
37
+ .replace(/\s+/g, ' ')
38
+ .trim()
39
+ .slice(0, maxLength);
40
+
41
+ const clampInputText = (value, maxLength) => String(value || '').slice(0, maxLength);
42
+
43
+ const removeControlChars = (value) => String(value || '').replace(/[\u0000-\u001F\u007F]/g, '');
44
+
45
+ const sanitizePackNameInput = (value, maxLength = 120) => removeControlChars(value).slice(0, maxLength);
46
+
47
+ const sanitizePackName = (value, maxLength = 120) =>
48
+ removeControlChars(value)
49
+ .replace(/\s+/g, ' ')
50
+ .trim()
51
+ .slice(0, maxLength);
52
+
53
+ const toBytesLabel = (bytes) => `${Math.round(Number(bytes || 0) / 1024)} KB`;
54
+ const normalizePhoneDigits = (value) => String(value || '').replace(/\D+/g, '');
55
+ const isValidPhone = (value) => {
56
+ const digits = normalizePhoneDigits(value);
57
+ return digits.length >= 10 && digits.length <= 15;
58
+ };
59
+ const normalizeTag = (value) =>
60
+ String(value || '')
61
+ .trim()
62
+ .toLowerCase()
63
+ .normalize('NFD')
64
+ .replace(/[\u0300-\u036f]/g, '')
65
+ .replace(/[^a-z0-9]+/g, '-')
66
+ .replace(/^-+|-+$/g, '')
67
+ .slice(0, 40);
68
+
69
+ const mergeTags = (...groups) => {
70
+ const seen = new Set();
71
+ const ordered = [];
72
+ for (const group of groups) {
73
+ for (const entry of Array.isArray(group) ? group : []) {
74
+ const normalized = normalizeTag(entry);
75
+ if (!normalized || seen.has(normalized)) continue;
76
+ seen.add(normalized);
77
+ ordered.push(normalized);
78
+ }
79
+ }
80
+ return ordered;
81
+ };
82
+
83
+ const decodeJwtPayload = (jwt) => {
84
+ const parts = String(jwt || '').split('.');
85
+ if (parts.length < 2) return null;
86
+ try {
87
+ const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/');
88
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
89
+ const decoded = atob(padded);
90
+ return JSON.parse(decoded);
91
+ } catch {
92
+ return null;
93
+ }
94
+ };
95
+
96
+ const normalizeGoogleAuthState = (value) => {
97
+ const user = value?.user && typeof value.user === 'object' ? value.user : null;
98
+ const sub = String(user?.sub || '').trim();
99
+ if (!sub) return { user: null, expiresAt: '' };
100
+ return {
101
+ user: {
102
+ sub,
103
+ email: String(user?.email || '').trim(),
104
+ name: String(user?.name || 'Conta Google').trim() || 'Conta Google',
105
+ picture: String(user?.picture || '').trim(),
106
+ },
107
+ expiresAt: String(value?.expiresAt || '').trim(),
108
+ };
109
+ };
110
+
111
+ const readGoogleAuthCache = () => {
112
+ try {
113
+ const raw = localStorage.getItem(GOOGLE_AUTH_CACHE_KEY);
114
+ if (!raw) return null;
115
+ const parsed = JSON.parse(raw);
116
+ const savedAt = Number(parsed?.savedAt || 0);
117
+ if (savedAt && Date.now() - savedAt > GOOGLE_AUTH_CACHE_MAX_STALE_MS) {
118
+ localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
119
+ return null;
120
+ }
121
+ const normalized = normalizeGoogleAuthState(parsed?.auth || null);
122
+ if (!normalized.user?.sub) {
123
+ localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
124
+ return null;
125
+ }
126
+ if (normalized.expiresAt) {
127
+ const expiresAt = Number(new Date(normalized.expiresAt));
128
+ if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
129
+ localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
130
+ return null;
131
+ }
132
+ }
133
+ return normalized;
134
+ } catch {
135
+ return null;
136
+ }
137
+ };
138
+
139
+ const writeGoogleAuthCache = (authState) => {
140
+ try {
141
+ const normalized = normalizeGoogleAuthState(authState);
142
+ if (!normalized.user?.sub) {
143
+ localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
144
+ return;
145
+ }
146
+ localStorage.setItem(
147
+ GOOGLE_AUTH_CACHE_KEY,
148
+ JSON.stringify({
149
+ auth: normalized,
150
+ savedAt: Date.now(),
151
+ }),
152
+ );
153
+ } catch {
154
+ // ignore storage errors
155
+ }
156
+ };
157
+
158
+ const clearGoogleAuthCache = () => {
159
+ try {
160
+ localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
161
+ } catch {
162
+ // ignore storage errors
163
+ }
164
+ };
165
+
166
+ const loadScript = (src) =>
167
+ new Promise((resolve, reject) => {
168
+ const existing = document.querySelector(`script[src="${src}"]`);
169
+ if (existing) {
170
+ if (existing.dataset.loaded === '1') {
171
+ resolve();
172
+ return;
173
+ }
174
+ existing.addEventListener('load', () => resolve(), { once: true });
175
+ existing.addEventListener('error', () => reject(new Error(`Falha ao carregar script: ${src}`)), { once: true });
176
+ return;
177
+ }
178
+
179
+ const script = document.createElement('script');
180
+ script.src = src;
181
+ script.async = true;
182
+ script.defer = true;
183
+ script.addEventListener('load', () => {
184
+ script.dataset.loaded = '1';
185
+ resolve();
186
+ });
187
+ script.addEventListener('error', () => reject(new Error(`Falha ao carregar script: ${src}`)));
188
+ document.head.appendChild(script);
189
+ });
190
+
191
+ const fetchJson = async (url, options = {}) => {
192
+ const response = await fetch(url, { credentials: 'same-origin', ...options });
193
+ const payload = await response.json().catch(() => ({}));
194
+ if (!response.ok) {
195
+ throw new Error(payload?.error || 'Falha na requisição.');
196
+ }
197
+ return payload;
198
+ };
199
+
200
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms || 0))));
201
+
202
+ const bytesToHex = (bufferLike) => {
203
+ const bytes = bufferLike instanceof Uint8Array ? bufferLike : new Uint8Array(bufferLike || []);
204
+ return Array.from(bytes)
205
+ .map((byte) => byte.toString(16).padStart(2, '0'))
206
+ .join('');
207
+ };
208
+
209
+ const computeDataUrlSha256 = async (dataUrl) => {
210
+ try {
211
+ const raw = String(dataUrl || '');
212
+ const base64 = raw.includes(',') ? raw.split(',').slice(1).join(',') : raw;
213
+ if (!base64) return '';
214
+ const subtle = globalThis.crypto?.subtle;
215
+ if (!subtle) return '';
216
+ const binary = atob(base64.replace(/\s+/g, ''));
217
+ const bytes = new Uint8Array(binary.length);
218
+ for (let i = 0; i < binary.length; i += 1) {
219
+ bytes[i] = binary.charCodeAt(i);
220
+ }
221
+ const digest = await subtle.digest('SHA-256', bytes);
222
+ return bytesToHex(digest);
223
+ } catch {
224
+ return '';
225
+ }
226
+ };
227
+
228
+ const runAsyncQueue = async (items, worker, maxConcurrency = FIXED_UPLOAD_QUEUE_CONCURRENCY) => {
229
+ const list = Array.isArray(items) ? items : [];
230
+ if (!list.length) return [];
231
+ const concurrency = Math.max(1, Math.min(Number(maxConcurrency || 1), list.length));
232
+ const results = new Array(list.length);
233
+ let cursor = 0;
234
+
235
+ const runWorker = async () => {
236
+ while (cursor < list.length) {
237
+ const index = cursor;
238
+ cursor += 1;
239
+ results[index] = await worker(list[index], index);
240
+ }
241
+ };
242
+
243
+ await Promise.all(Array.from({ length: concurrency }, () => runWorker()));
244
+ return results;
245
+ };
246
+
247
+ const isTransientUploadError = (error) => {
248
+ const statusCode = Number(error?.statusCode || 0);
249
+ if ([408, 429, 502, 503, 504].includes(statusCode)) return true;
250
+ const message = String(error?.message || '').toLowerCase();
251
+ return message.includes('rede') || message.includes('timeout') || message.includes('demorou');
252
+ };
253
+
254
+ const writeUploadTask = (payload) => {
255
+ try {
256
+ localStorage.setItem(
257
+ PACK_UPLOAD_TASK_KEY,
258
+ JSON.stringify({
259
+ ...payload,
260
+ updatedAt: Date.now(),
261
+ }),
262
+ );
263
+ } catch {
264
+ // ignore storage errors
265
+ }
266
+ };
267
+
268
+ const clearCreatePackStorage = () => {
269
+ try {
270
+ localStorage.removeItem(CREATE_PACK_DRAFT_KEY);
271
+ localStorage.removeItem(PACK_UPLOAD_TASK_KEY);
272
+ } catch {
273
+ // ignore storage errors
274
+ }
275
+ };
276
+
277
+ const fileToDataUrl = (file) =>
278
+ new Promise((resolve, reject) => {
279
+ const reader = new FileReader();
280
+ reader.onload = () => resolve(String(reader.result || ''));
281
+ reader.onerror = () => reject(new Error('Falha ao ler arquivo.'));
282
+ reader.readAsDataURL(file);
283
+ });
284
+
285
+ const uploadStickerWithProgress = ({ apiBasePath, packKey, editToken, item, setCover, onProgress }) =>
286
+ new Promise((resolve, reject) => {
287
+ const xhr = new XMLHttpRequest();
288
+ xhr.open('POST', `${apiBasePath}/${encodeURIComponent(packKey)}/stickers-upload`);
289
+ xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
290
+ xhr.timeout = UPLOAD_REQUEST_TIMEOUT_MS;
291
+
292
+ xhr.upload.onprogress = (event) => {
293
+ if (!event.lengthComputable) return;
294
+ const percentage = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100)));
295
+ onProgress(percentage);
296
+ };
297
+
298
+ xhr.onerror = () => {
299
+ const error = new Error(`Falha de rede ao enviar ${item.file.name}.`);
300
+ error.statusCode = 0;
301
+ reject(error);
302
+ };
303
+ xhr.ontimeout = () => {
304
+ const error = new Error(`Timeout ao enviar ${item.file.name}. Tente novamente.`);
305
+ error.statusCode = 408;
306
+ reject(error);
307
+ };
308
+ xhr.onload = () => {
309
+ let payload = {};
310
+ try {
311
+ payload = JSON.parse(xhr.responseText || '{}');
312
+ } catch {
313
+ payload = {};
314
+ }
315
+ if (xhr.status >= 200 && xhr.status < 300) {
316
+ resolve(payload);
317
+ return;
318
+ }
319
+ if (xhr.status === 413) {
320
+ const error = new Error(`Arquivo muito grande para enviar (${item.file.name}). Reduza o tamanho e tente novamente.`);
321
+ error.statusCode = 413;
322
+ error.code = payload?.code || '';
323
+ reject(error);
324
+ return;
325
+ }
326
+ if (xhr.status === 502 || xhr.status === 504) {
327
+ const error = new Error(`Servidor demorou para processar ${item.file.name}. Tente novamente em seguida.`);
328
+ error.statusCode = xhr.status;
329
+ error.code = payload?.code || '';
330
+ reject(error);
331
+ return;
332
+ }
333
+ const error = new Error(payload?.error || `Falha no upload de ${item.file.name}.`);
334
+ error.statusCode = xhr.status;
335
+ error.code = payload?.code || '';
336
+ reject(error);
337
+ };
338
+
339
+ const body = JSON.stringify({
340
+ edit_token: editToken,
341
+ upload_id: String(item.id || ''),
342
+ sticker_hash: String(item.hash || ''),
343
+ sticker_data_url: item.dataUrl,
344
+ set_cover: Boolean(setCover),
345
+ });
346
+ xhr.send(body);
347
+ });
348
+
349
+ const uploadStickerWithRetry = async (params) => {
350
+ let lastError = null;
351
+
352
+ for (let attempt = 1; attempt <= UPLOAD_AUTO_RETRY_ATTEMPTS; attempt += 1) {
353
+ try {
354
+ return await uploadStickerWithProgress(params);
355
+ } catch (error) {
356
+ lastError = error;
357
+ if (attempt >= UPLOAD_AUTO_RETRY_ATTEMPTS || !isTransientUploadError(error)) {
358
+ break;
359
+ }
360
+ await sleep(UPLOAD_RETRY_BASE_DELAY_MS * attempt);
361
+ }
362
+ }
363
+
364
+ throw lastError || new Error('Falha no upload do sticker.');
365
+ };
366
+
367
+ function StepPill({ step, active, done }) {
368
+ return html`
369
+ <div className=${`flex min-w-0 items-center gap-1.5 rounded-xl border px-2.5 py-1.5 transition sm:gap-2 sm:rounded-2xl sm:px-3 sm:py-2 ${
370
+ active
371
+ ? 'border-accent/50 bg-accent/10 text-accent'
372
+ : done
373
+ ? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
374
+ : 'border-line/70 bg-panelSoft/80 text-slate-300'
375
+ }`}>
376
+ <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-black/25 text-[11px] font-extrabold sm:h-6 sm:w-6 sm:text-xs">
377
+ ${done ? '✓' : step.id}
378
+ </span>
379
+ <p className="truncate text-[10px] font-semibold sm:text-[11px]">${step.title}</p>
380
+ </div>
381
+ `;
382
+ }
383
+
384
+ function FloatingField({ label, value, onChange, maxLength, hint = '', multiline = false }) {
385
+ const used = String(value || '').length;
386
+ const nearLimit = used >= maxLength * 0.85;
387
+ const atLimit = used >= maxLength;
388
+ const Tag = multiline ? 'textarea' : 'input';
389
+
390
+ return html`
391
+ <label className="block">
392
+ <span className="mb-1.5 inline-block text-xs font-semibold text-slate-300">${label}</span>
393
+ <div className="relative">
394
+ <${Tag}
395
+ className=${`w-full rounded-2xl border bg-panel/70 px-3.5 py-2.5 text-sm text-slate-100 outline-none transition placeholder:text-transparent md:bg-panel/80 md:px-4 md:py-3 ${
396
+ atLimit ? 'border-rose-400/60 focus:border-rose-300' : 'border-line focus:border-accent/60'
397
+ } ${multiline ? 'min-h-[96px] max-h-44 resize-none overflow-y-auto md:min-h-[110px] md:max-h-52' : 'h-11 md:h-12'}`}
398
+ placeholder=${label}
399
+ value=${value}
400
+ maxlength=${maxLength}
401
+ onInput=${onChange}
402
+ />
403
+ <span className="pointer-events-none absolute left-3.5 top-[-9px] rounded-md bg-base px-1.5 text-[10px] font-semibold uppercase tracking-[.08em] text-slate-400 md:left-4 md:bg-panel md:px-2">
404
+ ${label}
405
+ </span>
406
+ </div>
407
+ <div className="mt-1.5 flex items-center justify-between gap-3 text-[11px]">
408
+ <span className="line-clamp-2 text-slate-400">${hint}</span>
409
+ <span className=${`${atLimit ? 'text-rose-300' : nearLimit ? 'text-amber-300' : 'text-slate-400'} font-semibold`}>${used}/${maxLength}</span>
410
+ </div>
411
+ </label>
412
+ `;
413
+ }
414
+
415
+ function StickerThumb({ item, index, selectedCoverId, onSetCover, onRemove, onDragStart, onDropOn }) {
416
+ return html`
417
+ <article
418
+ draggable=${true}
419
+ onDragStart=${() => onDragStart(item.id)}
420
+ onDragOver=${(e) => e.preventDefault()}
421
+ onDrop=${() => onDropOn(item.id)}
422
+ className="group relative overflow-hidden rounded-2xl border border-line bg-panelSoft"
423
+ >
424
+ ${item.mediaKind === 'video'
425
+ ? html`<video src=${item.dataUrl} muted=${true} playsInline=${true} preload="metadata" className="aspect-square w-full object-cover bg-slate-900/80"></video>`
426
+ : html`<img src=${item.dataUrl} alt=${item.file.name} className="aspect-square w-full object-contain bg-slate-900/80" />`}
427
+ <span className="absolute left-2 top-2 rounded-md bg-black/50 px-1.5 py-0.5 text-[10px] font-bold">#${index + 1}</span>
428
+ <div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/80 to-transparent p-2">
429
+ <button
430
+ type="button"
431
+ onClick=${() => onSetCover(item.id)}
432
+ className=${`rounded-lg px-2 py-1 text-[10px] font-bold ${
433
+ selectedCoverId === item.id ? 'bg-accent text-slate-900' : 'bg-white/15 text-slate-100'
434
+ }`}
435
+ >
436
+ ${selectedCoverId === item.id ? 'Capa' : 'Definir capa'}
437
+ </button>
438
+ <button type="button" onClick=${() => onRemove(item.id)} className="rounded-lg bg-rose-500/80 px-2 py-1 text-[10px] font-bold text-white">Remover</button>
439
+ </div>
440
+ </article>
441
+ `;
442
+ }
443
+
444
+ function PackPreviewPanel({ preview, quality, compact = false }) {
445
+ return html`
446
+ <div className="space-y-2">
447
+ <article className="min-w-0 overflow-hidden rounded-2xl border border-line/70 bg-panelSoft/80">
448
+ <img src=${preview.coverUrl} alt="Preview capa" className="aspect-square w-full object-cover bg-slate-900/70" />
449
+ <div className=${`${compact ? 'p-3' : 'p-4'} space-y-2`}>
450
+ <p className=${`${compact ? 'text-base' : 'text-lg'} line-clamp-2 font-display font-bold`}>${preview.name}</p>
451
+ <p className="line-clamp-2 text-sm text-slate-300">${preview.description || 'Descrição do pack aparecerá aqui.'}</p>
452
+ <p className="text-xs text-slate-400">por ${preview.publisher}</p>
453
+ <div className="flex flex-wrap items-center gap-1">
454
+ ${preview.tags.length
455
+ ? preview.tags.map((tag) => html`<span key=${tag} className="rounded-full border border-line/70 px-2 py-0.5 text-[10px] text-slate-300">#${tag}</span>`)
456
+ : html`<span className="text-[10px] text-slate-500">Adicione tags para melhorar descoberta</span>`}
457
+ </div>
458
+ <div className="flex flex-wrap items-center gap-1.5 text-xs">
459
+ <span className="rounded-full border border-line/70 px-2 py-1 text-slate-300">${preview.visibility}</span>
460
+ <span className="rounded-full border border-line/70 px-2 py-1 text-slate-300">🧩 ${preview.stickerCount}</span>
461
+ <span className="rounded-full border border-line/70 px-2 py-1 text-slate-300">❤️ ${preview.fakeLikes}</span>
462
+ <span className="rounded-full border border-line/70 px-2 py-1 text-slate-300">⬇ ${preview.fakeOpens}</span>
463
+ </div>
464
+ </div>
465
+ </article>
466
+
467
+ <div className="rounded-2xl border border-line/60 bg-panelSoft/70 p-3">
468
+ <div className="flex items-center justify-between gap-2">
469
+ <p className="truncate text-xs font-semibold text-slate-200">Score: ${quality.score} · ${quality.label}</p>
470
+ <span className=${`${quality.tone} shrink-0 text-[11px] font-semibold`}>${quality.score}%</span>
471
+ </div>
472
+ <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-900/70">
473
+ <div className=${`h-full transition-all ${quality.bar}`} style=${{ width: `${quality.score}%` }}></div>
474
+ </div>
475
+ </div>
476
+ </div>
477
+ `;
478
+ }
479
+
480
+ function CreatePackApp() {
481
+ const root = document.getElementById('create-pack-react-root');
482
+ const apiBasePath = root?.dataset?.apiBasePath || '/api/sticker-packs';
483
+ const webPath = root?.dataset?.webPath || '/stickers';
484
+ const googleSessionApiPath = `${apiBasePath}/auth/google/session`;
485
+
486
+ const [step, setStep] = useState(1);
487
+ const [limits, setLimits] = useState(DEFAULT_LIMITS);
488
+ const [name, setName] = useState('');
489
+ const [description, setDescription] = useState('');
490
+ const [publisher, setPublisher] = useState('');
491
+ const [visibility, setVisibility] = useState('public');
492
+ const [tags, setTags] = useState([]);
493
+ const [tagInput, setTagInput] = useState('');
494
+ const [suggestedTags, setSuggestedTags] = useState(DEFAULT_SUGGESTED_TAGS);
495
+ const [accountId, setAccountId] = useState('');
496
+ const [googleAuthConfig, setGoogleAuthConfig] = useState({ enabled: false, required: false, clientId: '' });
497
+ const [googleAuth, setGoogleAuth] = useState(() => readGoogleAuthCache() || { user: null, expiresAt: '' });
498
+ const [googleAuthUiReady, setGoogleAuthUiReady] = useState(false);
499
+ const [googleAuthError, setGoogleAuthError] = useState('');
500
+ const [googleAuthBusy, setGoogleAuthBusy] = useState(false);
501
+ const [googleSessionChecked, setGoogleSessionChecked] = useState(false);
502
+ const [files, setFiles] = useState([]);
503
+ const [coverId, setCoverId] = useState('');
504
+ const [dragActive, setDragActive] = useState(false);
505
+ const [draggingStickerId, setDraggingStickerId] = useState('');
506
+ const [busy, setBusy] = useState(false);
507
+ const [status, setStatus] = useState('');
508
+ const [error, setError] = useState('');
509
+ const [publishPhase, setPublishPhase] = useState('idle');
510
+ const [progress, setProgress] = useState({ current: 0, total: 0 });
511
+ const [uploadMap, setUploadMap] = useState({});
512
+ const [activeSession, setActiveSession] = useState(null);
513
+ const [result, setResult] = useState(null);
514
+ const [backendPublishState, setBackendPublishState] = useState(null);
515
+ const [draftLoaded, setDraftLoaded] = useState(false);
516
+ const [mobilePreviewOpen, setMobilePreviewOpen] = useState(false);
517
+ const googleButtonRef = useRef(null);
518
+ const googleLoginEnabled = Boolean(googleAuthConfig.enabled && googleAuthConfig.clientId);
519
+ const googleLoginRequired = Boolean(googleAuthConfig.required);
520
+ const hasGoogleLogin = Boolean(googleAuth.user?.sub);
521
+ const shouldRenderGoogleButton = googleLoginEnabled && !hasGoogleLogin && googleSessionChecked && !googleAuthBusy;
522
+
523
+ const canStep2 = useMemo(
524
+ () =>
525
+ sanitizePackName(name, limits.pack_name_max_length).length > 0 &&
526
+ (googleLoginRequired ? hasGoogleLogin : googleLoginEnabled ? hasGoogleLogin || isValidPhone(accountId) : isValidPhone(accountId)),
527
+ [name, accountId, limits.pack_name_max_length, googleLoginRequired, googleLoginEnabled, hasGoogleLogin],
528
+ );
529
+ const canStep3 = useMemo(() => files.length > 0, [files.length]);
530
+ const publishReady = canStep2 && canStep3 && !busy;
531
+ const completionPercentage = Math.round((step / STEPS.length) * 100);
532
+ const failedUploadsCount = useMemo(
533
+ () => files.reduce((acc, item) => (uploadMap[item.id]?.status === 'error' ? acc + 1 : acc), 0),
534
+ [files, uploadMap],
535
+ );
536
+ const pendingUploadsCount = useMemo(
537
+ () => files.reduce((acc, item) => (uploadMap[item.id]?.status === 'done' ? acc : acc + 1), 0),
538
+ [files, uploadMap],
539
+ );
540
+ const hasPartialUploadedSession = Boolean(activeSession?.packKey && pendingUploadsCount > 0 && pendingUploadsCount < files.length);
541
+ const backendPackStatus = String(
542
+ backendPublishState?.status || result?.status || activeSession?.created?.status || '',
543
+ ).toLowerCase();
544
+ const publishLabel =
545
+ backendPackStatus === 'failed'
546
+ ? '🛠️ Reparar pack'
547
+ : failedUploadsCount > 0
548
+ ? `🔁 Reenviar falhas (${failedUploadsCount})`
549
+ : hasPartialUploadedSession
550
+ ? '▶ Retomar envio'
551
+ : '🚀 Publicar Pack';
552
+
553
+ const suggestedFromText = useMemo(() => {
554
+ const haystack = `${name} ${description}`
555
+ .toLowerCase()
556
+ .normalize('NFD')
557
+ .replace(/[\u0300-\u036f]/g, '');
558
+ const selected = new Set(tags);
559
+ const matches = [];
560
+
561
+ for (const tag of suggestedTags) {
562
+ const normalizedTag = normalizeTag(tag);
563
+ if (!normalizedTag || selected.has(normalizedTag)) continue;
564
+ const plain = normalizedTag.replace(/-/g, ' ');
565
+ const directMatch = haystack.includes(plain) || haystack.includes(normalizedTag);
566
+ if (directMatch) matches.push(normalizedTag);
567
+ }
568
+
569
+ if (matches.length >= 5) return matches.slice(0, 5);
570
+ const fallback = suggestedTags.map((tag) => normalizeTag(tag)).filter((tag) => tag && !selected.has(tag));
571
+ return mergeTags(matches, fallback).slice(0, 5);
572
+ }, [name, description, suggestedTags, tags]);
573
+
574
+ const tagTypeaheadSuggestions = useMemo(() => {
575
+ const query = normalizeTag(tagInput);
576
+ if (!query) return [];
577
+
578
+ const selected = new Set(tags);
579
+ const startsWith = [];
580
+ const includes = [];
581
+
582
+ for (const tag of mergeTags(suggestedFromText, suggestedTags)) {
583
+ if (!tag || selected.has(tag) || tag === query) continue;
584
+ if (tag.startsWith(query)) {
585
+ startsWith.push(tag);
586
+ continue;
587
+ }
588
+ if (tag.includes(query)) {
589
+ includes.push(tag);
590
+ }
591
+ }
592
+
593
+ return [...startsWith, ...includes].slice(0, 6);
594
+ }, [tagInput, tags, suggestedFromText, suggestedTags]);
595
+
596
+ const preview = useMemo(() => {
597
+ const safeName = sanitizePackName(name, limits.pack_name_max_length) || 'novopack';
598
+ const safeDescription = clampText(description, limits.description_max_length);
599
+ const preferredCover = files.find((item) => item.id === coverId) || files[0] || null;
600
+ const imageFallback = files.find((item) => item.mediaKind !== 'video') || null;
601
+ const cover = preferredCover?.mediaKind === 'video' ? imageFallback : preferredCover;
602
+ return {
603
+ name: safeName,
604
+ description: safeDescription,
605
+ publisher: clampText(publisher || 'OmniZap Creator', limits.publisher_max_length),
606
+ coverUrl: cover?.dataUrl || 'https://iili.io/fSNGag2.png',
607
+ stickerCount: files.length,
608
+ visibility,
609
+ tags: tags.slice(0, 3),
610
+ fakeLikes: Math.max(12, files.length * 7 + 11),
611
+ fakeOpens: Math.max(100, files.length * 55 + 70),
612
+ };
613
+ }, [name, description, publisher, files, coverId, visibility, limits.description_max_length, limits.pack_name_max_length, limits.publisher_max_length, tags]);
614
+
615
+ const quality = useMemo(() => {
616
+ const titleLength = sanitizePackName(name, limits.pack_name_max_length).length;
617
+ const descriptionLength = clampText(description, limits.description_max_length).length;
618
+ const titleScore = titleLength >= 6 ? 1 : titleLength >= 4 ? 0.7 : 0;
619
+ const descriptionScore = descriptionLength >= 28 ? 1 : descriptionLength >= 14 ? 0.6 : 0;
620
+ const tagsScore = Math.min(1, tags.length / 4);
621
+ const stickersScore = Math.min(1, files.length / 12);
622
+ const coverScore = coverId ? 1 : files.length ? 0.6 : 0;
623
+ const finalScore = Math.round((titleScore * 0.28 + descriptionScore * 0.24 + tagsScore * 0.2 + stickersScore * 0.2 + coverScore * 0.08) * 100);
624
+ if (finalScore >= 80) return { score: finalScore, label: 'Excelente', tone: 'text-emerald-300', bar: 'bg-emerald-400' };
625
+ if (finalScore >= 60) return { score: finalScore, label: 'Bom', tone: 'text-amber-300', bar: 'bg-amber-400' };
626
+ return { score: finalScore, label: 'Precisa melhorar', tone: 'text-rose-300', bar: 'bg-rose-400' };
627
+ }, [name, description, tags.length, files.length, coverId, limits.pack_name_max_length, limits.description_max_length]);
628
+
629
+ useEffect(() => {
630
+ const load = async () => {
631
+ try {
632
+ const payload = await fetchJson(`${apiBasePath}/create-config`);
633
+ const apiLimits = payload?.data?.limits || {};
634
+ const apiSuggestions = payload?.data?.rules?.suggested_tags;
635
+ const apiGoogleAuth = payload?.data?.auth?.google || {};
636
+ setLimits((prev) => ({ ...prev, ...apiLimits }));
637
+ if (Array.isArray(apiSuggestions) && apiSuggestions.length) {
638
+ setSuggestedTags(mergeTags(apiSuggestions).slice(0, 20));
639
+ }
640
+ setGoogleAuthConfig({
641
+ enabled: Boolean(apiGoogleAuth?.enabled),
642
+ required: Boolean(apiGoogleAuth?.required),
643
+ clientId: String(apiGoogleAuth?.client_id || '').trim(),
644
+ });
645
+ } catch {
646
+ // keep default
647
+ }
648
+ };
649
+ load();
650
+ }, [apiBasePath]);
651
+
652
+ useEffect(() => {
653
+ if (!googleLoginEnabled) {
654
+ setGoogleSessionChecked(true);
655
+ return;
656
+ }
657
+ let cancelled = false;
658
+ setGoogleSessionChecked(false);
659
+
660
+ fetchJson(googleSessionApiPath)
661
+ .then((payload) => {
662
+ if (cancelled) return;
663
+ const sessionData = payload?.data || {};
664
+ if (!sessionData?.authenticated || !sessionData?.user?.sub) {
665
+ setGoogleAuth({ user: null, expiresAt: '' });
666
+ clearGoogleAuthCache();
667
+ return;
668
+ }
669
+ const nextAuth = {
670
+ user: {
671
+ sub: String(sessionData.user.sub || ''),
672
+ email: String(sessionData.user.email || ''),
673
+ name: String(sessionData.user.name || 'Conta Google'),
674
+ picture: String(sessionData.user.picture || ''),
675
+ },
676
+ expiresAt: String(sessionData.expires_at || ''),
677
+ };
678
+ setGoogleAuth(nextAuth);
679
+ writeGoogleAuthCache(nextAuth);
680
+ setGoogleAuthError('');
681
+ })
682
+ .catch(() => {
683
+ // silent: endpoint may be unavailable in some setups
684
+ })
685
+ .finally(() => {
686
+ if (cancelled) return;
687
+ setGoogleSessionChecked(true);
688
+ });
689
+
690
+ return () => {
691
+ cancelled = true;
692
+ };
693
+ }, [googleLoginEnabled, googleSessionApiPath]);
694
+
695
+ useEffect(() => {
696
+ const clearGoogleButton = () => {
697
+ if (googleButtonRef.current) googleButtonRef.current.innerHTML = '';
698
+ try {
699
+ window.google?.accounts?.id?.cancel?.();
700
+ } catch {
701
+ // ignore sdk errors
702
+ }
703
+ };
704
+
705
+ if (!shouldRenderGoogleButton) {
706
+ clearGoogleButton();
707
+ return;
708
+ }
709
+ if (!googleButtonRef.current) return;
710
+
711
+ let cancelled = false;
712
+ setGoogleAuthUiReady(false);
713
+ setGoogleAuthError('');
714
+
715
+ loadScript(GOOGLE_GSI_SCRIPT_SRC)
716
+ .then(() => {
717
+ if (cancelled) return;
718
+ const accounts = window.google?.accounts?.id;
719
+ if (!accounts) throw new Error('SDK do Google não disponível.');
720
+
721
+ accounts.initialize({
722
+ client_id: googleAuthConfig.clientId,
723
+ callback: (response) => {
724
+ const credential = String(response?.credential || '').trim();
725
+ const claims = decodeJwtPayload(credential);
726
+ if (!credential || !claims?.sub) {
727
+ setGoogleAuthError('Falha ao concluir login Google.');
728
+ return;
729
+ }
730
+ setGoogleAuthBusy(true);
731
+ fetchJson(googleSessionApiPath, {
732
+ method: 'POST',
733
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
734
+ body: JSON.stringify({ google_id_token: credential }),
735
+ })
736
+ .then((sessionPayload) => {
737
+ const sessionData = sessionPayload?.data || {};
738
+ if (!sessionData?.authenticated || !sessionData?.user?.sub) {
739
+ throw new Error('Sessão Google não foi criada.');
740
+ }
741
+ setGoogleAuth({
742
+ user: {
743
+ sub: String(sessionData.user.sub || claims.sub || ''),
744
+ email: String(sessionData.user.email || claims.email || ''),
745
+ name: String(sessionData.user.name || claims.name || claims.given_name || 'Conta Google'),
746
+ picture: String(sessionData.user.picture || claims.picture || ''),
747
+ },
748
+ expiresAt: String(sessionData.expires_at || ''),
749
+ });
750
+ writeGoogleAuthCache({
751
+ user: {
752
+ sub: String(sessionData.user.sub || claims.sub || ''),
753
+ email: String(sessionData.user.email || claims.email || ''),
754
+ name: String(sessionData.user.name || claims.name || claims.given_name || 'Conta Google'),
755
+ picture: String(sessionData.user.picture || claims.picture || ''),
756
+ },
757
+ expiresAt: String(sessionData.expires_at || ''),
758
+ });
759
+ setGoogleAuthError('');
760
+ setError('');
761
+ })
762
+ .catch((sessionError) => {
763
+ setGoogleAuthError(sessionError?.message || 'Falha ao salvar sessão Google.');
764
+ })
765
+ .finally(() => setGoogleAuthBusy(false));
766
+ },
767
+ auto_select: false,
768
+ cancel_on_tap_outside: true,
769
+ });
770
+
771
+ if (googleButtonRef.current) {
772
+ googleButtonRef.current.innerHTML = '';
773
+ const measuredWidth = Math.floor(Number(googleButtonRef.current.clientWidth || 0));
774
+ const buttonWidth = Math.max(180, Math.min(320, measuredWidth || 320));
775
+ accounts.renderButton(googleButtonRef.current, {
776
+ type: 'standard',
777
+ theme: 'filled_black',
778
+ size: 'large',
779
+ text: 'signin_with',
780
+ shape: 'pill',
781
+ logo_alignment: 'left',
782
+ width: buttonWidth,
783
+ });
784
+ }
785
+ setGoogleAuthUiReady(true);
786
+ })
787
+ .catch((sdkError) => {
788
+ if (cancelled) return;
789
+ setGoogleAuthError(sdkError?.message || 'Falha ao carregar login Google.');
790
+ });
791
+
792
+ return () => {
793
+ cancelled = true;
794
+ clearGoogleButton();
795
+ };
796
+ }, [shouldRenderGoogleButton, googleAuthConfig.clientId, googleSessionApiPath]);
797
+
798
+ useEffect(() => {
799
+ try {
800
+ const raw = localStorage.getItem(CREATE_PACK_DRAFT_KEY);
801
+ if (!raw) {
802
+ setDraftLoaded(true);
803
+ return;
804
+ }
805
+ const parsed = JSON.parse(raw);
806
+ if (!parsed || typeof parsed !== 'object') {
807
+ setDraftLoaded(true);
808
+ return;
809
+ }
810
+
811
+ const restoredName =
812
+ typeof parsed.name === 'string' ? sanitizePackNameInput(parsed.name, DEFAULT_LIMITS.pack_name_max_length) : '';
813
+ if (restoredName) setName(restoredName);
814
+ if (typeof parsed.description === 'string') setDescription(parsed.description);
815
+ if (typeof parsed.publisher === 'string') setPublisher(parsed.publisher);
816
+ if (typeof parsed.visibility === 'string') setVisibility(parsed.visibility);
817
+ if (typeof parsed.accountId === 'string') setAccountId(parsed.accountId);
818
+ if (Array.isArray(parsed.tags)) setTags(mergeTags(parsed.tags).slice(0, MAX_MANUAL_TAGS));
819
+ const parsedStep = Number.isFinite(Number(parsed.step)) ? Math.max(1, Math.min(3, Number(parsed.step))) : 1;
820
+
821
+ let restoredCount = 0;
822
+ if (Array.isArray(parsed.files)) {
823
+ const restored = parsed.files
824
+ .filter((item) => item && typeof item.dataUrl === 'string' && typeof item.name === 'string')
825
+ .map((item) => ({
826
+ id: String(item.id || `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`),
827
+ file: {
828
+ name: String(item.name || 'sticker.webp'),
829
+ size: Number(item.size || 0),
830
+ type: String(item.type || 'image/webp'),
831
+ },
832
+ hash: /^[a-f0-9]{64}$/.test(String(item.hash || '').toLowerCase()) ? String(item.hash || '').toLowerCase() : '',
833
+ mediaKind:
834
+ String(item.type || '').toLowerCase().startsWith('video/') ||
835
+ String(item.name || '').toLowerCase().match(/\.(mp4|webm|mov|m4v)$/i)
836
+ ? 'video'
837
+ : 'image',
838
+ dataUrl: String(item.dataUrl),
839
+ }));
840
+
841
+ if (restored.length) {
842
+ restoredCount = restored.length;
843
+ setFiles(restored.slice(0, DEFAULT_LIMITS.stickers_per_pack));
844
+ setUploadMap(
845
+ restored.reduce((acc, item) => {
846
+ acc[item.id] = { status: 'idle', progress: 0, name: item.file.name };
847
+ return acc;
848
+ }, {}),
849
+ );
850
+ const restoredCoverId = String(parsed.coverId || '');
851
+ setCoverId(restored.find((item) => item.id === restoredCoverId)?.id || restored[0].id);
852
+ }
853
+ }
854
+ if (parsed?.activeSession && typeof parsed.activeSession === 'object') {
855
+ const packKey = String(parsed.activeSession.packKey || '').trim();
856
+ const editToken = String(parsed.activeSession.editToken || '').trim();
857
+ if (packKey && editToken) {
858
+ setActiveSession({
859
+ packKey,
860
+ editToken,
861
+ webUrl: String(parsed.activeSession.webUrl || '').trim() || null,
862
+ created: parsed.activeSession.created && typeof parsed.activeSession.created === 'object' ? parsed.activeSession.created : null,
863
+ });
864
+ }
865
+ }
866
+
867
+ const normalizedStep = restoredCount === 0 ? Math.min(2, parsedStep) : parsedStep;
868
+ setStep(normalizedStep);
869
+ if (restoredCount > 0 || restoredName) {
870
+ setStatus('Rascunho restaurado automaticamente.');
871
+ }
872
+ } catch {
873
+ // ignore invalid drafts
874
+ } finally {
875
+ setDraftLoaded(true);
876
+ }
877
+ }, []);
878
+
879
+ useEffect(() => {
880
+ if (!draftLoaded || busy) return;
881
+
882
+ const serializable = {
883
+ step,
884
+ name,
885
+ description,
886
+ publisher,
887
+ visibility,
888
+ accountId,
889
+ tags,
890
+ coverId,
891
+ activeSession: activeSession?.packKey && activeSession?.editToken ? activeSession : null,
892
+ files: files.map((item) => ({
893
+ id: item.id,
894
+ name: item?.file?.name || 'sticker.webp',
895
+ size: Number(item?.file?.size || 0),
896
+ type: String(item?.file?.type || 'image/webp'),
897
+ hash: String(item?.hash || ''),
898
+ dataUrl: item.dataUrl,
899
+ })),
900
+ updatedAt: Date.now(),
901
+ };
902
+
903
+ try {
904
+ const raw = JSON.stringify(serializable);
905
+ if (raw.length <= CREATE_PACK_DRAFT_MAX_CHARS) {
906
+ localStorage.setItem(CREATE_PACK_DRAFT_KEY, raw);
907
+ } else {
908
+ const lighter = { ...serializable, step: Math.min(2, Number(serializable.step || 1)), coverId: '', files: [] };
909
+ localStorage.setItem(CREATE_PACK_DRAFT_KEY, JSON.stringify(lighter));
910
+ }
911
+ } catch {
912
+ // ignore storage errors
913
+ }
914
+ }, [draftLoaded, busy, step, name, description, publisher, visibility, accountId, tags, coverId, files, activeSession]);
915
+
916
+ useEffect(() => {
917
+ if (!draftLoaded) return;
918
+ if (files.length > 0) return;
919
+ try {
920
+ const rawTask = localStorage.getItem(PACK_UPLOAD_TASK_KEY);
921
+ if (!rawTask) return;
922
+ const task = JSON.parse(rawTask);
923
+ const statusValue = String(task?.status || '').toLowerCase();
924
+ if (statusValue !== 'paused') return;
925
+ localStorage.removeItem(PACK_UPLOAD_TASK_KEY);
926
+ setStatus((prev) => prev || 'Envio pausado anterior foi limpo. Selecione os stickers novamente para continuar.');
927
+ } catch {
928
+ // ignore
929
+ }
930
+ }, [draftLoaded, files.length]);
931
+
932
+ useEffect(() => {
933
+ if (!draftLoaded || busy) return;
934
+ if (!activeSession?.packKey || !activeSession?.editToken) return;
935
+
936
+ let cancelled = false;
937
+ const syncBackendPublishState = async () => {
938
+ try {
939
+ const response = await fetch(`${apiBasePath}/${encodeURIComponent(activeSession.packKey)}/publish-state`, {
940
+ method: 'POST',
941
+ credentials: 'same-origin',
942
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
943
+ body: JSON.stringify({ edit_token: activeSession.editToken }),
944
+ });
945
+ const payload = await response.json().catch(() => ({}));
946
+ if (!response.ok || cancelled) return;
947
+
948
+ const publishState = payload?.data || null;
949
+ const packData = payload?.pack || null;
950
+ if (!publishState || typeof publishState !== 'object') return;
951
+
952
+ setBackendPublishState(publishState);
953
+ if (packData && packData.pack_key) {
954
+ setResult((prev) => (prev?.pack_key === packData.pack_key ? { ...prev, ...packData } : prev || packData));
955
+ }
956
+
957
+ const uploads = Array.isArray(publishState.uploads) ? publishState.uploads : [];
958
+ const uploadsById = new Map(uploads.map((entry) => [String(entry.upload_id || ''), entry]));
959
+ const uploadsByHash = new Map(
960
+ uploads.filter((entry) => entry?.sticker_hash).map((entry) => [String(entry.sticker_hash || ''), entry]),
961
+ );
962
+
963
+ setUploadMap((prev) => {
964
+ const next = { ...prev };
965
+ for (const item of files) {
966
+ const match = uploadsById.get(String(item.id || '')) || (item.hash ? uploadsByHash.get(String(item.hash || '')) : null);
967
+ if (!match) continue;
968
+ const remoteStatus = String(match.status || '').toLowerCase();
969
+ if (remoteStatus === 'done') {
970
+ next[item.id] = { ...(next[item.id] || {}), status: 'done', progress: 100, error: '' };
971
+ } else if (remoteStatus === 'failed') {
972
+ next[item.id] = {
973
+ ...(next[item.id] || {}),
974
+ status: 'error',
975
+ progress: 0,
976
+ error: String(match.error_message || 'Falha anterior no upload.'),
977
+ };
978
+ } else if (remoteStatus === 'processing') {
979
+ next[item.id] = { ...(next[item.id] || {}), status: 'uploading', progress: Number(next[item.id]?.progress || 0), error: '' };
980
+ }
981
+ }
982
+ return next;
983
+ });
984
+
985
+ const doneCount = files.reduce((acc, item) => {
986
+ const match = uploadsById.get(String(item.id || '')) || (item.hash ? uploadsByHash.get(String(item.hash || '')) : null);
987
+ return acc + (String(match?.status || '').toLowerCase() === 'done' ? 1 : 0);
988
+ }, 0);
989
+
990
+ setProgress({
991
+ current: doneCount,
992
+ total: Math.max(files.length, Number(publishState?.consistency?.total_uploads || 0), files.length ? 0 : 0),
993
+ });
994
+
995
+ const realStatus = String(publishState.status || '').toLowerCase();
996
+ if (realStatus === PACK_STATUS_PUBLISHED) {
997
+ clearCreatePackStorage();
998
+ setActiveSession(null);
999
+ setPublishPhase('idle');
1000
+ setError('');
1001
+ setStatus('Pack já foi publicado no backend. Rascunho local limpo.');
1002
+ return;
1003
+ }
1004
+
1005
+ if (realStatus === 'failed') {
1006
+ setStatus('Pack com falha no backend. Use "Reparar pack" para retomar o fluxo.');
1007
+ return;
1008
+ }
1009
+
1010
+ if (realStatus === 'processing') {
1011
+ setStatus('Pack em processamento/finalização. Você pode tentar publicar novamente para concluir.');
1012
+ return;
1013
+ }
1014
+
1015
+ if (realStatus === 'draft' || realStatus === 'uploading') {
1016
+ setStatus('Rascunho sincronizado com o backend. Você pode retomar o envio com segurança.');
1017
+ }
1018
+ } catch {
1019
+ // mantém estado local se backend estiver indisponível
1020
+ }
1021
+ };
1022
+
1023
+ syncBackendPublishState();
1024
+ return () => {
1025
+ cancelled = true;
1026
+ };
1027
+ }, [draftLoaded, busy, activeSession, apiBasePath, files]);
1028
+
1029
+ const addIncomingFiles = async (incoming) => {
1030
+ const raw = Array.from(incoming || []).filter(Boolean);
1031
+ if (!raw.length) return;
1032
+
1033
+ const filtered = raw.filter((file) => {
1034
+ const lowerName = String(file.name || '').toLowerCase();
1035
+ const lowerType = String(file.type || '').toLowerCase();
1036
+ const isImage = lowerType.startsWith('image/');
1037
+ const isVideo =
1038
+ lowerType.startsWith('video/') ||
1039
+ lowerName.endsWith('.mp4') ||
1040
+ lowerName.endsWith('.webm') ||
1041
+ lowerName.endsWith('.mov') ||
1042
+ lowerName.endsWith('.m4v');
1043
+ if (!isImage && !isVideo) return false;
1044
+ const maxBytes = Number(limits.sticker_upload_source_max_bytes || 0);
1045
+ return Number(file.size || 0) <= maxBytes;
1046
+ });
1047
+
1048
+ if (!filtered.length) {
1049
+ setError(
1050
+ `Envie imagem ou vídeo até ${toBytesLabel(
1051
+ limits.sticker_upload_source_max_bytes,
1052
+ )}. O sistema converte automaticamente para webp.`,
1053
+ );
1054
+ return;
1055
+ }
1056
+
1057
+ const availableSlots = Math.max(0, Number(limits.stickers_per_pack || 30) - files.length);
1058
+ const selected = filtered.slice(0, availableSlots);
1059
+ if (!selected.length) {
1060
+ setError(`Seu pack pode ter até ${limits.stickers_per_pack} stickers.`);
1061
+ return;
1062
+ }
1063
+
1064
+ setError('');
1065
+ const mapped = await Promise.all(
1066
+ selected.map(async (file) => {
1067
+ const dataUrl = await fileToDataUrl(file);
1068
+ return {
1069
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
1070
+ file,
1071
+ hash: await computeDataUrlSha256(dataUrl),
1072
+ mediaKind:
1073
+ String(file.type || '').toLowerCase().startsWith('video/') ||
1074
+ String(file.name || '').toLowerCase().match(/\.(mp4|webm|mov|m4v)$/i)
1075
+ ? 'video'
1076
+ : 'image',
1077
+ dataUrl,
1078
+ };
1079
+ }),
1080
+ );
1081
+
1082
+ setFiles((prev) => [...prev, ...mapped].slice(0, limits.stickers_per_pack));
1083
+ setUploadMap((prev) => {
1084
+ const next = { ...prev };
1085
+ for (const item of mapped) {
1086
+ next[item.id] = { status: 'idle', progress: 0, name: item.file.name };
1087
+ }
1088
+ return next;
1089
+ });
1090
+
1091
+ if (!coverId && mapped[0]?.id) {
1092
+ setCoverId(mapped[0].id);
1093
+ }
1094
+ };
1095
+
1096
+ const onDropUpload = async (event) => {
1097
+ event.preventDefault();
1098
+ setDragActive(false);
1099
+ await addIncomingFiles(event.dataTransfer?.files || []);
1100
+ };
1101
+
1102
+ const removeSticker = (id) => {
1103
+ setFiles((prev) => {
1104
+ const next = prev.filter((item) => item.id !== id);
1105
+ if (id === coverId) {
1106
+ setCoverId(next[0]?.id || '');
1107
+ }
1108
+ return next;
1109
+ });
1110
+ setUploadMap((prev) => {
1111
+ const next = { ...prev };
1112
+ delete next[id];
1113
+ return next;
1114
+ });
1115
+ };
1116
+
1117
+ const reorderStickers = (fromId, toId) => {
1118
+ if (!fromId || !toId || fromId === toId) return;
1119
+ setFiles((prev) => {
1120
+ const fromIndex = prev.findIndex((item) => item.id === fromId);
1121
+ const toIndex = prev.findIndex((item) => item.id === toId);
1122
+ if (fromIndex < 0 || toIndex < 0) return prev;
1123
+ const clone = [...prev];
1124
+ const [moved] = clone.splice(fromIndex, 1);
1125
+ clone.splice(toIndex, 0, moved);
1126
+ return clone;
1127
+ });
1128
+ };
1129
+
1130
+ const publishPack = async () => {
1131
+ const finalName = sanitizePackName(name, limits.pack_name_max_length);
1132
+ const finalPublisher = clampText(publisher || 'OmniZap Creator', limits.publisher_max_length);
1133
+ const finalDescription = clampText(description, limits.description_max_length);
1134
+
1135
+ if (!finalName) {
1136
+ setError('Informe um nome válido para o pack.');
1137
+ setStep(1);
1138
+ return;
1139
+ }
1140
+ if (googleLoginRequired && !hasGoogleLogin) {
1141
+ setError('Faça login com Google para publicar packs.');
1142
+ setStep(1);
1143
+ return;
1144
+ }
1145
+ if (!googleLoginRequired && !googleLoginEnabled && !isValidPhone(accountId)) {
1146
+ setError('Informe seu número de celular com DDD para publicar.');
1147
+ setStep(1);
1148
+ return;
1149
+ }
1150
+ if (!files.length) {
1151
+ setError('Adicione ao menos 1 sticker para publicar.');
1152
+ setStep(2);
1153
+ return;
1154
+ }
1155
+
1156
+ setBusy(true);
1157
+ setError('');
1158
+ setBackendPublishState((prev) => prev || null);
1159
+ const doneBeforeRun = files.reduce((acc, item) => (uploadMap[item.id]?.status === 'done' ? acc + 1 : acc), 0);
1160
+ setProgress({ current: doneBeforeRun, total: files.length });
1161
+ let session = activeSession;
1162
+
1163
+ try {
1164
+ if (!session?.packKey || !session?.editToken) {
1165
+ setPublishPhase('creating');
1166
+ setStatus('Criando pack...');
1167
+ writeUploadTask({
1168
+ status: 'running',
1169
+ title: 'Publicando pack',
1170
+ phase: 'creating',
1171
+ current: doneBeforeRun,
1172
+ total: files.length,
1173
+ progress: Math.round((doneBeforeRun / Math.max(1, files.length)) * 100),
1174
+ packKey: null,
1175
+ packUrl: null,
1176
+ message: 'Criando pack...',
1177
+ });
1178
+
1179
+ const createResponse = await fetch(`${apiBasePath}/create`, {
1180
+ method: 'POST',
1181
+ credentials: 'same-origin',
1182
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
1183
+ body: JSON.stringify({
1184
+ name: finalName,
1185
+ publisher: finalPublisher,
1186
+ description: finalDescription,
1187
+ tags,
1188
+ visibility,
1189
+ owner_jid: hasGoogleLogin ? '' : clampText(accountId, 64),
1190
+ }),
1191
+ });
1192
+
1193
+ const createPayload = await createResponse.json().catch(() => ({}));
1194
+ if (!createResponse.ok) throw new Error(createPayload?.error || 'Não foi possível criar o pack.');
1195
+
1196
+ const created = createPayload?.data || {};
1197
+ const editToken = String(createPayload?.meta?.edit_token || '').trim();
1198
+ const packKey = String(created?.pack_key || '').trim();
1199
+ if (!editToken || !packKey) throw new Error('Resposta de criação inválida.');
1200
+ session = {
1201
+ packKey,
1202
+ editToken,
1203
+ webUrl: created?.web_url || `${webPath}/${packKey}`,
1204
+ created,
1205
+ };
1206
+ setActiveSession(session);
1207
+ setResult(created);
1208
+ setBackendPublishState((prev) => ({
1209
+ ...(prev || {}),
1210
+ pack_key: packKey,
1211
+ status: String(created?.status || 'draft').toLowerCase(),
1212
+ }));
1213
+ }
1214
+
1215
+ const pendingFiles = files.filter((item) => uploadMap[item.id]?.status !== 'done');
1216
+ let processed = doneBeforeRun;
1217
+ let failedCount = 0;
1218
+
1219
+ if (pendingFiles.length > 0) {
1220
+ setPublishPhase('uploading');
1221
+ setStatus('Enviando stickers...');
1222
+ writeUploadTask({
1223
+ status: 'running',
1224
+ title: 'Publicando pack',
1225
+ phase: 'uploading',
1226
+ current: doneBeforeRun,
1227
+ total: files.length,
1228
+ progress: Math.round((doneBeforeRun / Math.max(1, files.length)) * 100),
1229
+ packKey: session.packKey,
1230
+ packUrl: session.webUrl,
1231
+ message: 'Enviando stickers...',
1232
+ });
1233
+ setUploadMap((prev) => {
1234
+ const next = { ...prev };
1235
+ for (const item of pendingFiles) {
1236
+ next[item.id] = { ...(next[item.id] || {}), status: 'uploading', progress: 0, error: '' };
1237
+ }
1238
+ return next;
1239
+ });
1240
+
1241
+ await runAsyncQueue(
1242
+ pendingFiles,
1243
+ async (item) => {
1244
+ let effectiveHash = String(item.hash || '');
1245
+ if (!effectiveHash) {
1246
+ effectiveHash = await computeDataUrlSha256(item.dataUrl);
1247
+ if (effectiveHash) {
1248
+ setFiles((prev) => prev.map((entry) => (entry.id === item.id ? { ...entry, hash: effectiveHash } : entry)));
1249
+ }
1250
+ }
1251
+
1252
+ const effectiveItem = effectiveHash && effectiveHash !== item.hash ? { ...item, hash: effectiveHash } : item;
1253
+
1254
+ try {
1255
+ const uploadPayload = await uploadStickerWithRetry({
1256
+ apiBasePath,
1257
+ packKey: session.packKey,
1258
+ editToken: session.editToken,
1259
+ item: effectiveItem,
1260
+ setCover: effectiveItem.id === coverId,
1261
+ onProgress: (percentage) => {
1262
+ setUploadMap((prev) => ({
1263
+ ...prev,
1264
+ [effectiveItem.id]: {
1265
+ ...(prev[effectiveItem.id] || {}),
1266
+ status: 'uploading',
1267
+ progress: percentage,
1268
+ error: '',
1269
+ },
1270
+ }));
1271
+ writeUploadTask({
1272
+ status: 'running',
1273
+ title: 'Publicando pack',
1274
+ phase: 'uploading',
1275
+ current: processed,
1276
+ total: files.length,
1277
+ progress: Math.round(((processed + percentage / 100) / Math.max(1, files.length)) * 100),
1278
+ packKey: session.packKey,
1279
+ packUrl: session.webUrl,
1280
+ message: `Enviando ${effectiveItem.file.name}`,
1281
+ });
1282
+ },
1283
+ });
1284
+
1285
+ setUploadMap((prev) => ({
1286
+ ...prev,
1287
+ [effectiveItem.id]: { ...(prev[effectiveItem.id] || {}), status: 'done', progress: 100, error: '' },
1288
+ }));
1289
+
1290
+ const remotePackStatus = String(uploadPayload?.data?.pack_status || '').toLowerCase();
1291
+ if (remotePackStatus) {
1292
+ setBackendPublishState((prev) => ({
1293
+ ...(prev || {}),
1294
+ pack_key: session.packKey,
1295
+ status: remotePackStatus,
1296
+ }));
1297
+ }
1298
+ } catch (err) {
1299
+ failedCount += 1;
1300
+ setUploadMap((prev) => ({
1301
+ ...prev,
1302
+ [effectiveItem.id]: {
1303
+ ...(prev[effectiveItem.id] || {}),
1304
+ status: 'error',
1305
+ progress: 0,
1306
+ error: err?.message || 'Falha de upload',
1307
+ },
1308
+ }));
1309
+ } finally {
1310
+ processed += 1;
1311
+ setProgress({ current: processed, total: files.length });
1312
+ writeUploadTask({
1313
+ status: 'running',
1314
+ title: 'Publicando pack',
1315
+ phase: 'uploading',
1316
+ current: processed,
1317
+ total: files.length,
1318
+ progress: Math.round((processed / Math.max(1, files.length)) * 100),
1319
+ packKey: session.packKey,
1320
+ packUrl: session.webUrl,
1321
+ message: processed >= files.length ? 'Preparando finalização...' : 'Processando próximo sticker...',
1322
+ });
1323
+ }
1324
+ },
1325
+ FIXED_UPLOAD_QUEUE_CONCURRENCY,
1326
+ );
1327
+ }
1328
+
1329
+ if (failedCount > 0) {
1330
+ setPublishPhase('idle');
1331
+ setStatus(`Upload concluído com ${failedCount} falha(s).`);
1332
+ setError(`Alguns stickers falharam. Clique em "🚀 Publicar Pack" novamente para reenviar apenas as falhas.`);
1333
+ setResult((prev) => prev || session.created || null);
1334
+ setBackendPublishState((prev) => ({
1335
+ ...(prev || {}),
1336
+ pack_key: session.packKey,
1337
+ status: 'draft',
1338
+ }));
1339
+ setStep(3);
1340
+ writeUploadTask({
1341
+ status: 'error',
1342
+ title: 'Publicação parcial',
1343
+ phase: 'uploading',
1344
+ current: Number(processed || 0),
1345
+ total: Number(files.length || 0),
1346
+ progress: Math.round((Number(processed || 0) / Math.max(1, Number(files.length || 1))) * 100),
1347
+ packKey: session.packKey,
1348
+ packUrl: session.webUrl,
1349
+ message: `${failedCount} sticker(s) falharam no upload.`,
1350
+ });
1351
+ return;
1352
+ }
1353
+
1354
+ setPublishPhase('processing');
1355
+ setStatus('Processando stickers...');
1356
+ writeUploadTask({
1357
+ status: 'running',
1358
+ title: 'Publicando pack',
1359
+ phase: 'processing',
1360
+ current: Number(files.length || 0),
1361
+ total: Number(files.length || 0),
1362
+ progress: 100,
1363
+ packKey: session.packKey,
1364
+ packUrl: session.webUrl,
1365
+ message: 'Validando consistência do pack...',
1366
+ });
1367
+
1368
+ setPublishPhase('publishing');
1369
+ setStatus('Publicando pack...');
1370
+ const finalizeResponse = await fetch(`${apiBasePath}/${encodeURIComponent(session.packKey)}/finalize`, {
1371
+ method: 'POST',
1372
+ credentials: 'same-origin',
1373
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
1374
+ body: JSON.stringify({ edit_token: session.editToken }),
1375
+ });
1376
+ const finalizePayload = await finalizeResponse.json().catch(() => ({}));
1377
+ if (finalizeResponse.status === 409) {
1378
+ const publishState = finalizePayload?.data?.publish_state || null;
1379
+ const packFromFinalize = finalizePayload?.data?.pack || session.created || null;
1380
+ if (publishState) setBackendPublishState(publishState);
1381
+ if (packFromFinalize) setResult(packFromFinalize);
1382
+ setPublishPhase('idle');
1383
+ setStatus('Pack ficou em rascunho aguardando correções.');
1384
+ setError(finalizePayload?.error || 'Finalize recusado: o pack ainda não está consistente.');
1385
+ writeUploadTask({
1386
+ status: 'error',
1387
+ title: 'Finalize pendente',
1388
+ phase: 'finalize',
1389
+ current: Number(files.length || 0),
1390
+ total: Number(files.length || 0),
1391
+ progress: 100,
1392
+ packKey: session.packKey,
1393
+ packUrl: session.webUrl,
1394
+ message: finalizePayload?.error || 'Pack ainda não pode ser publicado.',
1395
+ });
1396
+ setStep(3);
1397
+ return;
1398
+ }
1399
+ if (!finalizeResponse.ok) {
1400
+ throw new Error(finalizePayload?.error || 'Falha ao finalizar publicação do pack.');
1401
+ }
1402
+
1403
+ const finalizeData = finalizePayload?.data || {};
1404
+ const publishedPack = finalizeData?.pack || session.created || result;
1405
+ const publishState = finalizeData?.publish_state || null;
1406
+ if (publishState) setBackendPublishState(publishState);
1407
+ setStatus('Pack publicado com sucesso.');
1408
+ setResult(publishedPack);
1409
+ setStep(3);
1410
+ setPublishPhase('idle');
1411
+ setActiveSession(null);
1412
+ clearCreatePackStorage();
1413
+ writeUploadTask({
1414
+ status: 'done',
1415
+ title: 'Pack publicado',
1416
+ phase: 'published',
1417
+ current: Number(files.length || 0),
1418
+ total: Number(files.length || 0),
1419
+ progress: 100,
1420
+ packKey: session.packKey,
1421
+ packUrl: session.webUrl,
1422
+ message: 'Pack publicado com sucesso.',
1423
+ });
1424
+ } catch (err) {
1425
+ setPublishPhase('idle');
1426
+ setError(err?.message || 'Falha ao publicar pack.');
1427
+ setStatus('');
1428
+ writeUploadTask({
1429
+ status: 'error',
1430
+ title: 'Falha na publicação',
1431
+ phase: publishPhase || 'unknown',
1432
+ current: Number(progress.current || 0),
1433
+ total: Number(progress.total || files.length || 0),
1434
+ progress: Math.round((Number(progress.current || 0) / Math.max(1, Number(progress.total || files.length || 1))) * 100),
1435
+ packKey: session?.packKey || activeSession?.packKey || result?.pack_key || null,
1436
+ packUrl: session?.webUrl || activeSession?.webUrl || result?.web_url || null,
1437
+ message: err?.message || 'Falha ao publicar pack.',
1438
+ });
1439
+ } finally {
1440
+ setBusy(false);
1441
+ }
1442
+ };
1443
+
1444
+ useEffect(() => {
1445
+ const onBeforeUnload = () => {
1446
+ if (!busy) return;
1447
+ writeUploadTask({
1448
+ status: 'paused',
1449
+ title: 'Publicação pausada',
1450
+ current: Number(progress.current || 0),
1451
+ total: Number(progress.total || files.length || 0),
1452
+ progress: Math.round((Number(progress.current || 0) / Math.max(1, Number(progress.total || files.length || 1))) * 100),
1453
+ packKey: activeSession?.packKey || result?.pack_key || null,
1454
+ packUrl: activeSession?.webUrl || result?.web_url || null,
1455
+ message: 'Você saiu da tela durante o envio.',
1456
+ });
1457
+ };
1458
+
1459
+ window.addEventListener('beforeunload', onBeforeUnload);
1460
+ return () => window.removeEventListener('beforeunload', onBeforeUnload);
1461
+ }, [busy, progress.current, progress.total, files.length, result, activeSession]);
1462
+
1463
+ const nextStep = () => {
1464
+ if (step === 1 && !canStep2) {
1465
+ if (!sanitizePackName(name, limits.pack_name_max_length).length) {
1466
+ setError('Defina um nome para avançar.');
1467
+ return;
1468
+ }
1469
+ setError(
1470
+ googleLoginRequired
1471
+ ? 'Faça login com Google para avançar.'
1472
+ : googleLoginEnabled
1473
+ ? 'Faça login com Google ou informe seu número de celular para avançar.'
1474
+ : 'Informe seu número de celular com DDD para avançar.',
1475
+ );
1476
+ return;
1477
+ }
1478
+ if (step === 2 && !canStep3) {
1479
+ setError('Adicione stickers para avançar.');
1480
+ return;
1481
+ }
1482
+ setError('');
1483
+ setStep((prev) => Math.min(3, prev + 1));
1484
+ };
1485
+
1486
+ const prevStep = () => {
1487
+ setError('');
1488
+ setStep((prev) => Math.max(1, prev - 1));
1489
+ };
1490
+
1491
+ const disconnectGoogleLogin = () => {
1492
+ try {
1493
+ window.google?.accounts?.id?.disableAutoSelect?.();
1494
+ } catch {
1495
+ // ignore sdk errors
1496
+ }
1497
+ setGoogleAuthBusy(true);
1498
+ fetchJson(googleSessionApiPath, { method: 'DELETE' })
1499
+ .catch(() => null)
1500
+ .finally(() => {
1501
+ clearGoogleAuthCache();
1502
+ setGoogleAuth({ user: null, expiresAt: '' });
1503
+ setGoogleAuthBusy(false);
1504
+ });
1505
+ setGoogleAuthError('');
1506
+ };
1507
+
1508
+ const restartCreateFlow = () => {
1509
+ if (busy) return;
1510
+ const confirmed = window.confirm('Recomeçar a criação? Isso vai limpar o rascunho local e o progresso salvo neste dispositivo.');
1511
+ if (!confirmed) return;
1512
+
1513
+ clearCreatePackStorage();
1514
+ setStep(1);
1515
+ setName('');
1516
+ setDescription('');
1517
+ setPublisher('');
1518
+ setVisibility('public');
1519
+ setTags([]);
1520
+ setTagInput('');
1521
+ setAccountId('');
1522
+ setFiles([]);
1523
+ setCoverId('');
1524
+ setDragActive(false);
1525
+ setDraggingStickerId('');
1526
+ setStatus('Criação reiniciada. Dados locais foram limpos.');
1527
+ setError('');
1528
+ setPublishPhase('idle');
1529
+ setProgress({ current: 0, total: 0 });
1530
+ setUploadMap({});
1531
+ setActiveSession(null);
1532
+ setResult(null);
1533
+ setBackendPublishState(null);
1534
+ };
1535
+
1536
+ const addTag = (rawValue) => {
1537
+ const normalized = normalizeTag(rawValue);
1538
+ if (!normalized) return;
1539
+ setTags((prev) => {
1540
+ if (prev.includes(normalized) || prev.length >= MAX_MANUAL_TAGS) return prev;
1541
+ return [...prev, normalized];
1542
+ });
1543
+ setTagInput('');
1544
+ };
1545
+
1546
+ const removeTag = (value) => {
1547
+ const normalized = normalizeTag(value);
1548
+ setTags((prev) => prev.filter((entry) => entry !== normalized));
1549
+ };
1550
+
1551
+ const onTagInputKeyDown = (event) => {
1552
+ if (event.key === 'Tab' && !event.shiftKey && tagInput.trim() && tagTypeaheadSuggestions.length) {
1553
+ event.preventDefault();
1554
+ addTag(tagTypeaheadSuggestions[0]);
1555
+ return;
1556
+ }
1557
+ if (event.key === 'Enter' || event.key === ',') {
1558
+ event.preventDefault();
1559
+ addTag(tagInput);
1560
+ return;
1561
+ }
1562
+ if (event.key === 'Backspace' && !tagInput.trim()) {
1563
+ const last = tags[tags.length - 1];
1564
+ if (last) removeTag(last);
1565
+ }
1566
+ };
1567
+
1568
+ const visibilityHelp =
1569
+ visibility === 'private'
1570
+ ? 'Privado: apenas você acessa este pack.'
1571
+ : visibility === 'unlisted'
1572
+ ? 'Não listado: acesso por link direto.'
1573
+ : 'Público: aparece no catálogo para descoberta.';
1574
+
1575
+ const uploadProgressTotal = Math.max(0, Number(progress.total || files.length || 0));
1576
+ const uploadProgressDone = Math.max(0, Math.min(uploadProgressTotal || 0, Number(progress.current || 0)));
1577
+ const uploadProgressPercent = Math.max(
1578
+ 0,
1579
+ Math.min(100, Math.round((uploadProgressDone / Math.max(1, uploadProgressTotal || 1)) * 100)),
1580
+ );
1581
+ const uploadHasFailures = failedUploadsCount > 0;
1582
+ const backendStateFailed = backendPackStatus === 'failed';
1583
+ const publishCompleted = Boolean(
1584
+ result && String(backendPackStatus || result?.status || '').toLowerCase() === PACK_STATUS_PUBLISHED && !busy,
1585
+ );
1586
+ const showUploadProgressCard = step === 3 && busy;
1587
+ const showUploadFailureCard = step === 3 && !busy && (uploadHasFailures || backendStateFailed);
1588
+ const publishedPackUrl =
1589
+ String(result?.web_url || activeSession?.webUrl || '').trim() ||
1590
+ (result?.pack_key ? `${webPath}/${encodeURIComponent(String(result.pack_key || ''))}` : '');
1591
+ const finalStepPrimaryLabel = publishCompleted ? '👁 Ver pack criado' : publishLabel;
1592
+ const mobilePrimaryActionLabel = step < 3 ? 'Continuar' : finalStepPrimaryLabel;
1593
+ const mobilePrimaryActionClass =
1594
+ step < 3 ? 'bg-accent text-slate-900' : 'bg-accent2 text-slate-900';
1595
+ const toggleMobilePreview = () => setMobilePreviewOpen((prev) => !prev);
1596
+ const openCreatedPack = () => {
1597
+ if (!publishedPackUrl) return;
1598
+ window.location.assign(publishedPackUrl);
1599
+ };
1600
+ const handleFinalStepPrimaryAction = () => {
1601
+ if (publishCompleted) {
1602
+ openCreatedPack();
1603
+ return;
1604
+ }
1605
+ publishPack();
1606
+ };
1607
+ const finalStepPrimaryDisabled = publishCompleted ? !publishedPackUrl : !publishReady;
1608
+
1609
+ return html`
1610
+ <div className="min-h-screen bg-gradient-to-b from-[#0a0f15] via-[#0d1219] to-[#0e141a]">
1611
+ <div className="mx-auto w-full max-w-7xl px-4 pb-32 pt-4 md:px-6 md:pb-10 md:pt-5">
1612
+ <header className="mb-4 flex flex-wrap items-start justify-between gap-3 md:mb-6 md:items-center">
1613
+ <div>
1614
+ <p className="mb-1 text-xs font-semibold uppercase tracking-[.15em] text-accent">OmniZap Studio</p>
1615
+ <h1 className="font-display text-2xl font-extrabold leading-tight md:text-4xl">Criar novo Pack</h1>
1616
+ <p className="mt-1 text-xs text-slate-400 md:text-sm">Fluxo guiado para montar e publicar seu pack com visual de marketplace.</p>
1617
+ </div>
1618
+ <div className="flex flex-wrap items-center justify-end gap-1.5 text-[11px] font-semibold md:gap-2">
1619
+ <span className="hidden rounded-full border border-line/60 bg-panel/70 px-3 py-1 sm:inline-flex">🧩 Até ${limits.stickers_per_pack} stickers</span>
1620
+ <span className="hidden rounded-full border border-line/60 bg-panel/70 px-3 py-1 sm:inline-flex">📦 Até ${limits.packs_per_owner} packs</span>
1621
+ <span className="hidden rounded-full border border-line/60 bg-panel/70 px-3 py-1 md:inline-flex">✍ ${limits.pack_name_max_length} caracteres no nome</span>
1622
+ <button
1623
+ type="button"
1624
+ onClick=${restartCreateFlow}
1625
+ disabled=${busy}
1626
+ className="h-8 rounded-full border border-line/70 bg-panel/70 px-3 text-[11px] font-semibold text-slate-200 disabled:opacity-60"
1627
+ title="Limpar rascunho local e recomeçar"
1628
+ >
1629
+ Recomeçar
1630
+ </button>
1631
+ </div>
1632
+ </header>
1633
+
1634
+ <div className="mb-3 grid grid-cols-3 gap-2 md:mb-5">
1635
+ ${STEPS.map((item) => html`<${StepPill} key=${item.id} step=${item} active=${step === item.id} done=${step > item.id} />`)}
1636
+ </div>
1637
+ <div className="mb-4 md:mb-6">
1638
+ <div className="mb-1 flex items-center justify-between text-[11px] font-semibold text-slate-400">
1639
+ <span>Progresso</span>
1640
+ <span>${completionPercentage}%</span>
1641
+ </div>
1642
+ <div className="h-1.5 overflow-hidden rounded-full bg-slate-900/70 md:h-2">
1643
+ <div className="h-full bg-accent transition-all duration-300" style=${{ width: `${completionPercentage}%` }}></div>
1644
+ </div>
1645
+ </div>
1646
+
1647
+ <div className="grid gap-3 lg:grid-cols-[minmax(340px,1.1fr)_minmax(320px,.9fr)] lg:gap-4">
1648
+ <section className="min-w-0 rounded-2xl border border-line/70 bg-panel/85 p-3 shadow-none md:rounded-3xl md:border-line md:bg-panel md:p-5 md:shadow-panel">
1649
+ ${step === 1
1650
+ ? html`
1651
+ <div className="space-y-3 md:space-y-4">
1652
+ <${FloatingField}
1653
+ label="Nome do pack"
1654
+ value=${name}
1655
+ maxLength=${limits.pack_name_max_length}
1656
+ hint="Use um nome curto e fácil de encontrar."
1657
+ onChange=${(e) => setName(sanitizePackNameInput(e.target.value, limits.pack_name_max_length))}
1658
+ />
1659
+ <${FloatingField}
1660
+ label="Descrição"
1661
+ value=${description}
1662
+ multiline=${true}
1663
+ maxLength=${limits.description_max_length}
1664
+ hint="Explique o tema do pack em uma frase curta"
1665
+ onChange=${(e) => setDescription(clampInputText(e.target.value, limits.description_max_length))}
1666
+ />
1667
+ <${FloatingField}
1668
+ label="Autor"
1669
+ value=${publisher}
1670
+ maxLength=${limits.publisher_max_length}
1671
+ hint="Como seu nome será exibido no catálogo."
1672
+ onChange=${(e) => setPublisher(clampInputText(e.target.value, limits.publisher_max_length))}
1673
+ />
1674
+ ${googleLoginEnabled
1675
+ ? html`
1676
+ <div className="rounded-2xl border border-line/70 bg-panel/70 p-3 md:p-4">
1677
+ <div className="flex items-start justify-between gap-3">
1678
+ <div>
1679
+ <p className="text-xs font-semibold uppercase tracking-[.08em] text-slate-400">
1680
+ ${googleLoginRequired ? 'Login obrigatório' : 'Login Google'}
1681
+ </p>
1682
+ <p className="mt-1 text-sm font-semibold text-slate-100">Entrar com Google</p>
1683
+ <p className="mt-1 text-xs text-slate-400">
1684
+ ${googleLoginRequired
1685
+ ? 'Somente contas logadas podem criar packs nesta página.'
1686
+ : 'Faça login para vincular o pack à sua conta Google.'}
1687
+ </p>
1688
+ </div>
1689
+ ${hasGoogleLogin
1690
+ ? html`<span className="rounded-full border border-emerald-400/40 bg-emerald-400/10 px-2.5 py-1 text-[11px] font-semibold text-emerald-300">Conectado</span>`
1691
+ : null}
1692
+ </div>
1693
+
1694
+ ${hasGoogleLogin
1695
+ ? html`
1696
+ <div className="mt-3 flex items-center justify-between gap-2 rounded-xl border border-line/70 bg-panelSoft/80 p-2.5 md:gap-3 md:p-3">
1697
+ <div className="min-w-0">
1698
+ <p className="truncate text-sm font-semibold text-slate-100">${googleAuth.user?.name || 'Conta Google'}</p>
1699
+ <p className="truncate text-xs text-slate-400">${googleAuth.user?.email || ''}</p>
1700
+ </div>
1701
+ <button type="button" onClick=${disconnectGoogleLogin} className="h-10 rounded-lg border border-line/70 px-3 text-xs font-semibold text-slate-200">
1702
+ Trocar conta
1703
+ </button>
1704
+ </div>
1705
+ `
1706
+ : html`
1707
+ <div className="mt-3">
1708
+ <div ref=${googleButtonRef} className="min-h-[42px] w-full max-w-full overflow-hidden"></div>
1709
+ ${!googleSessionChecked
1710
+ ? html`<p className="mt-2 text-xs text-slate-400">Verificando sessão Google...</p>`
1711
+ : googleAuthBusy
1712
+ ? html`<p className="mt-2 text-xs text-slate-400">Conectando conta Google...</p>`
1713
+ : !googleAuthUiReady && !googleAuthError
1714
+ ? html`<p className="mt-2 text-xs text-slate-400">Carregando login Google...</p>`
1715
+ : null}
1716
+ ${googleSessionChecked && !googleAuthBusy && !shouldRenderGoogleButton && !googleAuthError
1717
+ ? html`<p className="mt-2 text-xs text-slate-400">Login Google indisponível no momento. Tente recarregar a página.</p>`
1718
+ : null}
1719
+ </div>
1720
+ `}
1721
+
1722
+ ${googleAuthError ? html`<p className="mt-2 text-xs text-rose-300">${googleAuthError}</p>` : null}
1723
+ </div>
1724
+ `
1725
+ : html`
1726
+ <${FloatingField}
1727
+ label="Celular (WhatsApp)"
1728
+ value=${accountId}
1729
+ maxLength=${32}
1730
+ hint="Obrigatório para vincular o pack ao criador. Ex: 5511999998888"
1731
+ onChange=${(e) => setAccountId(String(e.target.value || '').replace(/[^\d+()\-\s]/g, '').slice(0, 32))}
1732
+ />
1733
+ ${accountId && !isValidPhone(accountId)
1734
+ ? html`<p className="text-xs text-rose-300">Informe um número válido com DDD (10 a 15 dígitos).</p>`
1735
+ : null}
1736
+ `}
1737
+ <label className="block">
1738
+ <span className="mb-2 inline-block text-xs font-semibold text-slate-300">Tags do pack</span>
1739
+ <div className="rounded-2xl border border-line/70 bg-panelSoft/80 px-3 py-3">
1740
+ <div className="mb-2 flex flex-wrap gap-2">
1741
+ ${tags.map((tag) => html`
1742
+ <button
1743
+ key=${tag}
1744
+ type="button"
1745
+ onClick=${() => removeTag(tag)}
1746
+ className="inline-flex items-center gap-1 rounded-full border border-accent/40 bg-accent/10 px-2.5 py-1 text-[11px] font-semibold text-accent"
1747
+ title="Remover tag"
1748
+ >
1749
+ #${tag}
1750
+ <span aria-hidden="true">×</span>
1751
+ </button>
1752
+ `)}
1753
+ </div>
1754
+ <input
1755
+ type="text"
1756
+ value=${tagInput}
1757
+ maxlength=${40}
1758
+ onInput=${(e) => setTagInput(String(e.target.value || ''))}
1759
+ onKeyDown=${onTagInputKeyDown}
1760
+ onBlur=${() => addTag(tagInput)}
1761
+ placeholder=${tags.length >= MAX_MANUAL_TAGS ? `Limite de ${MAX_MANUAL_TAGS} tags` : 'Digite e pressione Enter para adicionar'}
1762
+ disabled=${tags.length >= MAX_MANUAL_TAGS}
1763
+ className="h-11 w-full rounded-xl border border-line/70 bg-panel/80 px-3 text-sm outline-none transition focus:border-accent/60 disabled:opacity-60"
1764
+ />
1765
+ ${tagInput.trim() && tags.length < MAX_MANUAL_TAGS && tagTypeaheadSuggestions.length
1766
+ ? html`
1767
+ <div className="mt-2 rounded-xl border border-line/70 bg-panel/70 p-2">
1768
+ <div className="mb-1 flex items-center justify-between gap-2">
1769
+ <p className="text-[10px] font-semibold uppercase tracking-[.08em] text-slate-400">Sugestões</p>
1770
+ <p className="text-[10px] text-slate-500">Tab completa a primeira</p>
1771
+ </div>
1772
+ <div className="flex flex-wrap gap-1.5">
1773
+ ${tagTypeaheadSuggestions.map((tag) => html`
1774
+ <button
1775
+ key=${`typeahead-${tag}`}
1776
+ type="button"
1777
+ onMouseDown=${(e) => e.preventDefault()}
1778
+ onClick=${() => addTag(tag)}
1779
+ className="rounded-full border border-accent/35 bg-accent/10 px-2 py-1 text-[10px] font-semibold text-accent transition hover:border-accent/60"
1780
+ >
1781
+ #${tag}
1782
+ </button>
1783
+ `)}
1784
+ </div>
1785
+ </div>
1786
+ `
1787
+ : null}
1788
+ <p className="mt-2 text-[11px] text-slate-400">${tags.length}/${MAX_MANUAL_TAGS} tags selecionadas.</p>
1789
+ <div className="mt-2 flex flex-wrap gap-1.5">
1790
+ ${suggestedFromText.map((tag) => html`
1791
+ <button
1792
+ key=${tag}
1793
+ type="button"
1794
+ onMouseDown=${(e) => e.preventDefault()}
1795
+ onClick=${() => addTag(tag)}
1796
+ className="rounded-full border border-line bg-panel px-2 py-1 text-[10px] font-semibold text-slate-300 transition hover:border-accent/50 hover:text-accent"
1797
+ >
1798
+ + ${tag}
1799
+ </button>
1800
+ `)}
1801
+ </div>
1802
+ </div>
1803
+ </label>
1804
+ <label className="block">
1805
+ <span className="mb-2 inline-block text-xs font-semibold text-slate-300">Visibilidade</span>
1806
+ <select
1807
+ value=${visibility}
1808
+ onChange=${(e) => setVisibility(String(e.target.value || 'public'))}
1809
+ className="h-11 w-full rounded-2xl border border-line/70 bg-panelSoft/80 px-4 text-sm outline-none focus:border-accent/60 md:h-12"
1810
+ >
1811
+ <option value="public">Público</option>
1812
+ <option value="unlisted">Não listado</option>
1813
+ <option value="private">Privado</option>
1814
+ </select>
1815
+ <p className="mt-2 text-[11px] text-slate-400">${visibilityHelp}</p>
1816
+ </label>
1817
+
1818
+ </div>
1819
+ `
1820
+ : null}
1821
+
1822
+ ${step === 2
1823
+ ? html`
1824
+ <div className="space-y-3 md:space-y-4">
1825
+ <div
1826
+ onDragOver=${(e) => {
1827
+ e.preventDefault();
1828
+ setDragActive(true);
1829
+ }}
1830
+ onDragLeave=${() => setDragActive(false)}
1831
+ onDrop=${onDropUpload}
1832
+ className=${`rounded-2xl border border-dashed p-4 text-center transition md:rounded-3xl md:border-2 md:p-6 ${dragActive ? 'border-accent bg-accent/10' : 'border-line/70 bg-panelSoft/80'}`}
1833
+ >
1834
+ <p className="text-sm font-bold md:text-base">Arraste e solte seus stickers aqui</p>
1835
+ <p className="mt-1 text-xs text-slate-400">
1836
+ Imagens e vídeos até ${toBytesLabel(
1837
+ limits.sticker_upload_source_max_bytes,
1838
+ )} cada (conversão automática para .webp)
1839
+ </p>
1840
+ <input
1841
+ id="webp-upload"
1842
+ type="file"
1843
+ accept="image/*,video/*"
1844
+ multiple
1845
+ className="hidden"
1846
+ onChange=${async (e) => {
1847
+ await addIncomingFiles(e.target.files || []);
1848
+ e.target.value = '';
1849
+ }}
1850
+ />
1851
+ <label for="webp-upload" className="mt-3 inline-flex h-11 cursor-pointer items-center rounded-xl bg-accent px-4 text-sm font-extrabold text-slate-900">Selecionar stickers</label>
1852
+ </div>
1853
+
1854
+ <div className="flex items-center justify-between text-xs text-slate-400">
1855
+ <span>${files.length}/${limits.stickers_per_pack} selecionados</span>
1856
+ <span>Arraste para reordenar • toque para definir capa</span>
1857
+ </div>
1858
+
1859
+ ${files.length
1860
+ ? html`
1861
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4">
1862
+ ${files.map((item, index) => html`<${StickerThumb}
1863
+ key=${item.id}
1864
+ item=${item}
1865
+ index=${index}
1866
+ selectedCoverId=${coverId}
1867
+ onSetCover=${setCoverId}
1868
+ onRemove=${removeSticker}
1869
+ onDragStart=${setDraggingStickerId}
1870
+ onDropOn=${(targetId) => reorderStickers(draggingStickerId, targetId)}
1871
+ />`)}
1872
+ </div>
1873
+ `
1874
+ : html`<p className="rounded-2xl border border-line/70 bg-panelSoft/80 p-3 text-center text-sm text-slate-400 md:p-4">Nenhum sticker selecionado ainda.</p>`}
1875
+ </div>
1876
+ `
1877
+ : null}
1878
+
1879
+ ${step === 3
1880
+ ? html`
1881
+ <div className="space-y-3 md:space-y-4">
1882
+ <div className="rounded-2xl border border-line/70 bg-panelSoft/80 p-3 md:p-4">
1883
+ <div className="flex items-start justify-between gap-3">
1884
+ <div>
1885
+ <h3 className="font-display text-base font-bold md:text-lg">Revisão final</h3>
1886
+ <p className="mt-0.5 text-xs text-slate-400">Confira os dados antes de publicar.</p>
1887
+ </div>
1888
+ <span className="rounded-full border border-line/70 bg-panel/60 px-2.5 py-1 text-[11px] font-semibold text-slate-300">
1889
+ ${files.length} stickers
1890
+ </span>
1891
+ </div>
1892
+ <div className="mt-3 grid gap-1.5 text-sm text-slate-300">
1893
+ <p className="truncate"><span className="text-slate-400">Nome:</span> <strong>${preview.name}</strong></p>
1894
+ <p><span className="text-slate-400">Visibilidade:</span> ${preview.visibility}</p>
1895
+ <p className="truncate text-xs text-slate-400">Autor: ${preview.publisher}</p>
1896
+ </div>
1897
+ </div>
1898
+
1899
+ ${showUploadProgressCard
1900
+ ? html`
1901
+ <div className="rounded-2xl border border-accent/25 bg-accent/5 p-3 md:p-4">
1902
+ <div className="flex items-center justify-between gap-3">
1903
+ <p className="text-sm font-semibold text-slate-100">${status || 'Processando publicação...'}</p>
1904
+ <p className="text-xs font-semibold text-accent">${uploadProgressPercent}%</p>
1905
+ </div>
1906
+ <div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-900/70">
1907
+ <div className="h-full bg-accent transition-all" style=${{ width: `${uploadProgressPercent}%` }}></div>
1908
+ </div>
1909
+ <p className="mt-2 text-xs text-slate-400">
1910
+ ${publishPhase === 'creating'
1911
+ ? 'Criando pack...'
1912
+ : publishPhase === 'uploading'
1913
+ ? `${uploadProgressDone}/${uploadProgressTotal || files.length || 0} enviados`
1914
+ : publishPhase === 'processing'
1915
+ ? 'Validando consistência e capa do pack...'
1916
+ : publishPhase === 'publishing'
1917
+ ? 'Publicando pack no marketplace...'
1918
+ : `${uploadProgressDone}/${uploadProgressTotal || files.length || 0} concluídos`}
1919
+ </p>
1920
+ </div>
1921
+ `
1922
+ : null}
1923
+
1924
+ ${showUploadFailureCard
1925
+ ? html`
1926
+ <div className="rounded-2xl border border-rose-400/25 bg-rose-400/5 p-3 text-sm">
1927
+ <p className="font-semibold text-rose-200">
1928
+ ${backendStateFailed
1929
+ ? 'O pack entrou em estado de falha no backend.'
1930
+ : `${failedUploadsCount} sticker(s) falharam no envio.`}
1931
+ </p>
1932
+ <p className="mt-1 text-xs text-rose-200/80">
1933
+ ${backendStateFailed
1934
+ ? `Use "${publishLabel}" para reparar e concluir a publicação.`
1935
+ : `Toque em "${publishLabel}" para reenviar apenas as falhas.`}
1936
+ </p>
1937
+ </div>
1938
+ `
1939
+ : null}
1940
+
1941
+ ${publishCompleted
1942
+ ? html`
1943
+ <div className="rounded-2xl border border-emerald-400/25 bg-emerald-400/5 p-3 text-sm text-emerald-100 md:p-4">
1944
+ <p className="font-bold">Pack publicado com sucesso</p>
1945
+ <p className="mt-1">${result.name} · ${result.pack_key}</p>
1946
+ <div className="mt-3 flex flex-wrap gap-2">
1947
+ <a href=${result.web_url || `${webPath}/${result.pack_key}`} className="inline-flex h-10 items-center rounded-lg bg-emerald-300 px-3 text-xs font-bold text-slate-900">Abrir pack</a>
1948
+ <a href=${webPath} className="inline-flex h-10 items-center rounded-lg border border-emerald-300/30 px-3 text-xs font-bold">Voltar ao marketplace</a>
1949
+ </div>
1950
+ </div>
1951
+ `
1952
+ : null}
1953
+ </div>
1954
+ `
1955
+ : null}
1956
+ </section>
1957
+
1958
+ <aside className="hidden min-w-0 rounded-3xl border border-line/70 bg-panel/85 p-4 lg:block lg:p-5">
1959
+ <div className="mb-2 flex items-center justify-between">
1960
+ <p className="text-xs font-semibold uppercase tracking-[.12em] text-accent">Preview em tempo real</p>
1961
+ <span className="text-[11px] font-semibold text-slate-400">Atualiza automaticamente</span>
1962
+ </div>
1963
+ <${PackPreviewPanel} preview=${preview} quality=${quality} compact=${false} />
1964
+ </aside>
1965
+ </div>
1966
+
1967
+ <div className="mt-3 lg:hidden">
1968
+ <div className="rounded-2xl border border-line/70 bg-panel/80 p-3">
1969
+ <button
1970
+ type="button"
1971
+ onClick=${toggleMobilePreview}
1972
+ className="flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-line/70 bg-panelSoft/70 px-3 text-left"
1973
+ aria-expanded=${mobilePreviewOpen ? 'true' : 'false'}
1974
+ >
1975
+ <div>
1976
+ <p className="text-xs font-semibold uppercase tracking-[.08em] text-slate-400">Preview</p>
1977
+ <p className="text-sm font-semibold text-slate-100">${preview.name}</p>
1978
+ </div>
1979
+ <span className="text-xs font-semibold text-accent">${mobilePreviewOpen ? 'Ocultar' : 'Mostrar'}</span>
1980
+ </button>
1981
+ ${mobilePreviewOpen
1982
+ ? html`<div className="mt-3"><${PackPreviewPanel} preview=${preview} quality=${quality} compact=${true} /></div>`
1983
+ : html`<p className="mt-2 text-xs text-slate-400">Toque para visualizar capa, descrição e score do pack.</p>`}
1984
+ </div>
1985
+ </div>
1986
+
1987
+ ${error
1988
+ ? html`<div className="mt-3 rounded-2xl border border-rose-400/25 bg-rose-400/5 px-3 py-2.5 text-sm text-rose-200 md:mt-4 md:px-4 md:py-3">${error}</div>`
1989
+ : null}
1990
+ </div>
1991
+
1992
+ <div className="fixed inset-x-0 bottom-0 z-30 border-t border-line/70 bg-panel/95 p-3 backdrop-blur md:hidden">
1993
+ <div className="mx-auto w-full max-w-7xl">
1994
+ <div className="mb-2 flex items-center justify-between gap-2">
1995
+ <button
1996
+ type="button"
1997
+ className="h-8 rounded-full border border-line/70 bg-panelSoft/80 px-3 text-xs font-semibold text-slate-200 disabled:opacity-60"
1998
+ onClick=${restartCreateFlow}
1999
+ disabled=${busy}
2000
+ title="Limpar rascunho local"
2001
+ >
2002
+ Recomeçar
2003
+ </button>
2004
+ <button
2005
+ type="button"
2006
+ className="h-8 rounded-full border border-line/70 bg-panelSoft/60 px-3 text-xs font-semibold text-slate-300"
2007
+ onClick=${toggleMobilePreview}
2008
+ aria-expanded=${mobilePreviewOpen ? 'true' : 'false'}
2009
+ >
2010
+ ${mobilePreviewOpen ? 'Ocultar preview' : 'Preview'}
2011
+ </button>
2012
+ </div>
2013
+ <div className="grid grid-cols-[1fr_1.45fr] gap-2">
2014
+ <button
2015
+ type="button"
2016
+ className="h-11 rounded-xl border border-line/70 bg-panelSoft/80 text-sm font-bold disabled:opacity-60"
2017
+ onClick=${prevStep}
2018
+ disabled=${step === 1 || busy}
2019
+ >
2020
+ Voltar
2021
+ </button>
2022
+ ${step < 3
2023
+ ? html`
2024
+ <button
2025
+ type="button"
2026
+ className=${`h-11 rounded-xl text-sm font-extrabold disabled:opacity-60 ${mobilePrimaryActionClass}`}
2027
+ onClick=${nextStep}
2028
+ disabled=${busy}
2029
+ >
2030
+ ${mobilePrimaryActionLabel}
2031
+ </button>
2032
+ `
2033
+ : html`
2034
+ <button
2035
+ type="button"
2036
+ className=${`h-11 rounded-xl text-sm font-extrabold disabled:opacity-60 ${mobilePrimaryActionClass}`}
2037
+ onClick=${handleFinalStepPrimaryAction}
2038
+ disabled=${finalStepPrimaryDisabled}
2039
+ >
2040
+ ${mobilePrimaryActionLabel}
2041
+ </button>
2042
+ `}
2043
+ </div>
2044
+ </div>
2045
+ </div>
2046
+
2047
+ <div className="mt-6 hidden items-center justify-end gap-2 px-6 pb-6 md:flex">
2048
+ <button
2049
+ type="button"
2050
+ className="h-10 rounded-xl border border-line/70 bg-panelSoft/80 px-4 text-sm font-bold disabled:opacity-60"
2051
+ onClick=${restartCreateFlow}
2052
+ disabled=${busy}
2053
+ title="Limpar rascunho local e recomeçar"
2054
+ >
2055
+ Recomeçar
2056
+ </button>
2057
+ <button type="button" className="h-11 rounded-xl border border-line/70 bg-panelSoft/80 px-5 text-sm font-bold" onClick=${prevStep} disabled=${step === 1 || busy}>Voltar</button>
2058
+ ${step < 3
2059
+ ? html`<button type="button" className="h-11 rounded-xl bg-accent px-5 text-sm font-extrabold text-slate-900" onClick=${nextStep} disabled=${busy}>Próximo passo</button>`
2060
+ : html`<button type="button" className="h-11 rounded-xl bg-accent2 px-5 text-sm font-extrabold text-slate-900 disabled:opacity-60" onClick=${handleFinalStepPrimaryAction} disabled=${finalStepPrimaryDisabled}>${finalStepPrimaryLabel}</button>`}
2061
+ </div>
2062
+ </div>
2063
+ `;
2064
+ }
2065
+
2066
+ const root = document.getElementById('create-pack-react-root');
2067
+ if (root) {
2068
+ createRoot(root).render(html`<${CreatePackApp} />`);
2069
+ }