@omnizap-system/omnizap 2.5.12

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 (425) hide show
  1. package/.clusterfuzzlite/Dockerfile +10 -0
  2. package/.env.example +907 -0
  3. package/.github/codeql/codeql-config.yml +10 -0
  4. package/.github/dependabot.yml +35 -0
  5. package/.github/workflows/ci.yml +73 -0
  6. package/.github/workflows/codeql.yml +106 -0
  7. package/.github/workflows/db-migration-check.yml +98 -0
  8. package/.github/workflows/dependency-review.yml +22 -0
  9. package/.github/workflows/deploy.yml +95 -0
  10. package/.github/workflows/release.yml +106 -0
  11. package/.github/workflows/security-attest-provenance.yml +51 -0
  12. package/.github/workflows/security-gitleaks.yml +34 -0
  13. package/.github/workflows/security-runner-hardening.yml +31 -0
  14. package/.github/workflows/security-scorecard.yml +44 -0
  15. package/.github/workflows/security-zap-baseline.yml +44 -0
  16. package/.github/workflows/security-zap-full-scan.yml +43 -0
  17. package/.github/workflows/security-zizmor.yml +36 -0
  18. package/.github/workflows/wiki-sync.yml +44 -0
  19. package/.gitleaks.toml +15 -0
  20. package/.prettierrc +34 -0
  21. package/CODE_OF_CONDUCT.md +114 -0
  22. package/LICENSE +56 -0
  23. package/README.md +110 -0
  24. package/SECURITY.md +110 -0
  25. package/app/config/index.js +4 -0
  26. package/app/configParts/adminIdentity.js +92 -0
  27. package/app/configParts/baileysConfig.js +1818 -0
  28. package/app/configParts/groupUtils.js +692 -0
  29. package/app/configParts/loggerConfig.js +394 -0
  30. package/app/configParts/messagePersistenceService.js +305 -0
  31. package/app/connection/baileysCompatibility.test.js +40 -0
  32. package/app/connection/baileysDbAuthState.js +344 -0
  33. package/app/connection/socketController.js +2243 -0
  34. package/app/controllers/messageController.js +7 -0
  35. package/app/controllers/messagePipeline/commandMiddleware.js +146 -0
  36. package/app/controllers/messagePipeline/conversationMiddleware.js +183 -0
  37. package/app/controllers/messagePipeline/messagePipelineMiddlewares.test.js +522 -0
  38. package/app/controllers/messagePipeline/postProcessingMiddleware.js +41 -0
  39. package/app/controllers/messagePipeline/preProcessingMiddlewares.js +166 -0
  40. package/app/controllers/messageProcessingPipeline.js +699 -0
  41. package/app/modules/adminModule/AGENT.md +4056 -0
  42. package/app/modules/adminModule/adminAiHelpService.js +56 -0
  43. package/app/modules/adminModule/adminConfigRuntime.js +177 -0
  44. package/app/modules/adminModule/commandConfig.json +7122 -0
  45. package/app/modules/adminModule/groupCommandHandlers.js +1823 -0
  46. package/app/modules/adminModule/groupCommandHandlers.test.js +350 -0
  47. package/app/modules/adminModule/groupEventHandlers.js +399 -0
  48. package/app/modules/aiModule/AGENT.md +547 -0
  49. package/app/modules/aiModule/aiAiHelpService.js +14 -0
  50. package/app/modules/aiModule/aiConfigRuntime.js +135 -0
  51. package/app/modules/aiModule/catCommand.js +967 -0
  52. package/app/modules/aiModule/commandConfig.json +981 -0
  53. package/app/modules/analyticsModule/messageAnalysisEventRepository.js +83 -0
  54. package/app/modules/gameModule/AGENT.md +196 -0
  55. package/app/modules/gameModule/commandConfig.json +366 -0
  56. package/app/modules/gameModule/diceCommand.js +42 -0
  57. package/app/modules/gameModule/gameAiHelpService.js +14 -0
  58. package/app/modules/gameModule/gameConfigRuntime.js +68 -0
  59. package/app/modules/menuModule/AGENT.md +205 -0
  60. package/app/modules/menuModule/commandConfig.json +366 -0
  61. package/app/modules/menuModule/common.js +316 -0
  62. package/app/modules/menuModule/menuAiHelpService.js +14 -0
  63. package/app/modules/menuModule/menuConfigRuntime.js +68 -0
  64. package/app/modules/menuModule/menus.js +66 -0
  65. package/app/modules/playModule/AGENT.md +321 -0
  66. package/app/modules/playModule/commandConfig.json +584 -0
  67. package/app/modules/playModule/playAiHelpService.js +14 -0
  68. package/app/modules/playModule/playCommand.js +1417 -0
  69. package/app/modules/playModule/playConfigRuntime.js +68 -0
  70. package/app/modules/quoteModule/AGENT.md +199 -0
  71. package/app/modules/quoteModule/commandConfig.json +366 -0
  72. package/app/modules/quoteModule/quoteAiHelpService.js +14 -0
  73. package/app/modules/quoteModule/quoteCommand.js +842 -0
  74. package/app/modules/quoteModule/quoteConfigRuntime.js +68 -0
  75. package/app/modules/rpgPokemonModule/AGENT.md +229 -0
  76. package/app/modules/rpgPokemonModule/commandConfig.json +386 -0
  77. package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +795 -0
  78. package/app/modules/rpgPokemonModule/rpgBattleService.js +2110 -0
  79. package/app/modules/rpgPokemonModule/rpgBattleService.test.js +770 -0
  80. package/app/modules/rpgPokemonModule/rpgEvolutionUtils.js +22 -0
  81. package/app/modules/rpgPokemonModule/rpgPokemonAiHelpService.js +14 -0
  82. package/app/modules/rpgPokemonModule/rpgPokemonCommand.js +174 -0
  83. package/app/modules/rpgPokemonModule/rpgPokemonConfigRuntime.js +68 -0
  84. package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +192 -0
  85. package/app/modules/rpgPokemonModule/rpgPokemonDomain.test.js +93 -0
  86. package/app/modules/rpgPokemonModule/rpgPokemonEvolution.test.js +46 -0
  87. package/app/modules/rpgPokemonModule/rpgPokemonMessages.js +746 -0
  88. package/app/modules/rpgPokemonModule/rpgPokemonRepository.js +1847 -0
  89. package/app/modules/rpgPokemonModule/rpgPokemonService.js +6839 -0
  90. package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +354 -0
  91. package/app/modules/statsModule/AGENT.md +320 -0
  92. package/app/modules/statsModule/commandConfig.json +540 -0
  93. package/app/modules/statsModule/globalRankingCommand.js +64 -0
  94. package/app/modules/statsModule/rankingCommand.js +41 -0
  95. package/app/modules/statsModule/rankingCommon.js +1305 -0
  96. package/app/modules/statsModule/statsAiHelpService.js +14 -0
  97. package/app/modules/statsModule/statsConfigRuntime.js +68 -0
  98. package/app/modules/stickerModule/AGENT.md +692 -0
  99. package/app/modules/stickerModule/addStickerMetadata.js +239 -0
  100. package/app/modules/stickerModule/commandConfig.json +1216 -0
  101. package/app/modules/stickerModule/convertToWebp.js +367 -0
  102. package/app/modules/stickerModule/stickerAiHelpService.js +14 -0
  103. package/app/modules/stickerModule/stickerCommand.js +446 -0
  104. package/app/modules/stickerModule/stickerConfigRuntime.js +68 -0
  105. package/app/modules/stickerModule/stickerConvertCommand.js +159 -0
  106. package/app/modules/stickerModule/stickerTextCommand.js +653 -0
  107. package/app/modules/stickerPackModule/AGENT.md +215 -0
  108. package/app/modules/stickerPackModule/autoPackCollectorRuntime.js +20 -0
  109. package/app/modules/stickerPackModule/autoPackCollectorService.js +357 -0
  110. package/app/modules/stickerPackModule/commandConfig.json +387 -0
  111. package/app/modules/stickerPackModule/domainEventOutboxRepository.js +227 -0
  112. package/app/modules/stickerPackModule/domainEvents.js +52 -0
  113. package/app/modules/stickerPackModule/semanticReclassificationEngine.js +429 -0
  114. package/app/modules/stickerPackModule/semanticReclassificationEngine.test.js +75 -0
  115. package/app/modules/stickerPackModule/semanticThemeClusterService.js +544 -0
  116. package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +400 -0
  117. package/app/modules/stickerPackModule/stickerAssetRepository.js +400 -0
  118. package/app/modules/stickerPackModule/stickerAssetReprocessQueueRepository.js +175 -0
  119. package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +3702 -0
  120. package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +559 -0
  121. package/app/modules/stickerPackModule/stickerClassificationService.js +557 -0
  122. package/app/modules/stickerPackModule/stickerDedicatedTaskWorkerRuntime.js +249 -0
  123. package/app/modules/stickerPackModule/stickerDomainEventBus.js +65 -0
  124. package/app/modules/stickerPackModule/stickerDomainEventConsumerRuntime.js +208 -0
  125. package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +99 -0
  126. package/app/modules/stickerPackModule/stickerObjectStorageService.js +285 -0
  127. package/app/modules/stickerPackModule/stickerPackAiHelpService.js +14 -0
  128. package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +1148 -0
  129. package/app/modules/stickerPackModule/stickerPackConfigRuntime.js +68 -0
  130. package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +152 -0
  131. package/app/modules/stickerPackModule/stickerPackErrors.js +30 -0
  132. package/app/modules/stickerPackModule/stickerPackInteractionEventRepository.js +101 -0
  133. package/app/modules/stickerPackModule/stickerPackItemRepository.js +432 -0
  134. package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +313 -0
  135. package/app/modules/stickerPackModule/stickerPackMessageService.js +268 -0
  136. package/app/modules/stickerPackModule/stickerPackRepository.js +450 -0
  137. package/app/modules/stickerPackModule/stickerPackScoreSnapshotRepository.js +179 -0
  138. package/app/modules/stickerPackModule/stickerPackScoreSnapshotRuntime.js +271 -0
  139. package/app/modules/stickerPackModule/stickerPackService.js +733 -0
  140. package/app/modules/stickerPackModule/stickerPackServiceRuntime.js +32 -0
  141. package/app/modules/stickerPackModule/stickerPackUtils.js +107 -0
  142. package/app/modules/stickerPackModule/stickerStorageService.js +559 -0
  143. package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +242 -0
  144. package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +242 -0
  145. package/app/modules/systemMetricsModule/AGENT.md +193 -0
  146. package/app/modules/systemMetricsModule/commandConfig.json +344 -0
  147. package/app/modules/systemMetricsModule/pingCommand.js +399 -0
  148. package/app/modules/systemMetricsModule/systemMetricsAiHelpService.js +14 -0
  149. package/app/modules/systemMetricsModule/systemMetricsConfigRuntime.js +68 -0
  150. package/app/modules/tiktokModule/AGENT.md +196 -0
  151. package/app/modules/tiktokModule/commandConfig.json +366 -0
  152. package/app/modules/tiktokModule/tiktokAiHelpService.js +14 -0
  153. package/app/modules/tiktokModule/tiktokCommand.js +716 -0
  154. package/app/modules/tiktokModule/tiktokConfigRuntime.js +68 -0
  155. package/app/modules/userModule/AGENT.md +200 -0
  156. package/app/modules/userModule/commandConfig.json +386 -0
  157. package/app/modules/userModule/userAiHelpService.js +14 -0
  158. package/app/modules/userModule/userCommand.js +1155 -0
  159. package/app/modules/userModule/userConfigRuntime.js +68 -0
  160. package/app/modules/waifuPicsModule/AGENT.md +431 -0
  161. package/app/modules/waifuPicsModule/commandConfig.json +780 -0
  162. package/app/modules/waifuPicsModule/waifuPicsAiHelpService.js +14 -0
  163. package/app/modules/waifuPicsModule/waifuPicsCommand.js +586 -0
  164. package/app/modules/waifuPicsModule/waifuPicsConfigRuntime.js +68 -0
  165. package/app/observability/metrics.js +766 -0
  166. package/app/services/ai/aiHelpResponseCacheRepository.js +280 -0
  167. package/app/services/ai/aiLearningRepository.js +400 -0
  168. package/app/services/ai/commandConfigEnrichmentRepository.js +769 -0
  169. package/app/services/ai/commandConfigEnrichmentService.js +452 -0
  170. package/app/services/ai/commandConfigValidationService.js +443 -0
  171. package/app/services/ai/commandToolBuilderService.js +192 -0
  172. package/app/services/ai/conversationRouterService.js +516 -0
  173. package/app/services/ai/geminiService.js +115 -0
  174. package/app/services/ai/geminiService.test.js +87 -0
  175. package/app/services/ai/globalModuleAiHelpService.js +1412 -0
  176. package/app/services/ai/globalToolCallingService.js +203 -0
  177. package/app/services/ai/messageCommandExecutionService.js +391 -0
  178. package/app/services/ai/moduleAiHelpCoreService.js +1099 -0
  179. package/app/services/ai/moduleAiHelpWrapperFactory.js +65 -0
  180. package/app/services/ai/moduleCommandConfigRuntimeService.js +113 -0
  181. package/app/services/ai/moduleToolExecutorService.js +464 -0
  182. package/app/services/ai/moduleToolRegistryService.js +178 -0
  183. package/app/services/ai/toolCandidateSelectorService.js +781 -0
  184. package/app/services/auth/googleWebLinkService.js +80 -0
  185. package/app/services/auth/whatsappLoginLinkService.js +230 -0
  186. package/app/services/external/pokeApiService.js +398 -0
  187. package/app/services/group/groupMetadataService.js +311 -0
  188. package/app/services/infra/dbWriteQueue.js +874 -0
  189. package/app/services/infra/featureFlagService.js +131 -0
  190. package/app/services/infra/queueUtils.js +55 -0
  191. package/app/services/messaging/captchaService.js +491 -0
  192. package/app/services/messaging/messagePersistenceService.js +1 -0
  193. package/app/services/messaging/newsBroadcastService.js +347 -0
  194. package/app/services/sticker/stickerFocusService.js +347 -0
  195. package/app/services/sticker/stickerFocusService.test.js +43 -0
  196. package/app/store/aiPromptStore.js +38 -0
  197. package/app/store/conversationSessionStore.js +131 -0
  198. package/app/store/groupConfigStore.js +58 -0
  199. package/app/store/premiumUserStore.js +54 -0
  200. package/app/utils/antiLink/antiLinkModule.js +700 -0
  201. package/app/utils/http/getImageBufferModule.js +18 -0
  202. package/app/utils/json/jsonSanitizer.js +113 -0
  203. package/app/utils/json/jsonSanitizer.test.js +40 -0
  204. package/app/utils/systemMetrics/systemMetricsModule.js +88 -0
  205. package/app/workers/aiLearningWorker.js +605 -0
  206. package/app/workers/commandConfigEnrichmentWorker.js +242 -0
  207. package/database/index.js +2075 -0
  208. package/database/init.js +151 -0
  209. package/database/migrations/.gitkeep +0 -0
  210. package/database/migrations/20260307_d0_hardening_down.sql +64 -0
  211. package/database/migrations/20260307_d0_hardening_up.sql +79 -0
  212. package/database/migrations/20260307_d1_terms_acceptance_down.sql +11 -0
  213. package/database/migrations/20260307_d1_terms_acceptance_up.sql +37 -0
  214. package/database/migrations/20260307_d2_auth_hardening_down.sql +75 -0
  215. package/database/migrations/20260307_d2_auth_hardening_up.sql +100 -0
  216. package/database/migrations/20260314_d7_canonical_sender_down.sql +53 -0
  217. package/database/migrations/20260314_d7_canonical_sender_up.sql +114 -0
  218. package/database/migrations/20260406_d30_security_analytics_down.sql +95 -0
  219. package/database/migrations/20260406_d30_security_analytics_up.sql +292 -0
  220. package/database/migrations/20260407_d31_web_google_session_token_hardening_down.sql +2 -0
  221. package/database/migrations/20260407_d31_web_google_session_token_hardening_up.sql +17 -0
  222. package/database/migrations/20260408_d32_ai_help_response_cache_down.sql +1 -0
  223. package/database/migrations/20260408_d32_ai_help_response_cache_up.sql +22 -0
  224. package/database/migrations/20260409_d33_ai_learning_tables_down.sql +4 -0
  225. package/database/migrations/20260409_d33_ai_learning_tables_up.sql +52 -0
  226. package/database/migrations/20260410_d34_command_config_enrichment_down.sql +3 -0
  227. package/database/migrations/20260410_d34_command_config_enrichment_up.sql +48 -0
  228. package/database/schema.sql +1186 -0
  229. package/docker-compose.yml +104 -0
  230. package/docs/audits/stickerCatalogController-out-of-scope.md +103 -0
  231. package/docs/audits/stickerCatalogController-symbols.md +58 -0
  232. package/docs/compliance/acceptable-use-policy-2026-03-07.md +35 -0
  233. package/docs/compliance/dpa-b2b-standard-2026-03-07.md +80 -0
  234. package/docs/compliance/monthly-compliance-checklist-2026-03-07.md +88 -0
  235. package/docs/compliance/notice-and-takedown-policy-2026-03-07.md +34 -0
  236. package/docs/compliance/privacy-policy-2026-03-07.md +75 -0
  237. package/docs/compliance/subprocessors-inventory-2026-03-07.md +16 -0
  238. package/docs/database/production-db-evolution-runbook-2026q1.md +365 -0
  239. package/docs/security/dsar-lgpd-runbook-2026-03-07.md +86 -0
  240. package/docs/security/incident-response-lgpd-anpd-runbook-2026-03-07.md +77 -0
  241. package/docs/security/network-hardening-runbook-2026-03-07.md +137 -0
  242. package/docs/seo/omnizap-seo-playbook-br-2026-02-28.md +238 -0
  243. package/docs/seo/satellite-page-template.md +116 -0
  244. package/docs/seo/satellite-pages-phase1.json +364 -0
  245. package/docs/wiki/Home.md +120 -0
  246. package/docs/wiki/pair-extraordinaire-2026-03-08.md +3 -0
  247. package/docs/wiki/recent-changes-2026-03-08.md +47 -0
  248. package/ecosystem.prod.config.cjs +135 -0
  249. package/eslint.config.js +89 -0
  250. package/index.js +488 -0
  251. package/ml/clip_classifier/Dockerfile +18 -0
  252. package/ml/clip_classifier/README.md +118 -0
  253. package/ml/clip_classifier/adaptive_scoring.py +40 -0
  254. package/ml/clip_classifier/classifier.py +654 -0
  255. package/ml/clip_classifier/embedding_store.py +481 -0
  256. package/ml/clip_classifier/env_loader.py +15 -0
  257. package/ml/clip_classifier/llm_label_expander.py +144 -0
  258. package/ml/clip_classifier/main.py +213 -0
  259. package/ml/clip_classifier/requirements.txt +10 -0
  260. package/ml/clip_classifier/similarity_engine.py +74 -0
  261. package/new-logo.png +0 -0
  262. package/observability/alert-rules.yml +60 -0
  263. package/observability/grafana/dashboards/omnizap-mysql.json +136 -0
  264. package/observability/grafana/dashboards/omnizap-overview.json +170 -0
  265. package/observability/grafana/provisioning/dashboards/dashboards.yml +11 -0
  266. package/observability/grafana/provisioning/datasources/datasources.yml +15 -0
  267. package/observability/loki-config.yml +38 -0
  268. package/observability/mysql-setup.sql +46 -0
  269. package/observability/prometheus.yml +35 -0
  270. package/observability/promtail-config.yml +84 -0
  271. package/observability/sticker-catalog-slo.md +83 -0
  272. package/observability/sticker-scale-hardening-rollout.md +128 -0
  273. package/package.json +144 -0
  274. package/public/apple-touch-icon.png +0 -0
  275. package/public/assets/css/commands-react.input.css +71 -0
  276. package/public/assets/css/create-pack-react.input.css +31 -0
  277. package/public/assets/css/home-react.input.css +106 -0
  278. package/public/assets/css/login-react.input.css +58 -0
  279. package/public/assets/css/stickers-react.input.css +18 -0
  280. package/public/assets/css/terms-react.input.css +115 -0
  281. package/public/assets/css/user-react.input.css +57 -0
  282. package/public/assets/images/brand-icon-192.png +0 -0
  283. package/public/assets/images/brand-logo-128.webp +0 -0
  284. package/public/assets/images/hero-banner-1280.jpg +0 -0
  285. package/public/comandos/commands-catalog.json +4517 -0
  286. package/public/css/api-docs.css +161 -0
  287. package/public/css/stickers-admin.css +1288 -0
  288. package/public/css/styles.css +679 -0
  289. package/public/css/systemadm/admin.css +474 -0
  290. package/public/css/systemadm/base.css +73 -0
  291. package/public/css/systemadm/components.css +662 -0
  292. package/public/css/systemadm/layout.css +229 -0
  293. package/public/css/systemadm/tokens.css +56 -0
  294. package/public/favicon-16x16.png +0 -0
  295. package/public/favicon-32x32.png +0 -0
  296. package/public/favicon.ico +0 -0
  297. package/public/js/apps/apiDocsApp.js +235 -0
  298. package/public/js/apps/commandsReactApp.js +528 -0
  299. package/public/js/apps/createPackApp.js +1646 -0
  300. package/public/js/apps/homeReactApp.js +942 -0
  301. package/public/js/apps/loginReactApp.js +496 -0
  302. package/public/js/apps/stickersAdminApp.js +1753 -0
  303. package/public/js/apps/stickersApp.js +3797 -0
  304. package/public/js/apps/termsReactApp.js +528 -0
  305. package/public/js/apps/userApp.js +2540 -0
  306. package/public/js/apps/userProfile/actions.js +66 -0
  307. package/public/js/apps/userReactApp.js +547 -0
  308. package/public/js/catalog.js +950 -0
  309. package/public/pages/api-docs.html +40 -0
  310. package/public/pages/aup.html +158 -0
  311. package/public/pages/comandos.html +41 -0
  312. package/public/pages/dpa.html +227 -0
  313. package/public/pages/home.html +45 -0
  314. package/public/pages/licenca.html +182 -0
  315. package/public/pages/login.html +40 -0
  316. package/public/pages/notice-and-takedown.html +234 -0
  317. package/public/pages/politica-de-privacidade.html +251 -0
  318. package/public/pages/seo-bot-whatsapp-para-grupo.html +350 -0
  319. package/public/pages/seo-bot-whatsapp-sem-programar.html +350 -0
  320. package/public/pages/seo-como-automatizar-avisos-no-whatsapp.html +350 -0
  321. package/public/pages/seo-como-criar-comandos-whatsapp.html +350 -0
  322. package/public/pages/seo-como-evitar-spam-no-whatsapp.html +350 -0
  323. package/public/pages/seo-como-moderar-grupo-whatsapp.html +350 -0
  324. package/public/pages/seo-como-organizar-comunidade-whatsapp.html +350 -0
  325. package/public/pages/seo-melhor-bot-whatsapp-para-grupos.html +350 -0
  326. package/public/pages/stickers-admin.html +31 -0
  327. package/public/pages/stickers-create.html +41 -0
  328. package/public/pages/stickers.html +45 -0
  329. package/public/pages/suboperadores.html +237 -0
  330. package/public/pages/termos-de-uso-texto-integral.html +241 -0
  331. package/public/pages/termos-de-uso.html +41 -0
  332. package/public/pages/user-password-reset.html +32 -0
  333. package/public/pages/user-systemadm.html +508 -0
  334. package/public/pages/user.html +39 -0
  335. package/public/robots.txt +9 -0
  336. package/public/site.webmanifest +24 -0
  337. package/public/sitemap.xml +98 -0
  338. package/schemas/command-config.schema.json +582 -0
  339. package/scripts/baileys-compat-smoke.mjs +12 -0
  340. package/scripts/cache-bust.mjs +142 -0
  341. package/scripts/deploy.sh +916 -0
  342. package/scripts/email-broadcast-terms-update.mjs +170 -0
  343. package/scripts/enrich-command-discovery-fields.mjs +286 -0
  344. package/scripts/generate-command-config-schema.mjs +273 -0
  345. package/scripts/generate-commands-catalog.mjs +308 -0
  346. package/scripts/generate-module-agents.mjs +631 -0
  347. package/scripts/generate-seo-satellite-pages.mjs +400 -0
  348. package/scripts/github-deploy-notify.mjs +174 -0
  349. package/scripts/github-release-notify.mjs +219 -0
  350. package/scripts/release.sh +599 -0
  351. package/scripts/run-codeql-local.sh +116 -0
  352. package/scripts/run-prettier-all.mjs +25 -0
  353. package/scripts/security-smoketest.mjs +581 -0
  354. package/scripts/sticker-catalog-loadtest.mjs +210 -0
  355. package/scripts/sticker-worker-task.mjs +119 -0
  356. package/scripts/sync-readme-snapshot.mjs +133 -0
  357. package/scripts/validate-command-config-schema.mjs +130 -0
  358. package/scripts/validate-command-configs.mjs +15 -0
  359. package/scripts/wiki-sync.sh +191 -0
  360. package/server/auth/googleWebAuth/googleWebAuthRuntime.js +62 -0
  361. package/server/auth/googleWebAuth/googleWebAuthService.js +807 -0
  362. package/server/auth/jwt/webJwtService.js +147 -0
  363. package/server/auth/stickerCatalogAuthContext.js +165 -0
  364. package/server/auth/termsAcceptance/termsAcceptanceHandler.js +189 -0
  365. package/server/auth/userPassword/index.js +14 -0
  366. package/server/auth/userPassword/userPasswordAuthService.js +422 -0
  367. package/server/auth/userPassword/userPasswordCrypto.js +199 -0
  368. package/server/auth/userPassword/userPasswordCrypto.test.js +76 -0
  369. package/server/auth/userPassword/userPasswordRecoveryService.js +728 -0
  370. package/server/auth/validation/authSchemas.js +236 -0
  371. package/server/auth/webAccount/webAccountHandlers.js +1434 -0
  372. package/server/controllers/admin/adminBanService.js +138 -0
  373. package/server/controllers/admin/adminPanelHandlers.js +2083 -0
  374. package/server/controllers/admin/stickerCatalogAdminContext.js +17 -0
  375. package/server/controllers/admin/systemAdminController.js +201 -0
  376. package/server/controllers/email/emailAutomationController.js +239 -0
  377. package/server/controllers/metricsController.js +21 -0
  378. package/server/controllers/seo/stickerCatalogSeoContext.js +514 -0
  379. package/server/controllers/sticker/nonCatalogHandlers.js +303 -0
  380. package/server/controllers/sticker/stickerCatalogController.js +4700 -0
  381. package/server/controllers/system/contactController.js +115 -0
  382. package/server/controllers/system/githubController.js +137 -0
  383. package/server/controllers/system/stickerCatalogSystemContext.js +758 -0
  384. package/server/controllers/system/storageController.js +154 -0
  385. package/server/controllers/system/systemController.js +135 -0
  386. package/server/controllers/system/systemMetricsController.js +156 -0
  387. package/server/controllers/system/visitController.js +90 -0
  388. package/server/controllers/userController.js +145 -0
  389. package/server/email/emailAutomationRuntime.js +225 -0
  390. package/server/email/emailAutomationService.js +125 -0
  391. package/server/email/emailOutboxRepository.js +282 -0
  392. package/server/email/emailTemplateService.js +480 -0
  393. package/server/email/emailTransportService.js +156 -0
  394. package/server/http/clientIp.js +95 -0
  395. package/server/http/httpRequestUtils.js +262 -0
  396. package/server/http/httpRequestUtils.test.js +80 -0
  397. package/server/http/httpServer.js +180 -0
  398. package/server/http/requestContext.js +20 -0
  399. package/server/http/siteRoutingUtils.js +87 -0
  400. package/server/index.js +1 -0
  401. package/server/middleware/cachePolicy.js +26 -0
  402. package/server/middleware/cachePolicyHelpers.js +1 -0
  403. package/server/middleware/endpointRateLimit.js +181 -0
  404. package/server/middleware/rateLimit.js +70 -0
  405. package/server/middleware/requireAdminAuth.js +48 -0
  406. package/server/middleware/securityHeaders.js +97 -0
  407. package/server/routes/admin/systemAdminRouter.js +64 -0
  408. package/server/routes/email/emailAutomationRouter.js +46 -0
  409. package/server/routes/health/healthRouter.js +41 -0
  410. package/server/routes/indexRouter.js +234 -0
  411. package/server/routes/metrics/metricsRouter.js +58 -0
  412. package/server/routes/static/staticPageRouter.js +134 -0
  413. package/server/routes/sticker/catalogHandlers/catalogAdminHttp.js +105 -0
  414. package/server/routes/sticker/catalogHandlers/catalogAuthHttp.js +77 -0
  415. package/server/routes/sticker/catalogHandlers/catalogPublicHttp.js +120 -0
  416. package/server/routes/sticker/catalogHandlers/catalogUploadHttp.js +83 -0
  417. package/server/routes/sticker/catalogRouter.js +77 -0
  418. package/server/routes/sticker/stickerApiRouter.js +84 -0
  419. package/server/routes/sticker/stickerDataRouter.js +145 -0
  420. package/server/routes/sticker/stickerSiteRouter.js +43 -0
  421. package/server/routes/user/userApiPaths.js +66 -0
  422. package/server/routes/user/userRouter.js +65 -0
  423. package/server/utils/safePath.js +26 -0
  424. package/utils/logger/loggerModule.js +35 -0
  425. package/vite.config.mjs +38 -0
@@ -0,0 +1,3797 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import htm from 'htm';
4
+
5
+ const html = htm.bind(React.createElement);
6
+ const SEARCH_HISTORY_KEY = 'omnizap_stickers_search_history_v1';
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 GOOGLE_GSI_SCRIPT_SRC = 'https://accounts.google.com/gsi/client';
11
+ const PROFILE_ROUTE_SEGMENTS = new Set(['perfil', 'profile']);
12
+ const CREATORS_ROUTE_SEGMENTS = new Set(['creators', 'criadores']);
13
+ const DEFAULT_STICKER_PLACEHOLDER_URL = 'https://iili.io/fSNGag2.png';
14
+ const NSFW_STICKER_PLACEHOLDER_URL = 'https://iili.io/qfhwS6u.jpg';
15
+ const LAZY_IMAGE_PLACEHOLDER_DATA_URL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
16
+
17
+ const CATALOG_SORT_OPTIONS = [
18
+ { value: 'recent', label: 'Mais recentes', icon: '🆕' },
19
+ { value: 'likes', label: 'Mais curtidos', icon: '👍' },
20
+ { value: 'downloads', label: 'Mais baixados', icon: '⬇️' },
21
+ { value: 'trending', label: 'Em alta', icon: '🔥' },
22
+ { value: 'comments', label: 'Mais comentados', icon: '💬' },
23
+ ];
24
+
25
+ const DEFAULT_CATALOG_SORT = 'trending';
26
+ const DEFAULT_CREATORS_SORT = 'popular';
27
+ const FIRST_CATALOG_PAGE = 1;
28
+ const MOBILE_MAX_CATALOG_LIMIT = 12;
29
+ const MOBILE_DISCOVER_CAROUSEL_LIMIT = 5;
30
+ const DESKTOP_DISCOVER_GROWING_LIMIT = 4;
31
+ const DESKTOP_DISCOVER_TOP_LIMIT = 4;
32
+ const PACK_STICKERS_INITIAL_LIMIT_MOBILE = 8;
33
+ const PACK_STICKERS_INITIAL_LIMIT_DESKTOP = 12;
34
+ const PACK_STICKERS_LOAD_STEP = 8;
35
+ const OMNIZAP_LOGO_DATA_URL = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 48 48'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%230ea5e9'/%3E%3Cstop offset='1' stop-color='%2310b981'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='48' height='48' rx='24' fill='url(%23g)'/%3E%3Ctext x='24' y='30' text-anchor='middle' font-family='Segoe UI, Roboto, Arial, sans-serif' font-size='16' font-weight='700' fill='white'%3EOZ%3C/text%3E%3C/svg%3E";
36
+ const AVATAR_BG_PALETTE = ['#0f172a', '#1e293b', '#334155', '#0f766e', '#115e59', '#1d4ed8', '#3b0764', '#7c2d12', '#7f1d1d', '#14532d'];
37
+ const AVATAR_URL_CACHE = new Map();
38
+
39
+ const safeNumber = (value, fallback = 0) => {
40
+ const numeric = Number(value);
41
+ return Number.isFinite(numeric) ? numeric : fallback;
42
+ };
43
+
44
+ const COMPLETE_PACK_STICKER_TARGET = 30;
45
+
46
+ const normalizeCatalogSort = (value, fallback = DEFAULT_CATALOG_SORT) => {
47
+ const normalized = String(value || '')
48
+ .trim()
49
+ .toLowerCase();
50
+ if (normalized === 'new') return 'recent';
51
+ if (normalized === 'liked') return 'likes';
52
+ if (normalized === 'popular') return 'trending';
53
+ if (CATALOG_SORT_OPTIONS.some((entry) => entry.value === normalized)) return normalized;
54
+ return fallback;
55
+ };
56
+
57
+ const normalizeCreatorsSort = (value, fallback = DEFAULT_CREATORS_SORT) => {
58
+ const normalized = String(value || '')
59
+ .trim()
60
+ .toLowerCase();
61
+ if (normalized === 'creator_score') return 'popular';
62
+ if (['popular', 'likes', 'downloads', 'packs', 'name'].includes(normalized)) return normalized;
63
+ return fallback;
64
+ };
65
+
66
+ const catalogSortLabel = (sort) => CATALOG_SORT_OPTIONS.find((entry) => entry.value === normalizeCatalogSort(sort))?.label || 'Ordenar';
67
+
68
+ const getPackTrendScore = (pack) => safeNumber(pack?.signals?.trend_score);
69
+ const getPackRankingScore = (pack) => safeNumber(pack?.signals?.ranking_score);
70
+ const getPackCommentCount = (pack) => safeNumber(pack?.engagement?.comment_count || pack?.comment_count || 0);
71
+ const getPackStickerCount = (pack) => Math.max(0, safeNumber(pack?.sticker_count || 0));
72
+ const isPackComplete = (pack) => {
73
+ if (pack?.is_complete === true || pack?.is_complete === 1 || pack?.is_complete === '1') return true;
74
+ if (pack?.is_complete === false || pack?.is_complete === 0 || pack?.is_complete === '0') return false;
75
+ return getPackStickerCount(pack) >= COMPLETE_PACK_STICKER_TARGET;
76
+ };
77
+ const comparePacksByCompleteness = (left, right) => {
78
+ const leftCount = getPackStickerCount(left);
79
+ const rightCount = getPackStickerCount(right);
80
+ const leftIsComplete = isPackComplete(left) ? 1 : 0;
81
+ const rightIsComplete = isPackComplete(right) ? 1 : 0;
82
+
83
+ if (rightIsComplete !== leftIsComplete) return rightIsComplete - leftIsComplete;
84
+ if (rightCount !== leftCount) return rightCount - leftCount;
85
+ const leftHasCover = left?.cover_url ? 1 : 0;
86
+ const rightHasCover = right?.cover_url ? 1 : 0;
87
+ return rightHasCover - leftHasCover;
88
+ };
89
+
90
+ const buildCreatorScore = (creator) => {
91
+ const avgPackScore = safeNumber(creator?.avgPackScore ?? creator?.stats?.avg_pack_score);
92
+ const totalLikes = safeNumber(creator?.likes ?? creator?.stats?.total_likes);
93
+ const totalDownloads = safeNumber(creator?.downloads ?? creator?.stats?.total_opens);
94
+ return Number((avgPackScore * 0.45 + totalLikes * 0.0008 + totalDownloads * 0.00015).toFixed(6));
95
+ };
96
+
97
+ const DEFAULT_CATEGORIES = [
98
+ { value: '', label: '🔥 Em alta', icon: '🔥' },
99
+ { value: 'anime', label: 'Anime', icon: '🎌' },
100
+ { value: 'game', label: 'Games', icon: '🎮' },
101
+ { value: 'meme', label: 'Meme', icon: '😂' },
102
+ { value: 'nsfw', label: '+18', icon: '🔞' },
103
+ { value: 'dark-aesthetic', label: 'Dark', icon: '🖤' },
104
+ { value: 'texto', label: 'Texto', icon: '✍️' },
105
+ { value: 'cartoon', label: 'Cartoon', icon: '🧸' },
106
+ { value: 'foto-real', label: 'Foto real', icon: '📷' },
107
+ { value: 'animal-photo', label: 'Animal', icon: '🐾' },
108
+ { value: 'cyberpunk', label: 'Cyberpunk', icon: '⚡' },
109
+ ];
110
+
111
+ const CATEGORY_META = new Map(DEFAULT_CATEGORIES.map((entry) => [entry.value, entry]));
112
+
113
+ const parseIntSafe = (value, fallback) => {
114
+ const parsed = Number.parseInt(String(value || ''), 10);
115
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
116
+ };
117
+
118
+ const resolveCatalogPageLimit = (defaultLimit) => {
119
+ const parsedLimit = parseIntSafe(defaultLimit, 24);
120
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return parsedLimit;
121
+ const isMobileViewport = window.matchMedia('(max-width: 767px)').matches;
122
+ if (!isMobileViewport) return parsedLimit;
123
+ return Math.max(8, Math.min(parsedLimit, MOBILE_MAX_CATALOG_LIMIT));
124
+ };
125
+
126
+ const resolveInitialPackStickerLimit = () => {
127
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return PACK_STICKERS_INITIAL_LIMIT_DESKTOP;
128
+ const isMobileViewport = window.matchMedia('(max-width: 767px)').matches;
129
+ return isMobileViewport ? PACK_STICKERS_INITIAL_LIMIT_MOBILE : PACK_STICKERS_INITIAL_LIMIT_DESKTOP;
130
+ };
131
+
132
+ const normalizeGoogleAuthState = (value) => {
133
+ const user = value?.user && typeof value.user === 'object' ? value.user : null;
134
+ const sub = String(user?.sub || '').trim();
135
+ if (!sub) return { user: null, expiresAt: '' };
136
+ return {
137
+ user: {
138
+ sub,
139
+ email: String(user?.email || '').trim(),
140
+ name: String(user?.name || 'Conta Google').trim() || 'Conta Google',
141
+ picture: String(user?.picture || '').trim(),
142
+ },
143
+ expiresAt: String(value?.expiresAt || '').trim(),
144
+ };
145
+ };
146
+
147
+ const readGoogleAuthCache = () => {
148
+ try {
149
+ const raw = localStorage.getItem(GOOGLE_AUTH_CACHE_KEY);
150
+ if (!raw) return null;
151
+ const parsed = JSON.parse(raw);
152
+ const savedAt = Number(parsed?.savedAt || 0);
153
+ if (savedAt && Date.now() - savedAt > GOOGLE_AUTH_CACHE_MAX_STALE_MS) {
154
+ localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
155
+ return null;
156
+ }
157
+ const normalized = normalizeGoogleAuthState(parsed?.auth || null);
158
+ if (!normalized.user?.sub) {
159
+ localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
160
+ return null;
161
+ }
162
+ if (normalized.expiresAt) {
163
+ const expiresAt = Number(new Date(normalized.expiresAt));
164
+ if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
165
+ localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
166
+ return null;
167
+ }
168
+ }
169
+ return normalized;
170
+ } catch {
171
+ return null;
172
+ }
173
+ };
174
+
175
+ const writeGoogleAuthCache = (authState) => {
176
+ try {
177
+ const normalized = normalizeGoogleAuthState(authState);
178
+ if (!normalized.user?.sub) {
179
+ localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
180
+ return;
181
+ }
182
+ localStorage.setItem(
183
+ GOOGLE_AUTH_CACHE_KEY,
184
+ JSON.stringify({
185
+ auth: normalized,
186
+ savedAt: Date.now(),
187
+ }),
188
+ );
189
+ } catch {
190
+ // ignore storage errors
191
+ }
192
+ };
193
+
194
+ const clearGoogleAuthCache = () => {
195
+ try {
196
+ localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
197
+ } catch {
198
+ // ignore storage errors
199
+ }
200
+ };
201
+
202
+ const normalizeToken = (value) =>
203
+ String(value || '')
204
+ .toLowerCase()
205
+ .trim()
206
+ .normalize('NFD')
207
+ .replace(/[\u0300-\u036f]/g, '')
208
+ .replace(/[^a-z0-9- ]+/g, '');
209
+
210
+ const NSFW_HINT_TOKENS = ['nsfw', 'adult', 'explicit', 'suggestive', 'sexual', 'porn', 'nud', 'gore', '18', 'bikini', 'lingerie', 'underwear', 'swimsuit'];
211
+ const normalizeNsfwToken = (value) =>
212
+ String(value || '')
213
+ .toLowerCase()
214
+ .trim()
215
+ .normalize('NFD')
216
+ .replace(/[\u0300-\u036f]/g, '')
217
+ .replace(/[^a-z0-9- ]+/g, '')
218
+ .replace(/\s+/g, '-');
219
+
220
+ const hasNsfwHint = (value) => {
221
+ const normalized = normalizeNsfwToken(value);
222
+ if (!normalized) return false;
223
+ return NSFW_HINT_TOKENS.some((token) => normalized.includes(token));
224
+ };
225
+
226
+ const hasNsfwHintsInList = (values) => {
227
+ const list = Array.isArray(values) ? values : [];
228
+ return list.some((entry) => hasNsfwHint(entry));
229
+ };
230
+
231
+ const isSignalMarkedNsfw = (signals) => {
232
+ const level = String(signals?.nsfw_level || '')
233
+ .trim()
234
+ .toLowerCase();
235
+ if (signals?.sensitive_content === true) return true;
236
+ if (!level) return false;
237
+ return level !== 'safe';
238
+ };
239
+
240
+ const isClassificationMarkedNsfw = (classification) => {
241
+ if (!classification || typeof classification !== 'object') return false;
242
+ if (classification.is_nsfw === true) return true;
243
+ if (hasNsfwHint(classification.category || classification.majority_category || '')) return true;
244
+ if (hasNsfwHintsInList(classification.tags)) return true;
245
+
246
+ const nsfw = classification.nsfw || {};
247
+ if (Number(nsfw.flagged_items || 0) > 0) return true;
248
+ if (Number(nsfw.max_score || 0) >= 0.12) return true;
249
+ if (Number(nsfw.avg_score || 0) >= 0.08) return true;
250
+ return false;
251
+ };
252
+
253
+ const isPackMarkedNsfw = (pack) => {
254
+ if (!pack || typeof pack !== 'object') return false;
255
+ if (pack.is_nsfw === true) return true;
256
+ if (isSignalMarkedNsfw(pack.signals)) return true;
257
+ if (isClassificationMarkedNsfw(pack.classification)) return true;
258
+ if (hasNsfwHint(pack.name || '')) return true;
259
+ if (hasNsfwHint(pack.description || '')) return true;
260
+ if (hasNsfwHintsInList(pack.tags) || hasNsfwHintsInList(pack.manual_tags)) return true;
261
+ return false;
262
+ };
263
+
264
+ const isStickerMarkedNsfw = (item) => {
265
+ if (!item || typeof item !== 'object') return false;
266
+ if (item.is_nsfw === true) return true;
267
+ if (isClassificationMarkedNsfw(item.classification || item.asset?.classification)) return true;
268
+ if (hasNsfwHintsInList(item.tags)) return true;
269
+ return false;
270
+ };
271
+
272
+ const shortNum = (value) => {
273
+ const n = Number(value || 0);
274
+ if (!Number.isFinite(n)) return '0';
275
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
276
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
277
+ return String(n);
278
+ };
279
+
280
+ const getPackEngagement = (pack) => {
281
+ const engagement = pack?.engagement || {};
282
+ const likeCount = Number(engagement.like_count || 0);
283
+ const dislikeCount = Number(engagement.dislike_count || 0);
284
+ const openCount = Number(engagement.open_count || 0);
285
+ const commentCount = Number(engagement.comment_count || pack?.comment_count || 0);
286
+ return {
287
+ likeCount,
288
+ dislikeCount,
289
+ openCount,
290
+ commentCount,
291
+ score: Number(engagement.score || likeCount - dislikeCount),
292
+ };
293
+ };
294
+
295
+ const buildAvatarInitials = (value) => {
296
+ const base = String(value || '')
297
+ .trim()
298
+ .replace(/\s+/g, ' ');
299
+ if (!base) return 'OZ';
300
+ const parts = base.split(' ').filter(Boolean);
301
+ if (!parts.length) return 'OZ';
302
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
303
+ return `${parts[0][0] || ''}${parts[parts.length - 1][0] || ''}`.toUpperCase();
304
+ };
305
+
306
+ const hashText = (value) => {
307
+ let hash = 0;
308
+ const input = String(value || '');
309
+ for (let index = 0; index < input.length; index += 1) {
310
+ hash = (hash * 31 + input.charCodeAt(index)) >>> 0;
311
+ }
312
+ return hash >>> 0;
313
+ };
314
+
315
+ const escapeXml = (value) =>
316
+ String(value || '').replace(/[&<>"']/g, (char) => {
317
+ if (char === '&') return '&amp;';
318
+ if (char === '<') return '&lt;';
319
+ if (char === '>') return '&gt;';
320
+ if (char === '"') return '&quot;';
321
+ return '&apos;';
322
+ });
323
+
324
+ const getAvatarUrl = (name) => {
325
+ const seed = String(name || 'OmniZap').trim() || 'OmniZap';
326
+ const cached = AVATAR_URL_CACHE.get(seed);
327
+ if (cached) return cached;
328
+
329
+ const paletteIndex = hashText(seed) % AVATAR_BG_PALETTE.length;
330
+ const background = AVATAR_BG_PALETTE[paletteIndex];
331
+ const initials = escapeXml(buildAvatarInitials(seed));
332
+ const ariaLabel = escapeXml(seed);
333
+ const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='80' height='80' viewBox='0 0 80 80' role='img' aria-label='${ariaLabel}'><rect width='80' height='80' rx='40' fill='${background}'/><text x='40' y='49' text-anchor='middle' font-family='Segoe UI, Roboto, Arial, sans-serif' font-size='30' font-weight='700' fill='#e2e8f0'>${initials}</text></svg>`;
334
+ const dataUrl = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
335
+
336
+ if (AVATAR_URL_CACHE.size > 800) AVATAR_URL_CACHE.clear();
337
+ AVATAR_URL_CACHE.set(seed, dataUrl);
338
+ return dataUrl;
339
+ };
340
+
341
+ const parseCatalogSearchState = (search = '') => {
342
+ const params = new URLSearchParams(String(search || ''));
343
+ const pageParam = Number.parseInt(String(params.get('page') || ''), 10);
344
+ const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : FIRST_CATALOG_PAGE;
345
+ const filter = String(params.get('filter') || '')
346
+ .trim()
347
+ .toLowerCase();
348
+ const hasTrendingFilter = filter === 'trending';
349
+ const q = hasTrendingFilter ? '' : String(params.get('q') || '').trim();
350
+ const category = hasTrendingFilter
351
+ ? ''
352
+ : String(params.get('category') || params.get('categories') || '')
353
+ .trim()
354
+ .toLowerCase();
355
+
356
+ return {
357
+ q,
358
+ category,
359
+ page,
360
+ filter: hasTrendingFilter ? 'trending' : '',
361
+ sort: hasTrendingFilter ? 'trending' : normalizeCatalogSort(params.get('sort') || ''),
362
+ };
363
+ };
364
+
365
+ const parseCreatorsSearchState = (search = '') => {
366
+ const params = new URLSearchParams(String(search || ''));
367
+ return {
368
+ sort: normalizeCreatorsSort(params.get('sort') || ''),
369
+ q: String(params.get('q') || '').trim(),
370
+ };
371
+ };
372
+
373
+ const parseStickersLocation = (webPath = '/stickers') => {
374
+ const path = String(window.location.pathname || '');
375
+ const basePath = String(webPath || '/stickers').replace(/\/+$/, '') || '/stickers';
376
+ if (path === basePath || path === `${basePath}/`) return { view: 'catalog', packKey: '' };
377
+ const baseWithSlash = `${basePath}/`;
378
+ if (!path.startsWith(baseWithSlash)) return { view: 'catalog', packKey: '' };
379
+ const suffix = path.slice(baseWithSlash.length);
380
+ if (!suffix) return { view: 'catalog', packKey: '' };
381
+
382
+ const firstSegmentRaw = suffix.split('/')[0] || '';
383
+ if (!firstSegmentRaw) return { view: 'catalog', packKey: '' };
384
+
385
+ try {
386
+ const firstSegment = decodeURIComponent(firstSegmentRaw);
387
+ if (
388
+ PROFILE_ROUTE_SEGMENTS.has(
389
+ String(firstSegment || '')
390
+ .trim()
391
+ .toLowerCase(),
392
+ )
393
+ ) {
394
+ return { view: 'profile', packKey: '' };
395
+ }
396
+ if (
397
+ CREATORS_ROUTE_SEGMENTS.has(
398
+ String(firstSegment || '')
399
+ .trim()
400
+ .toLowerCase(),
401
+ )
402
+ ) {
403
+ return { view: 'creators', packKey: '' };
404
+ }
405
+ return { view: 'pack', packKey: firstSegment };
406
+ } catch {
407
+ return { view: 'catalog', packKey: '' };
408
+ }
409
+ };
410
+
411
+ const decodeJwtPayload = (jwt) => {
412
+ const parts = String(jwt || '').split('.');
413
+ if (parts.length < 2) return null;
414
+ try {
415
+ const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/');
416
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
417
+ return JSON.parse(atob(padded));
418
+ } catch {
419
+ return null;
420
+ }
421
+ };
422
+
423
+ const loadScript = (src) =>
424
+ new Promise((resolve, reject) => {
425
+ const existing = document.querySelector(`script[src="${src}"]`);
426
+ if (existing) {
427
+ if (existing.dataset.loaded === '1') {
428
+ resolve();
429
+ return;
430
+ }
431
+ existing.addEventListener('load', () => resolve(), { once: true });
432
+ existing.addEventListener('error', () => reject(new Error(`Falha ao carregar script: ${src}`)), { once: true });
433
+ return;
434
+ }
435
+
436
+ const script = document.createElement('script');
437
+ script.src = src;
438
+ script.async = true;
439
+ script.defer = true;
440
+ script.addEventListener('load', () => {
441
+ script.dataset.loaded = '1';
442
+ resolve();
443
+ });
444
+ script.addEventListener('error', () => reject(new Error(`Falha ao carregar script: ${src}`)));
445
+ document.head.appendChild(script);
446
+ });
447
+
448
+ const readFileAsDataUrl = (file) =>
449
+ new Promise((resolve, reject) => {
450
+ if (!file) {
451
+ reject(new Error('Arquivo inválido.'));
452
+ return;
453
+ }
454
+ const reader = new FileReader();
455
+ reader.onload = () => resolve(String(reader.result || ''));
456
+ reader.onerror = () => reject(new Error('Falha ao ler arquivo.'));
457
+ reader.readAsDataURL(file);
458
+ });
459
+
460
+ const moveArrayItem = (list, fromIndex, toIndex) => {
461
+ const arr = Array.isArray(list) ? [...list] : [];
462
+ if (fromIndex < 0 || toIndex < 0 || fromIndex >= arr.length || toIndex >= arr.length) return arr;
463
+ if (fromIndex === toIndex) return arr;
464
+ const [item] = arr.splice(fromIndex, 1);
465
+ arr.splice(toIndex, 0, item);
466
+ return arr;
467
+ };
468
+
469
+ const parseTagsInputText = (value) =>
470
+ String(value || '')
471
+ .split(',')
472
+ .map((entry) => String(entry || '').trim())
473
+ .filter(Boolean)
474
+ .slice(0, 8);
475
+
476
+ const isRecent = (dateString) => {
477
+ if (!dateString) return false;
478
+ const created = new Date(dateString).getTime();
479
+ if (!Number.isFinite(created)) return false;
480
+ return Date.now() - created <= 1000 * 60 * 60 * 24 * 7;
481
+ };
482
+
483
+ const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0)));
484
+
485
+ const tagLabel = (tag) => {
486
+ const normalized = String(tag || '').toLowerCase();
487
+ if (normalized.includes('nsfw')) return `🔞 ${String(tag).replace(/-/g, ' ').toUpperCase()}`;
488
+ if (normalized.includes('game')) return `🎮 ${String(tag).replace(/-/g, ' ')}`;
489
+ if (normalized.includes('anime')) return `🎌 ${String(tag).replace(/-/g, ' ')}`;
490
+ if (normalized.includes('meme')) return `😂 ${String(tag).replace(/-/g, ' ')}`;
491
+ return `🏷 ${String(tag).replace(/-/g, ' ')}`;
492
+ };
493
+
494
+ const readUploadTask = () => {
495
+ try {
496
+ const raw = localStorage.getItem(PACK_UPLOAD_TASK_KEY);
497
+ if (!raw) return null;
498
+ const parsed = JSON.parse(raw);
499
+ return parsed && typeof parsed === 'object' ? parsed : null;
500
+ } catch {
501
+ return null;
502
+ }
503
+ };
504
+
505
+ function UploadTaskWidget({ task, onClose }) {
506
+ if (!task) return null;
507
+ const progress = Math.max(0, Math.min(100, Number(task.progress || 0)));
508
+ const status = String(task.status || 'running');
509
+ const isDone = status === 'completed';
510
+ const isError = status === 'error';
511
+ const isPaused = status === 'paused';
512
+ const title = isDone ? 'Pack publicado' : isError ? 'Falha na publicação' : isPaused ? 'Publicação pausada' : 'Publicando pack';
513
+ const packUrl = String(task.packUrl || task.pack_url || '').trim();
514
+
515
+ return html`
516
+ <aside className="fixed bottom-4 right-4 z-[70] w-[min(92vw,360px)] rounded-2xl border border-slate-700 bg-slate-950/95 p-3 shadow-2xl backdrop-blur">
517
+ <div className="mb-2 flex items-center justify-between gap-2">
518
+ <p className="text-sm font-bold text-slate-100">${title}</p>
519
+ <button type="button" onClick=${onClose} className="rounded-md border border-slate-700 px-2 py-1 text-[11px] text-slate-300 hover:bg-slate-800">Fechar</button>
520
+ </div>
521
+ <p className="truncate text-xs text-slate-400">${task.message || `${task.current || 0}/${task.total || 0}`}</p>
522
+ <div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-800">
523
+ <div className=${`h-full transition-all ${isError ? 'bg-rose-400' : isDone ? 'bg-emerald-400' : isPaused ? 'bg-amber-400' : 'bg-cyan-400'}`} style=${{ width: `${progress}%` }}></div>
524
+ </div>
525
+ <div className="mt-2 flex items-center justify-between text-[11px]">
526
+ <span className="text-slate-400">${task.current || 0}/${task.total || 0}</span>
527
+ <span className=${`${isError ? 'text-rose-300' : isDone ? 'text-emerald-300' : isPaused ? 'text-amber-300' : 'text-cyan-300'} font-semibold`}> ${progress}% </span>
528
+ </div>
529
+ ${(isDone || isPaused) && packUrl
530
+ ? html`
531
+ <div className="mt-2 flex gap-2">
532
+ <a href=${packUrl} className="inline-flex rounded-lg border border-cyan-500/40 bg-cyan-500/10 px-2.5 py-1.5 text-[11px] font-semibold text-cyan-200">Abrir pack</a>
533
+ ${isPaused ? html`<a href="/stickers/create/" className="inline-flex rounded-lg border border-amber-500/40 bg-amber-500/10 px-2.5 py-1.5 text-[11px] font-semibold text-amber-200">Retomar envio</a>` : null}
534
+ </div>
535
+ `
536
+ : null}
537
+ </aside>
538
+ `;
539
+ }
540
+
541
+ function LazyCatalogImage({ src, alt = '', className = '', eager = false, fallbackSrc = DEFAULT_STICKER_PLACEHOLDER_URL, rootMargin = '180px 0px', threshold = 0.01 }) {
542
+ const imageRef = useRef(null);
543
+ const resolvedSrc = String(src || '').trim() || fallbackSrc;
544
+ const [shouldLoad, setShouldLoad] = useState(() => Boolean(eager));
545
+
546
+ useEffect(() => {
547
+ if (eager || shouldLoad) return;
548
+ if (typeof window === 'undefined' || typeof window.IntersectionObserver !== 'function') {
549
+ setShouldLoad(true);
550
+ return;
551
+ }
552
+ const node = imageRef.current;
553
+ if (!node) {
554
+ setShouldLoad(true);
555
+ return;
556
+ }
557
+ const observer = new window.IntersectionObserver(
558
+ (entries) => {
559
+ const isVisible = entries.some((entry) => entry.isIntersecting || entry.intersectionRatio > 0);
560
+ if (isVisible) {
561
+ setShouldLoad(true);
562
+ observer.disconnect();
563
+ }
564
+ },
565
+ { rootMargin, threshold },
566
+ );
567
+ observer.observe(node);
568
+ return () => observer.disconnect();
569
+ }, [eager, shouldLoad, resolvedSrc, rootMargin, threshold]);
570
+
571
+ return html`<img ref=${imageRef} src=${shouldLoad ? resolvedSrc : LAZY_IMAGE_PLACEHOLDER_DATA_URL} alt=${alt} className=${className} loading=${eager ? 'eager' : 'lazy'} decoding="async" fetchpriority=${eager ? 'high' : 'low'} />`;
572
+ }
573
+
574
+ function PackCard({ pack, index, onOpen, hasNsfwAccess = true, onRequireLogin }) {
575
+ const isTrending = index < 4 || Number(pack?.sticker_count || 0) >= 30;
576
+ const isNew = isRecent(pack?.created_at);
577
+ const engagement = getPackEngagement(pack);
578
+ const lockedByNsfw = isPackMarkedNsfw(pack) && !hasNsfwAccess;
579
+ const coverUrl = lockedByNsfw ? NSFW_STICKER_PLACEHOLDER_URL : pack.cover_preview_url || pack.cover_url || DEFAULT_STICKER_PLACEHOLDER_URL;
580
+ const handleOpen = () => {
581
+ if (lockedByNsfw) {
582
+ onRequireLogin?.();
583
+ return;
584
+ }
585
+ onOpen(pack.pack_key);
586
+ };
587
+
588
+ return html`
589
+ <button type="button" onClick=${handleOpen} className="group w-full text-left rounded-2xl border border-slate-800 bg-slate-900/90 shadow-soft overflow-hidden transition-all duration-200 active:scale-[0.985] md:hover:scale-[1.02] hover:-translate-y-0.5 hover:shadow-lg touch-manipulation">
590
+ <div className="relative aspect-[5/6] sm:aspect-[4/5] bg-slate-900 overflow-hidden">
591
+ <${LazyCatalogImage} src=${coverUrl} alt=${`Capa de ${pack.name}`} className=${`w-full h-full object-cover transition-transform duration-300 ${lockedByNsfw ? 'blur-md scale-105' : 'md:group-hover:scale-[1.05] group-active:scale-[1.02]'}`} />
592
+ <div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/60 to-transparent"></div>
593
+ <div className="absolute top-2 left-2 flex items-center gap-1">${lockedByNsfw ? html`<span className="rounded-full border border-amber-300/35 bg-amber-500/25 backdrop-blur px-1.5 py-0.5 text-[9px] font-bold text-amber-100">🔞 Login</span>` : null} ${isTrending ? html`<span className="rounded-full border border-emerald-300/30 bg-emerald-400/80 backdrop-blur px-1.5 py-0.5 text-[9px] font-bold text-slate-900">Trending</span>` : null} ${isNew ? html`<span className="rounded-full border border-white/15 bg-black/45 backdrop-blur px-1.5 py-0.5 text-[9px] font-semibold text-slate-100">Novo</span>` : null}</div>
594
+
595
+ <div className="absolute inset-x-0 bottom-0 p-2">
596
+ <h3 className="font-semibold text-sm leading-5 line-clamp-2">${pack.name || 'Pack sem nome'}</h3>
597
+ <div className="mt-1 flex items-center gap-1.5 text-[10px] text-slate-300">
598
+ <${LazyCatalogImage} src=${getAvatarUrl(pack.publisher)} alt="Criador" className="w-4 h-4 rounded-full bg-slate-700" />
599
+ <span className="truncate">${pack.publisher || 'Criador não informado'}</span>
600
+ </div>
601
+ <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[10px] text-slate-300">
602
+ <span>🧩 ${Number(pack.sticker_count || 0)}</span>
603
+ <span>❤️ ${shortNum(engagement.likeCount)}</span>
604
+ <span>⬇ ${shortNum(engagement.openCount)}</span>
605
+ </p>
606
+ </div>
607
+
608
+ <div className="pointer-events-none absolute inset-x-2 bottom-2 hidden md:flex justify-center opacity-0 transition-opacity duration-200 group-hover:opacity-100">
609
+ <span className="inline-flex h-8 w-full items-center justify-center rounded-xl border border-emerald-400/35 bg-emerald-400/12 px-3 text-xs font-semibold text-emerald-200 backdrop-blur"> ${lockedByNsfw ? 'Entrar para desbloquear' : 'Abrir pack'} </span>
610
+ </div>
611
+ ${lockedByNsfw
612
+ ? html`
613
+ <div className="pointer-events-none absolute inset-x-0 top-[42%] flex justify-center px-3">
614
+ <span className="inline-flex rounded-xl border border-amber-400/40 bg-slate-950/70 px-2.5 py-1 text-[10px] font-semibold text-amber-100 backdrop-blur"> Conteúdo sensível </span>
615
+ </div>
616
+ `
617
+ : null}
618
+ </div>
619
+
620
+ <div className="px-2 pb-2 pt-1 bg-slate-900/95 md:hidden">
621
+ <span className="inline-flex h-[34px] w-full items-center justify-center rounded-xl border border-emerald-400/30 bg-emerald-400/10 text-xs font-semibold text-emerald-200 transition group-active:brightness-110"> ${lockedByNsfw ? 'Entrar para desbloquear' : 'Abrir pack'} </span>
622
+ </div>
623
+ </button>
624
+ `;
625
+ }
626
+
627
+ function DiscoverPackRowItem({ pack, onOpen, rank = 0, hasNsfwAccess = true, onRequireLogin }) {
628
+ if (!pack?.pack_key) return null;
629
+ const lockedByNsfw = isPackMarkedNsfw(pack) && !hasNsfwAccess;
630
+ const handleOpen = () => {
631
+ if (lockedByNsfw) {
632
+ onRequireLogin?.();
633
+ return;
634
+ }
635
+ onOpen(pack.pack_key);
636
+ };
637
+ return html`
638
+ <button type="button" onClick=${handleOpen} className="w-full flex items-center gap-2 rounded-xl border border-slate-800 bg-slate-900/50 px-2 py-1.5 text-left hover:bg-slate-800/90">
639
+ <${LazyCatalogImage} src=${lockedByNsfw ? NSFW_STICKER_PLACEHOLDER_URL : pack.cover_preview_url || pack.cover_url || DEFAULT_STICKER_PLACEHOLDER_URL} alt="" className=${`h-9 w-9 rounded-lg object-cover bg-slate-800 ${lockedByNsfw ? 'blur-sm' : ''}`} />
640
+ <span className="min-w-0 flex-1">
641
+ <span className="block truncate text-xs font-medium text-slate-100">${rank > 0 ? `${rank}. ` : ''}${pack.name || 'Pack'}</span>
642
+ <span className="block truncate text-[10px] text-slate-400"> ${lockedByNsfw ? '🔒 Entrar para desbloquear' : `${pack.publisher || '-'} · ❤️ ${shortNum(getPackEngagement(pack).likeCount)}`} </span>
643
+ </span>
644
+ <span className="text-[10px] text-slate-500">→</span>
645
+ </button>
646
+ `;
647
+ }
648
+
649
+ function DiscoverPackMiniCard({ pack, onOpen, hasNsfwAccess = true, onRequireLogin }) {
650
+ if (!pack?.pack_key) return null;
651
+ const engagement = getPackEngagement(pack);
652
+ const lockedByNsfw = isPackMarkedNsfw(pack) && !hasNsfwAccess;
653
+ const handleOpen = () => {
654
+ if (lockedByNsfw) {
655
+ onRequireLogin?.();
656
+ return;
657
+ }
658
+ onOpen(pack.pack_key);
659
+ };
660
+ return html`
661
+ <button type="button" onClick=${handleOpen} className="group w-[170px] shrink-0 overflow-hidden rounded-xl border border-slate-800 bg-slate-900/80 text-left">
662
+ <div className="relative h-24 bg-slate-900">
663
+ <${LazyCatalogImage} src=${lockedByNsfw ? NSFW_STICKER_PLACEHOLDER_URL : pack.cover_preview_url || pack.cover_url || DEFAULT_STICKER_PLACEHOLDER_URL} alt="" className=${`h-full w-full object-cover transition-transform duration-200 ${lockedByNsfw ? 'blur-sm scale-105' : 'group-active:scale-[1.02]'}`} />
664
+ <div className="absolute inset-0 bg-gradient-to-t from-slate-950/90 to-transparent"></div>
665
+ ${lockedByNsfw ? html`<span className="absolute top-1.5 left-1.5 rounded-full border border-amber-300/35 bg-amber-500/25 px-1.5 py-0.5 text-[9px] font-semibold text-amber-100">🔞 Login</span>` : null}
666
+ </div>
667
+ <div className="p-2">
668
+ <p className="truncate text-xs font-semibold text-slate-100">${pack.name || 'Pack'}</p>
669
+ <p className="mt-1 truncate text-[10px] text-slate-400">${lockedByNsfw ? 'Entrar para desbloquear' : `⬇ ${shortNum(engagement.openCount)} · ❤️ ${shortNum(engagement.likeCount)}`}</p>
670
+ </div>
671
+ </button>
672
+ `;
673
+ }
674
+
675
+ function DiscoverCreatorMiniCard({ creator, onPick }) {
676
+ if (!creator?.publisher) return null;
677
+ return html`
678
+ <button type="button" onClick=${() => onPick(creator.publisher)} className="w-[190px] shrink-0 rounded-xl border border-slate-800 bg-slate-900/70 p-2 text-left hover:bg-slate-800/90">
679
+ <div className="flex items-center gap-2">
680
+ <${LazyCatalogImage} src=${getAvatarUrl(creator.publisher)} alt="" className="h-9 w-9 rounded-full bg-slate-800" />
681
+ <span className="min-w-0">
682
+ <span className="block truncate text-xs font-semibold text-slate-100">${creator.publisher}</span>
683
+ <span className="block truncate text-[10px] text-slate-400">${creator.packCount} packs · ❤️ ${shortNum(creator.likes)}</span>
684
+ </span>
685
+ </div>
686
+ </button>
687
+ `;
688
+ }
689
+
690
+ const isShareablePack = (pack) => {
691
+ const visibility = String(pack?.visibility || '').toLowerCase();
692
+ const status = String(pack?.status || '').toLowerCase();
693
+ return (visibility === 'public' || visibility === 'unlisted') && status === 'published';
694
+ };
695
+
696
+ const formatVisibilityPill = (visibility) => {
697
+ const normalized = String(visibility || '').toLowerCase();
698
+ if (normalized === 'public')
699
+ return {
700
+ label: '🌍 Público',
701
+ className: 'border-emerald-500/35 bg-emerald-500/10 text-emerald-200',
702
+ };
703
+ if (normalized === 'unlisted')
704
+ return {
705
+ label: '🔗 Não listado',
706
+ className: 'border-cyan-500/35 bg-cyan-500/10 text-cyan-200',
707
+ };
708
+ return { label: '🔒 Privado', className: 'border-slate-600 bg-slate-900/80 text-slate-300' };
709
+ };
710
+
711
+ const formatStatusPill = (status) => {
712
+ const normalized = String(status || '').toLowerCase();
713
+ if (normalized === 'published')
714
+ return {
715
+ label: '✅ Publicado',
716
+ className: 'border-emerald-500/35 bg-emerald-500/10 text-emerald-200',
717
+ };
718
+ if (normalized === 'draft')
719
+ return {
720
+ label: '📝 Rascunho',
721
+ className: 'border-amber-500/35 bg-amber-500/10 text-amber-200',
722
+ };
723
+ if (normalized === 'processing')
724
+ return {
725
+ label: '⚙️ Processando',
726
+ className: 'border-indigo-500/35 bg-indigo-500/10 text-indigo-200',
727
+ };
728
+ if (normalized === 'uploading') return { label: '⏫ Enviando', className: 'border-sky-500/35 bg-sky-500/10 text-sky-200' };
729
+ if (normalized === 'failed') return { label: '❌ Falhou', className: 'border-rose-500/35 bg-rose-500/10 text-rose-200' };
730
+ return {
731
+ label: `ℹ️ ${normalized || 'desconhecido'}`,
732
+ className: 'border-slate-600 bg-slate-900/80 text-slate-300',
733
+ };
734
+ };
735
+
736
+ function ToastStack({ toasts = [], onDismiss }) {
737
+ if (!Array.isArray(toasts) || !toasts.length) return null;
738
+ return html`
739
+ <div className="fixed right-3 top-16 z-[90] flex w-[min(92vw,380px)] flex-col gap-2">
740
+ ${toasts.map(
741
+ (toast) => html`
742
+ <div key=${toast.id} className=${`rounded-2xl border px-3 py-2.5 shadow-xl backdrop-blur ${toast.type === 'error' ? 'border-rose-500/35 bg-rose-500/15 text-rose-100' : toast.type === 'warning' ? 'border-amber-500/35 bg-amber-500/15 text-amber-100' : 'border-emerald-500/35 bg-emerald-500/15 text-emerald-100'}`}>
743
+ <div className="flex items-start justify-between gap-2">
744
+ <p className="text-sm leading-5">${toast.message || ''}</p>
745
+ <button type="button" onClick=${() => onDismiss?.(toast.id)} className="rounded-md border border-white/10 px-1.5 py-0.5 text-[11px] text-white/70 hover:bg-white/10">fechar</button>
746
+ </div>
747
+ </div>
748
+ `,
749
+ )}
750
+ </div>
751
+ `;
752
+ }
753
+
754
+ function ConfirmDialog({ open = false, title = 'Confirmar', message = '', confirmLabel = 'Confirmar', cancelLabel = 'Cancelar', busy = false, danger = false, onCancel, onConfirm }) {
755
+ if (!open) return null;
756
+ return html`
757
+ <div className="fixed inset-0 z-[88] flex items-end justify-center bg-black/60 p-3 sm:items-center">
758
+ <button type="button" className="absolute inset-0" onClick=${busy ? undefined : onCancel} aria-label="Fechar"></button>
759
+ <div className="relative max-h-[90vh] w-full max-w-md overflow-y-auto rounded-2xl border border-slate-700 bg-slate-900 p-4 shadow-2xl">
760
+ <h3 className="text-base font-bold text-slate-100">${title}</h3>
761
+ <p className="mt-2 text-sm text-slate-300">${message}</p>
762
+ <div className="mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
763
+ <button type="button" onClick=${onCancel} disabled=${busy} className="h-10 rounded-xl border border-slate-700 px-4 text-sm text-slate-200 hover:bg-slate-800 disabled:opacity-60">${cancelLabel}</button>
764
+ <button type="button" onClick=${onConfirm} disabled=${busy} className=${`h-10 rounded-xl border px-4 text-sm font-semibold disabled:opacity-60 ${danger ? 'border-rose-500/35 bg-rose-500/15 text-rose-100 hover:bg-rose-500/20' : 'border-emerald-500/35 bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/20'}`}>${busy ? 'Processando...' : confirmLabel}</button>
765
+ </div>
766
+ </div>
767
+ </div>
768
+ `;
769
+ }
770
+
771
+ const PROFILE_PACK_ACTIONS = [
772
+ { key: 'manage', label: '🛠️ Gerenciar pack' },
773
+ { key: 'edit', label: '✏️ Editar pack' },
774
+ { key: 'visibility', label: '👁️ Alterar visibilidade' },
775
+ { key: 'duplicate', label: '📤 Duplicar pack' },
776
+ { key: 'analytics', label: '📊 Ver analytics' },
777
+ { key: 'delete', label: '🗑️ Apagar pack', danger: true },
778
+ ];
779
+
780
+ function PackActionsSheet({ pack, open = false, busyAction = '', onClose, onAction }) {
781
+ if (!open || !pack) return null;
782
+ return html`
783
+ <div className="fixed inset-0 z-[87] flex items-end justify-center bg-black/60 p-2 sm:items-center">
784
+ <button type="button" className="absolute inset-0" onClick=${onClose} aria-label="Fechar"></button>
785
+ <section className="relative max-h-[88vh] w-full max-w-md overflow-y-auto rounded-3xl border border-slate-700 bg-slate-900 p-3 shadow-2xl">
786
+ <div className="mx-auto mb-2 h-1.5 w-10 rounded-full bg-slate-700 sm:hidden"></div>
787
+ <div className="mb-2 flex items-center gap-3 rounded-2xl border border-slate-800 bg-slate-950/60 p-2.5">
788
+ <img src=${pack.cover_url || 'https://iili.io/fSNGag2.png'} alt="" className="h-14 w-14 rounded-xl border border-slate-800 bg-slate-900 object-cover" />
789
+ <div className="min-w-0">
790
+ <p className="truncate text-sm font-semibold text-slate-100">${pack.name || 'Pack'}</p>
791
+ <p className="truncate text-xs text-slate-400">${pack.pack_key || '-'}</p>
792
+ </div>
793
+ </div>
794
+ <div className="space-y-1">
795
+ ${PROFILE_PACK_ACTIONS.map(
796
+ (action) => html`
797
+ <button key=${action.key} type="button" onClick=${() => onAction?.(action.key, pack)} disabled=${Boolean(busyAction)} className=${`w-full rounded-xl border px-3 py-3 text-left text-sm transition disabled:opacity-60 ${action.danger ? 'border-rose-500/25 bg-rose-500/10 text-rose-100 hover:bg-rose-500/15' : 'border-slate-700 bg-slate-950/60 text-slate-100 hover:bg-slate-800'}`}>
798
+ <span>${busyAction === action.key ? '⏳ ' : ''}${action.label}</span>
799
+ </button>
800
+ `,
801
+ )}
802
+ </div>
803
+ <button type="button" onClick=${onClose} className="mt-3 h-10 w-full rounded-xl border border-slate-700 text-sm text-slate-200 hover:bg-slate-800">Fechar</button>
804
+ </section>
805
+ </div>
806
+ `;
807
+ }
808
+
809
+ function PackAnalyticsModal({ open = false, pack = null, data = null, loading = false, error = '', onClose }) {
810
+ if (!open) return null;
811
+ const analytics = data?.analytics || null;
812
+ const publishState = data?.publish_state || null;
813
+ return html`
814
+ <div className="fixed inset-0 z-[86] flex items-end justify-center bg-black/60 p-3 sm:items-center">
815
+ <button type="button" className="absolute inset-0" onClick=${onClose} aria-label="Fechar"></button>
816
+ <section className="relative max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-2xl border border-slate-700 bg-slate-900 p-3 sm:p-4 shadow-2xl">
817
+ <div className="flex items-center justify-between gap-3">
818
+ <div>
819
+ <p className="text-xs uppercase tracking-wide text-slate-400">Analytics</p>
820
+ <h3 className="text-lg font-bold text-slate-100">${pack?.name || 'Pack'}</h3>
821
+ </div>
822
+ <button type="button" onClick=${onClose} className="h-9 rounded-lg border border-slate-700 px-3 text-sm text-slate-200 hover:bg-slate-800">Fechar</button>
823
+ </div>
824
+
825
+ ${loading
826
+ ? html`<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-3">${Array.from({ length: 4 }).map((_, i) => html`<div key=${i} className="h-20 animate-pulse rounded-xl border border-slate-800 bg-slate-950/50"></div>`)}</div>`
827
+ : error
828
+ ? html`<div className="mt-4 rounded-xl border border-rose-500/35 bg-rose-500/10 p-3 text-sm text-rose-200">${error}</div>`
829
+ : html`
830
+ <div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-3">
831
+ <article className="rounded-xl border border-slate-800 bg-slate-950/60 p-3">
832
+ <p className="text-[11px] text-slate-400">Downloads</p>
833
+ <p className="text-lg font-bold text-slate-100">⬇ ${shortNum(analytics?.downloads || 0)}</p>
834
+ </article>
835
+ <article className="rounded-xl border border-slate-800 bg-slate-950/60 p-3">
836
+ <p className="text-[11px] text-slate-400">Likes</p>
837
+ <p className="text-lg font-bold text-slate-100">❤️ ${shortNum(analytics?.likes || 0)}</p>
838
+ </article>
839
+ <article className="rounded-xl border border-slate-800 bg-slate-950/60 p-3">
840
+ <p className="text-[11px] text-slate-400">Dislikes</p>
841
+ <p className="text-lg font-bold text-slate-100">👎 ${shortNum(analytics?.dislikes || 0)}</p>
842
+ </article>
843
+ <article className="rounded-xl border border-slate-800 bg-slate-950/60 p-3">
844
+ <p className="text-[11px] text-slate-400">Score</p>
845
+ <p className="text-lg font-bold text-slate-100">⭐ ${shortNum(analytics?.score || 0)}</p>
846
+ </article>
847
+ </div>
848
+ <div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
849
+ <article className="rounded-xl border border-slate-800 bg-slate-950/60 p-3">
850
+ <p className="text-xs font-semibold text-slate-200">Últimas 24h vs 7 dias</p>
851
+ <div className="mt-2 space-y-1.5 text-xs text-slate-300">
852
+ <p>
853
+ 👆 Aberturas:
854
+ <span className="font-semibold text-slate-100">${shortNum(analytics?.interaction_window?.open_horizon || 0)}</span>
855
+ / ${shortNum(analytics?.interaction_window?.open_baseline || 0)}
856
+ </p>
857
+ <p>
858
+ ❤️ Likes:
859
+ <span className="font-semibold text-slate-100">${shortNum(analytics?.interaction_window?.like_horizon || 0)}</span>
860
+ / ${shortNum(analytics?.interaction_window?.like_baseline || 0)}
861
+ </p>
862
+ <p>
863
+ 👎 Dislikes:
864
+ <span className="font-semibold text-slate-100">${shortNum(analytics?.interaction_window?.dislike_horizon || 0)}</span>
865
+ / ${shortNum(analytics?.interaction_window?.dislike_baseline || 0)}
866
+ </p>
867
+ </div>
868
+ </article>
869
+ <article className="rounded-xl border border-slate-800 bg-slate-950/60 p-3">
870
+ <p className="text-xs font-semibold text-slate-200">Status de publicação</p>
871
+ <div className="mt-2 space-y-1.5 text-xs text-slate-300">
872
+ <p>
873
+ Status:
874
+ <span className="font-semibold text-slate-100">${publishState?.status || '-'}</span>
875
+ </p>
876
+ <p>
877
+ Figurinhas:
878
+ <span className="font-semibold text-slate-100">${shortNum(publishState?.consistency?.sticker_count || 0)}</span>
879
+ </p>
880
+ <p>
881
+ Uploads falhos:
882
+ <span className="font-semibold text-slate-100">${shortNum(publishState?.consistency?.failed_uploads || 0)}</span>
883
+ </p>
884
+ <p>
885
+ Capa válida:
886
+ <span className="font-semibold text-slate-100">${publishState?.consistency?.cover_valid ? 'sim' : 'não'}</span>
887
+ </p>
888
+ </div>
889
+ </article>
890
+ </div>
891
+ `}
892
+ </section>
893
+ </div>
894
+ `;
895
+ }
896
+
897
+ function CreatorPackCardPro({ pack, onOpenPublic, onOpenActions, onOpenManage, onQuickDelete, actionBusy = '' }) {
898
+ const visibilityPill = formatVisibilityPill(pack?.visibility);
899
+ const statusPill = formatStatusPill(pack?.status);
900
+ const engagement = getPackEngagement(pack);
901
+ const shareable = isShareablePack(pack) && Boolean(pack?.pack_key);
902
+ const coverUrl = pack?.cover_url || 'https://iili.io/fSNGag2.png';
903
+ const isCoverHidden = !pack?.cover_url && !isShareablePack(pack);
904
+
905
+ return html`
906
+ <article className="group min-w-0 overflow-hidden rounded-2xl border border-slate-800 bg-slate-900/90 shadow-soft transition hover:-translate-y-0.5 hover:shadow-lg">
907
+ <div className="relative">
908
+ <img src=${coverUrl} alt=${`Capa de ${pack?.name || 'Pack'}`} className="h-24 w-full object-cover bg-slate-950 sm:h-28 md:h-32" loading="lazy" />
909
+ <div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/25 to-transparent"></div>
910
+ <div className="absolute left-2 top-2 flex flex-wrap gap-1.5">
911
+ <span className=${`inline-flex rounded-full border px-2 py-0.5 text-[10px] ${statusPill.className}`}>${statusPill.label}</span>
912
+ <span className=${`inline-flex rounded-full border px-2 py-0.5 text-[10px] ${visibilityPill.className}`}>${visibilityPill.label}</span>
913
+ </div>
914
+ <button type="button" onClick=${() => onOpenActions?.(pack)} className="absolute right-2 top-2 inline-flex h-8 w-8 items-center justify-center rounded-full border border-slate-700/90 bg-slate-950/80 text-slate-100 hover:bg-slate-800" title="Ações">⋮</button>
915
+ ${isCoverHidden ? html`<div className="absolute bottom-2 left-2 rounded-full border border-slate-600 bg-slate-950/80 px-2 py-0.5 text-[10px] text-slate-300">🔒 capa oculta no catálogo</div>` : null}
916
+ </div>
917
+
918
+ <div className="min-w-0 p-2 space-y-1.5 sm:p-2.5 sm:space-y-2">
919
+ <div className="min-w-0">
920
+ <h3 className="truncate text-sm font-bold text-slate-100 sm:text-[15px]">${pack?.name || 'Pack sem nome'}</h3>
921
+ <p className="truncate text-[10px] text-slate-500/90">${pack?.pack_key || '-'}</p>
922
+ </div>
923
+
924
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 rounded-xl border border-slate-800 bg-slate-950/35 px-2 py-1.5">
925
+ <p className="text-[10px] text-slate-300">🧩 ${shortNum(pack?.sticker_count || 0)}</p>
926
+ <p className="text-[10px] text-slate-300">❤️ ${shortNum(engagement.likeCount)}</p>
927
+ <p className="text-[10px] text-slate-300">⬇ ${shortNum(engagement.openCount)}</p>
928
+ <p className="hidden truncate text-[10px] text-slate-400 sm:block">📅 ${pack?.updated_at ? new Date(pack.updated_at).toLocaleDateString('pt-BR') : '-'}</p>
929
+ </div>
930
+
931
+ <div className="grid grid-cols-3 gap-1.5">
932
+ <button type="button" onClick=${() => onOpenManage?.(pack)} disabled=${Boolean(actionBusy)} className="inline-flex h-8 items-center justify-center rounded-lg border border-slate-700 bg-slate-950/60 px-1 text-[11px] text-slate-100 hover:bg-slate-800 disabled:opacity-60" title="Adicionar sticker">
933
+ <span className="sm:hidden">➕</span>
934
+ <span className="hidden sm:inline">➕ Sticker</span>
935
+ </button>
936
+ <button type="button" onClick=${() => onOpenManage?.(pack)} disabled=${Boolean(actionBusy)} className="inline-flex h-8 items-center justify-center rounded-lg border border-slate-700 bg-slate-950/60 px-1 text-[11px] text-slate-100 hover:bg-slate-800 disabled:opacity-60" title="Editar pack">
937
+ <span className="sm:hidden">✏️</span>
938
+ <span className="hidden sm:inline">✏️ Editar</span>
939
+ </button>
940
+ <button type="button" onClick=${() => onQuickDelete?.(pack)} disabled=${Boolean(actionBusy)} className="inline-flex h-8 items-center justify-center rounded-lg border border-rose-500/25 bg-rose-500/10 px-1 text-[11px] text-rose-100 hover:bg-rose-500/15 disabled:opacity-60" title="Excluir pack">
941
+ <span className="sm:hidden">🗑️</span>
942
+ <span className="hidden sm:inline">🗑️ Excluir</span>
943
+ </button>
944
+ </div>
945
+
946
+ <div className="flex gap-2">
947
+ <button type="button" onClick=${() => onOpenManage?.(pack)} disabled=${Boolean(actionBusy)} className="inline-flex h-8 flex-1 items-center justify-center rounded-xl border border-cyan-500/35 bg-cyan-500/10 px-2.5 text-[11px] font-semibold text-cyan-100 hover:bg-cyan-500/20 disabled:opacity-60 sm:h-9 sm:text-xs">${actionBusy === 'manage' ? 'Abrindo...' : 'Gerenciar pack'}</button>
948
+ ${shareable ? html` <button type="button" onClick=${() => onOpenPublic?.(pack.pack_key)} className="inline-flex h-8 items-center justify-center rounded-xl border border-slate-700 bg-slate-950/60 px-2.5 text-[11px] font-medium text-slate-100 hover:bg-slate-800 sm:h-9 sm:px-3 sm:text-xs">Abrir</button> ` : html`<span className="inline-flex h-9 items-center justify-center rounded-xl border border-slate-800 bg-slate-950/40 px-3 text-[11px] text-slate-500">Sem link público</span>`}
949
+ </div>
950
+ </div>
951
+ </article>
952
+ `;
953
+ }
954
+
955
+ function PackManagerModal({ open = false, data = null, loading = false, error = '', busyAction = '', onClose, onRefresh, onSaveMetadata, onAddSticker, onRemoveSticker, onReplaceSticker, onSetCover, onReorder, onOpenAnalytics }) {
956
+ const pack = data?.pack || null;
957
+ const publishState = data?.publish_state || null;
958
+ const analytics = data?.analytics || null;
959
+ const items = Array.isArray(pack?.items) ? pack.items : [];
960
+
961
+ const [name, setName] = useState('');
962
+ const [publisher, setPublisher] = useState('');
963
+ const [description, setDescription] = useState('');
964
+ const [tagsText, setTagsText] = useState('');
965
+ const [visibility, setVisibility] = useState('public');
966
+ const [orderIds, setOrderIds] = useState([]);
967
+ const [draggingId, setDraggingId] = useState('');
968
+
969
+ useEffect(() => {
970
+ if (!pack) return;
971
+ setName(String(pack.name || ''));
972
+ setPublisher(String(pack.publisher || ''));
973
+ setDescription(String(pack.description || ''));
974
+ setTagsText(Array.isArray(pack.manual_tags) ? pack.manual_tags.join(', ') : '');
975
+ setVisibility(String(pack.visibility || 'public'));
976
+ setOrderIds(items.map((item) => item.sticker_id).filter(Boolean));
977
+ }, [pack?.id, pack?.version, items.length]);
978
+
979
+ useEffect(() => {
980
+ if (!pack) return;
981
+ const itemIds = items.map((item) => item.sticker_id).filter(Boolean);
982
+ setOrderIds((prev) => {
983
+ const current = Array.isArray(prev) ? prev.filter((id) => itemIds.includes(id)) : [];
984
+ const missing = itemIds.filter((id) => !current.includes(id));
985
+ return [...current, ...missing];
986
+ });
987
+ }, [pack?.id, items.map((item) => item.sticker_id).join('|')]);
988
+
989
+ if (!open) return null;
990
+
991
+ const orderMap = new Map(items.map((item) => [item.sticker_id, item]));
992
+ const orderedItems = orderIds.map((id) => orderMap.get(id)).filter(Boolean);
993
+ const orderDirty = orderedItems.length === items.length && orderedItems.some((item, index) => String(item?.sticker_id || '') !== String(items[index]?.sticker_id || ''));
994
+
995
+ return html`
996
+ <div className="fixed inset-0 z-[85] flex items-end justify-center bg-black/65 p-2 sm:items-center sm:p-4">
997
+ <button type="button" className="absolute inset-0" onClick=${onClose} aria-label="Fechar"></button>
998
+ <section className="relative flex max-h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-slate-700 bg-slate-900 shadow-2xl sm:h-[min(94vh,960px)] sm:rounded-3xl">
999
+ <header className="border-b border-slate-800 bg-slate-950/70 px-3 py-2.5 sm:px-4 sm:py-3">
1000
+ <div className="flex flex-wrap items-center justify-between gap-2">
1001
+ <div className="min-w-0">
1002
+ <p className="text-xs uppercase tracking-wide text-slate-400">Gerenciar pack</p>
1003
+ <h3 className="truncate text-base font-bold text-slate-100 sm:text-lg">${pack?.name || 'Carregando...'}</h3>
1004
+ <p className="truncate text-xs text-slate-500">${pack?.pack_key || '-'}</p>
1005
+ </div>
1006
+ <div className="flex w-full flex-wrap items-center gap-1.5 sm:w-auto sm:justify-end sm:gap-2">
1007
+ <label className=${`inline-flex h-9 cursor-pointer items-center rounded-xl border border-emerald-500/35 bg-emerald-500/10 px-2.5 text-[11px] font-semibold text-emerald-100 hover:bg-emerald-500/20 sm:h-10 sm:px-3 sm:text-xs ${busyAction ? 'pointer-events-none opacity-60' : ''}`}>
1008
+ <span className="sm:hidden">➕ Sticker</span>
1009
+ <span className="hidden sm:inline">➕ Adicionar sticker</span>
1010
+ <input
1011
+ type="file"
1012
+ accept="image/*,video/mp4,video/webm,video/quicktime,video/x-m4v"
1013
+ className="hidden"
1014
+ disabled=${Boolean(busyAction)}
1015
+ onChange=${(event) => {
1016
+ const file = event.target.files?.[0];
1017
+ if (!file) return;
1018
+ onAddSticker?.(file);
1019
+ event.target.value = '';
1020
+ }}
1021
+ />
1022
+ </label>
1023
+ <button type="button" onClick=${onOpenAnalytics} disabled=${loading || !pack || Boolean(busyAction)} className="h-9 rounded-xl border border-indigo-500/35 bg-indigo-500/10 px-2.5 text-[11px] font-semibold text-indigo-100 hover:bg-indigo-500/20 disabled:opacity-60 sm:h-10 sm:px-3 sm:text-xs">📊 <span className="hidden sm:inline">Analytics</span></button>
1024
+ <button type="button" onClick=${onRefresh} disabled=${Boolean(busyAction) || loading} className="h-9 rounded-xl border border-slate-700 px-2.5 text-[11px] text-slate-100 hover:bg-slate-800 disabled:opacity-60 sm:h-10 sm:px-3 sm:text-xs">${loading ? 'Atualizando...' : 'Atualizar'}</button>
1025
+ <button type="button" onClick=${onClose} disabled=${Boolean(busyAction)} className="ml-auto h-9 rounded-xl border border-slate-700 px-2.5 text-[11px] text-slate-100 hover:bg-slate-800 disabled:opacity-60 sm:ml-0 sm:h-10 sm:px-3 sm:text-xs">Fechar</button>
1026
+ </div>
1027
+ </div>
1028
+ </header>
1029
+
1030
+ <div className="min-h-0 flex-1 overflow-auto overflow-x-hidden p-2.5 sm:p-4">
1031
+ ${loading
1032
+ ? html`
1033
+ <div className="grid gap-4 lg:grid-cols-[340px_minmax(0,1fr)]">
1034
+ <div className="space-y-3">${Array.from({ length: 4 }).map((_, i) => html`<div key=${i} className="h-24 animate-pulse rounded-2xl border border-slate-800 bg-slate-950/50"></div>`)}</div>
1035
+ <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">${Array.from({ length: 8 }).map((_, i) => html`<div key=${i} className="aspect-square animate-pulse rounded-2xl border border-slate-800 bg-slate-950/50"></div>`)}</div>
1036
+ </div>
1037
+ `
1038
+ : error
1039
+ ? html`<div className="rounded-2xl border border-rose-500/35 bg-rose-500/10 p-3 text-sm text-rose-200">${error}</div>`
1040
+ : !pack
1041
+ ? html`<div className="rounded-2xl border border-slate-800 bg-slate-950/50 p-4 text-sm text-slate-300">Pack não carregado.</div>`
1042
+ : html`
1043
+ <div className="grid gap-3 lg:grid-cols-[360px_minmax(0,1fr)]">
1044
+ <aside className="space-y-3">
1045
+ <section className="rounded-2xl border border-slate-800 bg-slate-950/40 p-2.5 sm:p-3 space-y-3">
1046
+ <div className="flex gap-3">
1047
+ <img src=${pack.cover_url || 'https://iili.io/fSNGag2.png'} alt="" className="h-20 w-20 rounded-2xl border border-slate-800 bg-slate-900 object-cover" />
1048
+ <div className="min-w-0 space-y-1">
1049
+ <p className="truncate text-sm font-semibold text-slate-100">${pack.name || 'Pack'}</p>
1050
+ <p className="truncate text-[11px] text-slate-400">${pack.pack_key || '-'}</p>
1051
+ <div className="flex flex-wrap gap-1.5">
1052
+ <span className=${`inline-flex rounded-full border px-2 py-0.5 text-[10px] ${formatStatusPill(pack.status).className}`}>${formatStatusPill(pack.status).label}</span>
1053
+ <span className=${`inline-flex rounded-full border px-2 py-0.5 text-[10px] ${formatVisibilityPill(pack.visibility).className}`}>${formatVisibilityPill(pack.visibility).label}</span>
1054
+ </div>
1055
+ </div>
1056
+ </div>
1057
+ <div className="grid grid-cols-2 gap-2 text-xs">
1058
+ <div className="rounded-xl border border-slate-800 bg-slate-900/60 p-2">
1059
+ <p className="text-slate-400">Stickers</p>
1060
+ <p className="font-semibold text-slate-100">${shortNum(pack.sticker_count || 0)}</p>
1061
+ </div>
1062
+ <div className="rounded-xl border border-slate-800 bg-slate-900/60 p-2">
1063
+ <p className="text-slate-400">Downloads</p>
1064
+ <p className="font-semibold text-slate-100">${shortNum(analytics?.downloads || 0)}</p>
1065
+ </div>
1066
+ <div className="rounded-xl border border-slate-800 bg-slate-900/60 p-2">
1067
+ <p className="text-slate-400">Likes</p>
1068
+ <p className="font-semibold text-slate-100">${shortNum(analytics?.likes || 0)}</p>
1069
+ </div>
1070
+ <div className="rounded-xl border border-slate-800 bg-slate-900/60 p-2">
1071
+ <p className="text-slate-400">Pronto p/ publicar</p>
1072
+ <p className="font-semibold text-slate-100">${publishState?.consistency?.can_publish ? 'Sim' : 'Não'}</p>
1073
+ </div>
1074
+ </div>
1075
+ </section>
1076
+
1077
+ <form
1078
+ onSubmit=${(event) => {
1079
+ event.preventDefault();
1080
+ onSaveMetadata?.({
1081
+ name,
1082
+ publisher,
1083
+ description,
1084
+ tags: parseTagsInputText(tagsText),
1085
+ visibility,
1086
+ });
1087
+ }}
1088
+ className="rounded-2xl border border-slate-800 bg-slate-950/40 p-3 space-y-3"
1089
+ >
1090
+ <div>
1091
+ <label className="mb-1 block text-[11px] uppercase tracking-wide text-slate-400">Nome</label>
1092
+ <input value=${name} onChange=${(e) => setName(e.target.value)} maxlength="120" className="h-11 w-full rounded-xl border border-slate-700 bg-slate-900 px-3 text-sm text-slate-100 outline-none focus:border-cyan-400/40" />
1093
+ </div>
1094
+ <div>
1095
+ <label className="mb-1 block text-[11px] uppercase tracking-wide text-slate-400">Publisher</label>
1096
+ <input value=${publisher} onChange=${(e) => setPublisher(e.target.value)} maxlength="120" className="h-11 w-full rounded-xl border border-slate-700 bg-slate-900 px-3 text-sm text-slate-100 outline-none focus:border-cyan-400/40" />
1097
+ </div>
1098
+ <div>
1099
+ <label className="mb-1 block text-[11px] uppercase tracking-wide text-slate-400">Descrição</label>
1100
+ <textarea value=${description} onChange=${(e) => setDescription(e.target.value)} rows="3" maxlength="1024" className="w-full rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 outline-none focus:border-cyan-400/40"></textarea>
1101
+ </div>
1102
+ <div>
1103
+ <label className="mb-1 block text-[11px] uppercase tracking-wide text-slate-400">Tags (separadas por vírgula)</label>
1104
+ <input value=${tagsText} onChange=${(e) => setTagsText(e.target.value)} placeholder="meme, reaction, anime" className="h-11 w-full rounded-xl border border-slate-700 bg-slate-900 px-3 text-sm text-slate-100 outline-none focus:border-cyan-400/40" />
1105
+ <p className="mt-1 text-[11px] text-slate-500">Salvas no metadata do pack (máx. 8).</p>
1106
+ </div>
1107
+ <div>
1108
+ <label className="mb-1 block text-[11px] uppercase tracking-wide text-slate-400">Visibilidade</label>
1109
+ <select value=${visibility} onChange=${(e) => setVisibility(e.target.value)} className="h-11 w-full rounded-xl border border-slate-700 bg-slate-900 px-3 text-sm text-slate-100 outline-none focus:border-cyan-400/40">
1110
+ <option value="public">Público</option>
1111
+ <option value="unlisted">Não listado</option>
1112
+ <option value="private">Privado</option>
1113
+ </select>
1114
+ </div>
1115
+ <button type="submit" disabled=${Boolean(busyAction)} className="h-11 w-full rounded-xl border border-cyan-500/35 bg-cyan-500/10 text-sm font-semibold text-cyan-100 hover:bg-cyan-500/20 disabled:opacity-60">${busyAction === 'saveMetadata' ? 'Salvando...' : 'Salvar alterações'}</button>
1116
+ </form>
1117
+ </aside>
1118
+
1119
+ <section className="space-y-3 min-w-0">
1120
+ <div className="flex flex-wrap items-center justify-between gap-2">
1121
+ <div>
1122
+ <h4 className="text-base font-bold text-slate-100">Stickers do pack</h4>
1123
+ <p className="text-[11px] text-slate-400">Arraste para reordenar. Use ⭐, 🔁 e ❌.</p>
1124
+ </div>
1125
+ ${orderDirty ? html` <button type="button" onClick=${() => onReorder?.(orderIds)} disabled=${Boolean(busyAction)} className="h-9 rounded-xl border border-amber-500/35 bg-amber-500/10 px-2.5 text-[11px] font-semibold text-amber-100 hover:bg-amber-500/20 disabled:opacity-60 sm:h-10 sm:px-3 sm:text-xs">${busyAction === 'reorder' ? 'Salvando ordem...' : 'Salvar ordem'}</button> ` : null}
1126
+ </div>
1127
+
1128
+ ${orderedItems.length
1129
+ ? html`
1130
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4 xl:grid-cols-5">
1131
+ ${orderedItems.map((item, index) => {
1132
+ const isCover = String(pack?.cover_sticker_id || '') === String(item?.sticker_id || '');
1133
+ return html`
1134
+ <article
1135
+ key=${item.sticker_id}
1136
+ draggable="true"
1137
+ onDragStart=${() => setDraggingId(item.sticker_id)}
1138
+ onDragOver=${(e) => e.preventDefault()}
1139
+ onDrop=${(e) => {
1140
+ e.preventDefault();
1141
+ const fromIndex = orderIds.findIndex((id) => id === draggingId);
1142
+ const toIndex = orderIds.findIndex((id) => id === item.sticker_id);
1143
+ if (fromIndex >= 0 && toIndex >= 0) {
1144
+ setOrderIds(moveArrayItem(orderIds, fromIndex, toIndex));
1145
+ }
1146
+ setDraggingId('');
1147
+ }}
1148
+ className=${`group overflow-hidden rounded-2xl border bg-slate-950/40 ${draggingId === item.sticker_id ? 'border-cyan-400/50' : 'border-slate-800'}`}
1149
+ >
1150
+ <div className="relative aspect-square bg-slate-950">
1151
+ <img src=${item.asset_url || 'https://iili.io/fSNGag2.png'} alt=${item.accessibility_label || 'Sticker'} className="h-full w-full object-contain" loading="lazy" />
1152
+ <div className="absolute left-2 top-2 rounded-full border border-slate-700 bg-slate-950/90 px-2 py-0.5 text-[10px] text-slate-200">#${index + 1}</div>
1153
+ ${isCover ? html`<div className="absolute right-2 top-2 rounded-full border border-amber-400/40 bg-amber-500/15 px-2 py-0.5 text-[10px] font-semibold text-amber-100">⭐ Capa</div>` : null}
1154
+ </div>
1155
+ <div className="p-1.5 space-y-1.5 sm:p-2 sm:space-y-2">
1156
+ <p className="truncate text-[10px] text-slate-500">${item.sticker_id}</p>
1157
+ <div className="grid grid-cols-3 gap-1.5">
1158
+ <button type="button" title="Definir como capa" onClick=${() => onSetCover?.(item.sticker_id)} disabled=${Boolean(busyAction)} className="h-8 rounded-lg border border-amber-500/30 bg-amber-500/10 text-[11px] text-amber-100 hover:bg-amber-500/15 disabled:opacity-60">⭐</button>
1159
+ <label className=${`inline-flex h-8 cursor-pointer items-center justify-center rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-2 text-[11px] text-cyan-100 hover:bg-cyan-500/15 ${busyAction ? 'pointer-events-none opacity-60' : ''}`} title="Substituir sticker">
1160
+ 🔁
1161
+ <input
1162
+ type="file"
1163
+ accept="image/*,video/mp4,video/webm,video/quicktime,video/x-m4v"
1164
+ className="hidden"
1165
+ disabled=${Boolean(busyAction)}
1166
+ onChange=${(event) => {
1167
+ const file = event.target.files?.[0];
1168
+ if (!file) return;
1169
+ onReplaceSticker?.(item.sticker_id, file);
1170
+ event.target.value = '';
1171
+ }}
1172
+ />
1173
+ </label>
1174
+ <button type="button" title="Remover sticker" onClick=${() => onRemoveSticker?.(item.sticker_id)} disabled=${Boolean(busyAction)} className="h-8 rounded-lg border border-rose-500/30 bg-rose-500/10 text-[11px] text-rose-100 hover:bg-rose-500/15 disabled:opacity-60">❌</button>
1175
+ </div>
1176
+ </div>
1177
+ </article>
1178
+ `;
1179
+ })}
1180
+ </div>
1181
+ `
1182
+ : html`<div className="rounded-2xl border border-dashed border-slate-700 bg-slate-950/40 p-6 text-center text-sm text-slate-300">Este pack ainda não possui stickers.</div>`}
1183
+ </section>
1184
+ </div>
1185
+ `}
1186
+ </div>
1187
+
1188
+ <footer className="border-t border-slate-800 bg-slate-950/80 px-3 py-2">
1189
+ <div className="flex flex-col gap-1 text-[11px] text-slate-400 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between sm:gap-2 sm:text-xs">
1190
+ <span className="truncate">${busyAction ? `Processando: ${busyAction}` : 'Pronto para gerenciar.'}</span>
1191
+ <span className="break-words">${publishState?.consistency?.can_publish ? '✅ Pack consistente para publicação' : '⚠️ Revise capa/uploads/stickers antes de publicar'}</span>
1192
+ </div>
1193
+ </footer>
1194
+ </section>
1195
+ </div>
1196
+ `;
1197
+ }
1198
+
1199
+ function CreatorProfileDashboard({ googleAuthConfig, googleAuth, googleAuthBusy, googleAuthError, googleSessionChecked, myPacks, myPacksLoading, myPacksError, myProfileStats, onBack, onRefresh, onLogout, onOpenPublicPack, onOpenPackActions, onOpenManagePack, onRequestDeletePack, packActionBusyByKey = {} }) {
1200
+ const [packSearch, setPackSearch] = useState('');
1201
+ const [packSort, setPackSort] = useState('recent');
1202
+ const [packFilter, setPackFilter] = useState('all');
1203
+ const hasGoogleLogin = Boolean(googleAuth?.user?.sub);
1204
+ const googleLoginEnabled = Boolean(googleAuthConfig?.enabled && googleAuthConfig?.clientId);
1205
+ const packs = Array.isArray(myPacks) ? myPacks : [];
1206
+ const totals = packs.reduce(
1207
+ (acc, pack) => {
1208
+ const engagement = getPackEngagement(pack);
1209
+ acc.downloads += Number(engagement.openCount || 0);
1210
+ acc.likes += Number(engagement.likeCount || 0);
1211
+ acc.stickers += Number(pack?.sticker_count || 0);
1212
+ if (String(pack?.status || '').toLowerCase() === 'published') {
1213
+ acc.publishedStickers += Number(pack?.sticker_count || 0);
1214
+ }
1215
+ return acc;
1216
+ },
1217
+ { downloads: 0, likes: 0, stickers: 0, publishedStickers: 0 },
1218
+ );
1219
+ const filteredSortedPacks = useMemo(() => {
1220
+ const q = normalizeToken(packSearch);
1221
+ const next = packs.filter((pack) => {
1222
+ if (packFilter !== 'all') {
1223
+ const status = String(pack?.status || '').toLowerCase();
1224
+ const visibility = String(pack?.visibility || '').toLowerCase();
1225
+ if (packFilter === 'published' && status !== 'published') return false;
1226
+ if (packFilter === 'draft' && status !== 'draft') return false;
1227
+ if (packFilter === 'private' && visibility !== 'private') return false;
1228
+ if (packFilter === 'unlisted' && visibility !== 'unlisted') return false;
1229
+ }
1230
+ if (!q) return true;
1231
+ const searchable = [pack?.name, pack?.publisher, pack?.pack_key, pack?.description, ...(Array.isArray(pack?.manual_tags) ? pack.manual_tags : [])].map((value) => normalizeToken(value)).join(' ');
1232
+ return searchable.includes(q);
1233
+ });
1234
+
1235
+ next.sort((a, b) => {
1236
+ const ea = getPackEngagement(a);
1237
+ const eb = getPackEngagement(b);
1238
+ if (packSort === 'downloads') return eb.openCount - ea.openCount;
1239
+ if (packSort === 'likes') return eb.likeCount - ea.likeCount;
1240
+ return new Date(b?.updated_at || b?.created_at || 0).getTime() - new Date(a?.updated_at || a?.created_at || 0).getTime();
1241
+ });
1242
+ return next;
1243
+ }, [packs, packSearch, packSort, packFilter]);
1244
+ const visibleCountLabel = `${filteredSortedPacks.length}${packSearch.trim() || packFilter !== 'all' ? ` de ${packs.length}` : ''}`;
1245
+ const profileStatusChips = [
1246
+ { key: 'published', label: '🟢 Publicados', value: Number(myProfileStats?.published || 0) },
1247
+ { key: 'draft', label: '🟡 Rascunhos', value: Number(myProfileStats?.drafts || 0) },
1248
+ { key: 'private', label: '🔒 Privados', value: Number(myProfileStats?.private || 0) },
1249
+ { key: 'unlisted', label: '🔵 Não listados', value: Number(myProfileStats?.unlisted || 0) },
1250
+ ];
1251
+ const packFilterOptions = [
1252
+ { key: 'all', label: 'Todos' },
1253
+ { key: 'published', label: 'Publicados' },
1254
+ { key: 'draft', label: 'Rascunhos' },
1255
+ { key: 'private', label: 'Privados' },
1256
+ { key: 'unlisted', label: 'Não listados' },
1257
+ ];
1258
+
1259
+ return html`
1260
+ <section className="space-y-3 pb-16 sm:pb-4">
1261
+ <div className="flex flex-wrap items-center justify-between gap-2">
1262
+ <button type="button" onClick=${onBack} className="inline-flex items-center gap-2 rounded-xl border border-slate-700 px-3 py-2 text-sm text-slate-200 hover:bg-slate-800">← Catálogo</button>
1263
+ <div className="flex items-center gap-2">
1264
+ <a href="/user/" className="inline-flex h-10 items-center rounded-xl border border-emerald-500/35 bg-emerald-500/10 px-3 text-xs font-semibold text-emerald-100 hover:bg-emerald-500/20">👤 Minha conta</a>
1265
+ <button type="button" onClick=${onRefresh} disabled=${myPacksLoading || googleAuthBusy} className="inline-flex h-10 items-center rounded-xl border border-cyan-500/35 bg-cyan-500/10 px-3 text-xs font-semibold text-cyan-100 hover:bg-cyan-500/20 disabled:opacity-60">${myPacksLoading ? '...' : '⟳'}</button>
1266
+ </div>
1267
+ </div>
1268
+
1269
+ <section className="relative overflow-hidden rounded-3xl border border-slate-800 bg-gradient-to-br from-slate-950 via-slate-900 to-slate-900 p-3.5 sm:p-4">
1270
+ <div className="pointer-events-none absolute -right-10 -top-8 h-40 w-40 rounded-full bg-cyan-400/15 blur-3xl"></div>
1271
+ <div className="pointer-events-none absolute -left-8 bottom-0 h-32 w-32 rounded-full bg-emerald-400/10 blur-3xl"></div>
1272
+ <div className="relative">
1273
+ <div className="flex flex-wrap items-start justify-between gap-3">
1274
+ <div className="flex min-w-0 items-center gap-3">
1275
+ <img src=${googleAuth?.user?.picture || getAvatarUrl(googleAuth?.user?.name || 'creator')} alt="Avatar" className="h-20 w-20 rounded-2xl border border-slate-700 bg-slate-900 object-cover sm:h-24 sm:w-24" />
1276
+ <div className="min-w-0">
1277
+ <div className="mb-1 inline-flex items-center gap-1 rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-100">🧩 Gestão de stickers</div>
1278
+ <h1 className="truncate text-2xl font-extrabold tracking-tight text-slate-100 sm:text-3xl">Gerenciamento de Packs</h1>
1279
+ <p className="truncate text-xs text-slate-400">Organize uploads, capa, ordem e publicação dos seus stickers.</p>
1280
+ <p className="mt-0.5 text-[11px] text-slate-500">Área exclusiva para gerenciamento dos seus packs.</p>
1281
+ </div>
1282
+ </div>
1283
+ ${hasGoogleLogin ? html`<button type="button" onClick=${onLogout} disabled=${googleAuthBusy} className="inline-flex h-10 items-center rounded-xl border border-slate-700 px-3 text-xs text-slate-200 hover:bg-slate-800 disabled:opacity-60">${googleAuthBusy ? 'Saindo...' : 'Sair'}</button>` : null}
1284
+ </div>
1285
+
1286
+ ${hasGoogleLogin
1287
+ ? html`
1288
+ <div className="mt-3 rounded-2xl border border-slate-800 bg-slate-950/35 p-2.5">
1289
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
1290
+ <div className="rounded-xl border border-slate-800 bg-slate-900/50 px-2.5 py-2">
1291
+ <p className="text-[10px] text-slate-400">Packs</p>
1292
+ <p className="text-base font-bold text-slate-100">${shortNum(myProfileStats?.total || 0)}</p>
1293
+ </div>
1294
+ <div className="rounded-xl border border-slate-800 bg-slate-900/50 px-2.5 py-2">
1295
+ <p className="text-[10px] text-slate-400">Downloads</p>
1296
+ <p className="text-base font-bold text-slate-100">${shortNum(totals.downloads)}</p>
1297
+ </div>
1298
+ <div className="rounded-xl border border-slate-800 bg-slate-900/50 px-2.5 py-2">
1299
+ <p className="text-[10px] text-slate-400">Likes</p>
1300
+ <p className="text-base font-bold text-slate-100">${shortNum(totals.likes)}</p>
1301
+ </div>
1302
+ <div className="rounded-xl border border-slate-800 bg-slate-900/50 px-2.5 py-2">
1303
+ <p className="text-[10px] text-slate-400">Stickers publicados</p>
1304
+ <p className="text-base font-bold text-slate-100">${shortNum(totals.publishedStickers)}</p>
1305
+ </div>
1306
+ </div>
1307
+ <div className="mt-2 flex flex-wrap gap-1.5">
1308
+ ${profileStatusChips.map(
1309
+ (chip) => html`
1310
+ <button key=${chip.key} type="button" onClick=${() => setPackFilter((prev) => (prev === chip.key ? 'all' : chip.key))} className=${`inline-flex items-center gap-1 rounded-full border px-2 py-1 text-[11px] ${packFilter === chip.key ? 'border-cyan-400/35 bg-cyan-500/10 text-cyan-100' : 'border-slate-700 bg-slate-900/50 text-slate-300 hover:bg-slate-800'}`}>
1311
+ <span>${chip.label}</span>
1312
+ <span className="font-semibold">${shortNum(chip.value)}</span>
1313
+ </button>
1314
+ `,
1315
+ )}
1316
+ </div>
1317
+ </div>
1318
+ `
1319
+ : null}
1320
+ </div>
1321
+ </section>
1322
+
1323
+ ${!hasGoogleLogin
1324
+ ? html`
1325
+ <section className="rounded-2xl border border-slate-800 bg-slate-900/80 p-3">
1326
+ <div className="space-y-2.5">
1327
+ <div className="rounded-xl border border-cyan-500/20 bg-cyan-500/5 p-3">
1328
+ <p className="text-sm font-semibold text-cyan-200">Sessão necessária para gerenciamento</p>
1329
+ <p className="mt-1 text-xs text-slate-300">${googleLoginEnabled ? 'Esta área é exclusiva para gerenciar seus packs e stickers. Redirecionando para o login...' : 'Login Google indisponível no momento.'}</p>
1330
+ </div>
1331
+ ${!googleSessionChecked ? html`<p className="text-xs text-slate-400">Verificando sessão...</p>` : null} ${googleAuthError ? html`<p className="text-xs text-rose-300">${googleAuthError}</p>` : null}
1332
+ </div>
1333
+ </section>
1334
+ `
1335
+ : html`
1336
+ ${myPacksError ? html`<div className="rounded-2xl border border-rose-500/40 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">${myPacksError}</div>` : null}
1337
+
1338
+ <section className="space-y-2.5">
1339
+ <div className="rounded-2xl border border-slate-800 bg-slate-900/70 p-2.5">
1340
+ <div className="flex flex-wrap items-center justify-between gap-2">
1341
+ <div>
1342
+ <h2 className="text-base font-bold text-slate-100">Packs criados por você</h2>
1343
+ <p className="text-xs text-slate-400">Busca, ordenação e gerenciamento rápido.</p>
1344
+ </div>
1345
+ <span className="text-xs text-slate-400">${myPacksLoading ? 'Carregando...' : `${visibleCountLabel} pack(s)`}</span>
1346
+ </div>
1347
+
1348
+ <div className="mt-2 grid gap-2 md:grid-cols-[minmax(0,1fr)_180px]">
1349
+ <div className="relative">
1350
+ <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-xs text-slate-400">🔎</span>
1351
+ <input type="search" value=${packSearch} onChange=${(e) => setPackSearch(e.target.value)} placeholder="Buscar packs..." className="h-10 w-full rounded-xl border border-slate-700 bg-slate-950/60 pl-9 pr-3 text-sm text-slate-100 placeholder:text-slate-500 outline-none focus:border-cyan-400/40" />
1352
+ </div>
1353
+ <select value=${packSort} onChange=${(e) => setPackSort(e.target.value)} className="h-10 rounded-xl border border-slate-700 bg-slate-950/60 px-3 text-sm text-slate-100 outline-none focus:border-cyan-400/40">
1354
+ <option value="recent">Mais recente</option>
1355
+ <option value="downloads">Mais downloads</option>
1356
+ <option value="likes">Mais likes</option>
1357
+ </select>
1358
+ </div>
1359
+
1360
+ <div className="mt-2 flex flex-wrap gap-1.5">${packFilterOptions.map((option) => html` <button key=${option.key} type="button" onClick=${() => setPackFilter(option.key)} className=${`h-8 rounded-full border px-2.5 text-[11px] ${packFilter === option.key ? 'border-cyan-400/35 bg-cyan-500/10 text-cyan-100' : 'border-slate-700 bg-slate-950/50 text-slate-300 hover:bg-slate-800'}`}>${option.label}</button> `)}</div>
1361
+ </div>
1362
+
1363
+ ${myPacksLoading
1364
+ ? html` <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">${Array.from({ length: 6 }).map((_, index) => html`<div key=${index} className="h-56 animate-pulse rounded-2xl border border-slate-800 bg-slate-900/70"></div>`)}</div> `
1365
+ : filteredSortedPacks.length
1366
+ ? html` <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">${filteredSortedPacks.map((pack) => html` <${CreatorPackCardPro} key=${pack.id || pack.pack_key} pack=${pack} onOpenPublic=${onOpenPublicPack} onOpenActions=${onOpenPackActions} onOpenManage=${onOpenManagePack} onQuickDelete=${onRequestDeletePack} actionBusy=${packActionBusyByKey?.[pack.pack_key] || ''} /> `)}</div> `
1367
+ : html`
1368
+ <div className="rounded-2xl border border-dashed border-slate-700 bg-slate-900/60 p-5 text-center">
1369
+ <p className="text-sm font-semibold text-slate-100">${packs.length ? 'Nenhum pack corresponde aos filtros.' : 'Nenhum pack encontrado para esta conta.'}</p>
1370
+ <p className="mt-1 text-xs text-slate-400">
1371
+ ${packs.length
1372
+ ? 'Tente limpar busca/filtros para ver seus packs.'
1373
+ : html`Crie um pack com essa conta Google em
1374
+ <a href="/stickers/create/" className="text-cyan-300 underline">/stickers/create</a>
1375
+ e volte aqui.`}
1376
+ </p>
1377
+ ${packs.length
1378
+ ? html`<button
1379
+ type="button"
1380
+ onClick=${() => {
1381
+ setPackSearch('');
1382
+ setPackFilter('all');
1383
+ }}
1384
+ className="mt-3 h-9 rounded-xl border border-slate-700 px-3 text-xs text-slate-100 hover:bg-slate-800"
1385
+ >
1386
+ Limpar filtros
1387
+ </button>`
1388
+ : null}
1389
+ </div>
1390
+ `}
1391
+ </section>
1392
+ `}
1393
+ </section>
1394
+ `;
1395
+ }
1396
+
1397
+ function SkeletonGrid({ count = 10 }) {
1398
+ return html`
1399
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 gap-3">
1400
+ ${Array.from({ length: count }).map(
1401
+ (_, index) => html`
1402
+ <div key=${index} className="rounded-2xl border border-slate-700 bg-slate-800 overflow-hidden animate-pulse">
1403
+ <div className="aspect-[4/5] bg-slate-700"></div>
1404
+ <div className="p-2.5 space-y-2">
1405
+ <div className="h-3 rounded bg-slate-700"></div>
1406
+ <div className="h-3 w-2/3 rounded bg-slate-700"></div>
1407
+ <div className="h-11 rounded-xl bg-slate-700"></div>
1408
+ </div>
1409
+ </div>
1410
+ `,
1411
+ )}
1412
+ </div>
1413
+ `;
1414
+ }
1415
+
1416
+ function EmptyState({ onClear }) {
1417
+ return html`
1418
+ <div className="rounded-2xl border border-dashed border-slate-600 bg-slate-800/60 p-10 text-center">
1419
+ <div className="text-5xl mb-2">🧩</div>
1420
+ <p className="text-slate-100 font-semibold">Nenhum pack encontrado</p>
1421
+ <p className="text-slate-400 text-sm mt-1">Tente outra busca ou remova os filtros ativos.</p>
1422
+ <button type="button" onClick=${onClear} className="mt-4 inline-flex items-center justify-center rounded-xl border border-slate-600 px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">Limpar filtros</button>
1423
+ </div>
1424
+ `;
1425
+ }
1426
+
1427
+ function CatalogSortPicker({ open = false, currentSort = DEFAULT_CATALOG_SORT, busy = false, onClose, onSelect }) {
1428
+ if (!open) return null;
1429
+ const selectedSort = normalizeCatalogSort(currentSort);
1430
+
1431
+ return html`
1432
+ <div className="fixed inset-0 z-[85]">
1433
+ <button type="button" className="absolute inset-0 bg-black/55 backdrop-blur-sm" aria-label="Fechar ordenação" onClick=${onClose}></button>
1434
+ <div className="absolute inset-x-0 bottom-0 sm:inset-auto sm:right-4 sm:top-16 sm:w-[360px]">
1435
+ <div className="rounded-t-2xl sm:rounded-2xl border border-slate-700 bg-slate-950/95 p-3 shadow-2xl">
1436
+ <div className="mb-2 flex items-center justify-between gap-2">
1437
+ <div>
1438
+ <p className="text-[11px] uppercase tracking-wide text-slate-400">Ordenar catálogo</p>
1439
+ <p className="text-sm font-semibold text-slate-100">Escolha o tipo de ranking</p>
1440
+ </div>
1441
+ <button type="button" onClick=${onClose} className="h-8 rounded-lg border border-slate-700 px-2.5 text-xs text-slate-300 hover:bg-slate-800 disabled:opacity-60" disabled=${busy}>Fechar</button>
1442
+ </div>
1443
+ <div className="space-y-1.5">
1444
+ ${CATALOG_SORT_OPTIONS.map((option) => {
1445
+ const active = selectedSort === option.value;
1446
+ return html`
1447
+ <button key=${option.value} type="button" onClick=${() => onSelect?.(option.value)} disabled=${busy} className=${`w-full rounded-xl border px-3 py-2.5 text-left transition disabled:opacity-60 ${active ? 'border-emerald-400/40 bg-emerald-500/10 text-emerald-100' : 'border-slate-700 bg-slate-900/70 text-slate-200 hover:bg-slate-800'}`}>
1448
+ <span className="flex items-center justify-between gap-2">
1449
+ <span className="inline-flex items-center gap-2">
1450
+ <span>${option.icon}</span>
1451
+ <span className="text-sm font-medium">${option.label}</span>
1452
+ </span>
1453
+ <span className="text-[10px] text-slate-400">${active ? 'ativo' : ''}</span>
1454
+ </span>
1455
+ </button>
1456
+ `;
1457
+ })}
1458
+ </div>
1459
+ </div>
1460
+ </div>
1461
+ </div>
1462
+ `;
1463
+ }
1464
+
1465
+ function PackPageSkeleton() {
1466
+ return html`
1467
+ <section className="space-y-4 animate-pulse">
1468
+ <div className="h-10 w-40 rounded-xl border border-slate-700 bg-slate-800/70"></div>
1469
+ <div className="overflow-hidden rounded-2xl border border-slate-700 bg-slate-900/70">
1470
+ <div className="h-56 sm:h-64 bg-slate-800"></div>
1471
+ <div className="p-4 space-y-3">
1472
+ <div className="h-6 w-2/3 rounded bg-slate-800"></div>
1473
+ <div className="h-4 w-1/2 rounded bg-slate-800"></div>
1474
+ <div className="h-10 rounded-xl bg-slate-800"></div>
1475
+ <div className="h-9 rounded-xl bg-slate-800"></div>
1476
+ <div className="h-20 rounded-xl bg-slate-800"></div>
1477
+ </div>
1478
+ </div>
1479
+ <div className="grid grid-cols-3 gap-2 sm:gap-3">${Array.from({ length: 9 }).map((_, index) => html`<div key=${index} className="aspect-square rounded-xl border border-slate-700 bg-slate-800"></div>`)}</div>
1480
+ </section>
1481
+ `;
1482
+ }
1483
+
1484
+ function CreatorRankingSkeleton({ count = 8 }) {
1485
+ return html`
1486
+ <div className="space-y-2">
1487
+ ${Array.from({ length: count }).map(
1488
+ (_, index) => html`
1489
+ <div key=${index} className="animate-pulse rounded-2xl border border-slate-800 bg-slate-900/70 p-3">
1490
+ <div className="flex items-center gap-3">
1491
+ <div className="h-12 w-12 rounded-full bg-slate-800"></div>
1492
+ <div className="min-w-0 flex-1 space-y-2">
1493
+ <div className="h-4 w-40 rounded bg-slate-800"></div>
1494
+ <div className="h-3 w-56 rounded bg-slate-800"></div>
1495
+ </div>
1496
+ <div className="h-9 w-24 rounded-xl bg-slate-800"></div>
1497
+ </div>
1498
+ </div>
1499
+ `,
1500
+ )}
1501
+ </div>
1502
+ `;
1503
+ }
1504
+
1505
+ function CreatorsRankingPage({ creators = [], loading = false, error = '', sort = DEFAULT_CREATORS_SORT, onSortChange, onBack, onRetry, onOpenCreator, onOpenPack }) {
1506
+ const selectedSort = normalizeCreatorsSort(sort);
1507
+
1508
+ return html`
1509
+ <section className="space-y-4">
1510
+ <div className="flex flex-wrap items-center justify-between gap-2">
1511
+ <button type="button" onClick=${onBack} className="inline-flex h-10 items-center gap-2 rounded-xl border border-slate-700 px-3 text-sm text-slate-200 hover:bg-slate-800">← Voltar para catálogo</button>
1512
+ <div className="flex items-center gap-2">
1513
+ <span className="text-xs text-slate-400">Ordenar</span>
1514
+ <select value=${selectedSort} onChange=${(event) => onSortChange?.(event.target.value)} className="h-9 rounded-xl border border-slate-700 bg-slate-900 px-3 text-xs text-slate-200 outline-none">
1515
+ <option value="popular">Popular</option>
1516
+ <option value="likes">Likes</option>
1517
+ <option value="downloads">Downloads</option>
1518
+ <option value="packs">Packs</option>
1519
+ <option value="name">Nome</option>
1520
+ </select>
1521
+ </div>
1522
+ </div>
1523
+
1524
+ <div className="rounded-2xl border border-slate-800 bg-slate-900/70 p-3 sm:p-4">
1525
+ <p className="text-[11px] uppercase tracking-wide text-slate-400">Criadores populares</p>
1526
+ <h1 className="mt-1 text-lg sm:text-xl font-bold text-slate-100">Ranking de criadores</h1>
1527
+ <p className="text-xs text-slate-400">Avatar, packs publicados, downloads, likes e acesso rápido ao perfil.</p>
1528
+ </div>
1529
+
1530
+ ${error
1531
+ ? html`
1532
+ <div className="rounded-2xl border border-rose-500/30 bg-rose-500/10 p-4">
1533
+ <p className="text-sm font-semibold text-rose-100">Falha ao carregar criadores</p>
1534
+ <p className="mt-1 text-xs text-rose-200/90">${error}</p>
1535
+ <button type="button" onClick=${onRetry} className="mt-3 inline-flex h-9 items-center rounded-xl border border-rose-400/40 px-3 text-xs text-rose-100 hover:bg-rose-500/10">Tentar novamente</button>
1536
+ </div>
1537
+ `
1538
+ : null}
1539
+ ${loading ? html`<${CreatorRankingSkeleton} />` : null}
1540
+ ${!loading && !error && !creators.length
1541
+ ? html`
1542
+ <div className="rounded-2xl border border-dashed border-slate-700 bg-slate-900/50 p-8 text-center">
1543
+ <p className="text-sm font-semibold text-slate-100">Nenhum criador encontrado</p>
1544
+ <p className="mt-1 text-xs text-slate-400">Tente atualizar a página ou voltar ao catálogo.</p>
1545
+ </div>
1546
+ `
1547
+ : null}
1548
+ ${!loading && creators.length
1549
+ ? html`
1550
+ <div className="space-y-2">
1551
+ ${creators.map(
1552
+ (creator, index) => html`
1553
+ <article key=${creator.key || creator.publisher || index} className="fade-card rounded-2xl border border-slate-800 bg-slate-900/65 p-3">
1554
+ <div className="flex items-center gap-3">
1555
+ <img src=${creator.avatarUrl || getAvatarUrl(creator.publisher)} alt="" className="h-12 w-12 rounded-full bg-slate-800 border border-slate-700" loading="lazy" />
1556
+ <div className="min-w-0 flex-1">
1557
+ <div className="flex items-center gap-2">
1558
+ <p className="truncate text-sm font-semibold text-slate-100">${creator.publisher || 'Criador'}</p>
1559
+ ${creator.verified ? html`<span className="rounded-full border border-cyan-400/30 bg-cyan-500/10 px-2 py-0.5 text-[10px] text-cyan-200">Verificado</span>` : null}
1560
+ </div>
1561
+ <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-400">
1562
+ <span>📦 ${shortNum(creator.packCount || 0)} packs</span>
1563
+ <span>⬇ ${shortNum(creator.downloads || 0)} downloads</span>
1564
+ <span>❤️ ${shortNum(creator.likes || 0)} likes</span>
1565
+ </div>
1566
+ ${creator.topPack?.name ? html` <button type="button" onClick=${() => onOpenPack?.(creator.topPack?.pack_key)} className="mt-1 text-[11px] text-cyan-300 hover:text-cyan-200">Top pack: ${creator.topPack.name}</button> ` : null}
1567
+ </div>
1568
+ <button type="button" onClick=${() => onOpenCreator?.(creator)} className="shrink-0 h-9 rounded-xl border border-emerald-500/35 bg-emerald-500/10 px-3 text-xs font-semibold text-emerald-100 hover:bg-emerald-500/20">Ver perfil</button>
1569
+ </div>
1570
+ </article>
1571
+ `,
1572
+ )}
1573
+ </div>
1574
+ `
1575
+ : null}
1576
+ </section>
1577
+ `;
1578
+ }
1579
+
1580
+ function StickerPreview({ item, onClose, onPrev, onNext, hasNsfwAccess = true, onRequireLogin }) {
1581
+ if (!item) return null;
1582
+ const lockedByNsfw = isStickerMarkedNsfw(item) && !hasNsfwAccess;
1583
+ const stickerPreviewSrc = item?.asset_preview_url || item?.asset_url || DEFAULT_STICKER_PLACEHOLDER_URL;
1584
+
1585
+ const handleCopy = async () => {
1586
+ if (!item?.asset_url) return;
1587
+ try {
1588
+ await navigator.clipboard.writeText(item.asset_url);
1589
+ } catch {
1590
+ // ignore clipboard API errors
1591
+ }
1592
+ };
1593
+
1594
+ return html`
1595
+ <div className="fixed inset-0 z-[60] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
1596
+ <button type="button" className="absolute inset-0" aria-label="Fechar preview" onClick=${onClose}></button>
1597
+
1598
+ <div className="relative w-full max-w-xl rounded-2xl border border-slate-700 bg-slate-900 p-3">
1599
+ <img src=${lockedByNsfw ? NSFW_STICKER_PLACEHOLDER_URL : stickerPreviewSrc} alt=${item.accessibility_label || 'Sticker'} className=${`w-full max-h-[70vh] object-contain rounded-xl bg-slate-950 ${lockedByNsfw ? 'blur-md' : ''}`} />
1600
+ ${lockedByNsfw
1601
+ ? html`
1602
+ <div className="absolute inset-x-3 top-3 bottom-[64px] flex items-center justify-center rounded-xl bg-slate-950/40 p-4">
1603
+ <div className="max-w-sm rounded-xl border border-amber-500/35 bg-slate-950/80 px-4 py-3 text-center">
1604
+ <p className="text-sm font-semibold text-amber-100">Conteúdo sensível</p>
1605
+ <p className="mt-1 text-xs text-slate-300">Faça login com Google para desbloquear este sticker.</p>
1606
+ <button type="button" onClick=${() => onRequireLogin?.()} className="mt-3 inline-flex h-9 items-center rounded-xl border border-amber-400/40 bg-amber-500/15 px-3 text-xs font-semibold text-amber-100">Entrar e desbloquear</button>
1607
+ </div>
1608
+ </div>
1609
+ `
1610
+ : null}
1611
+
1612
+ <div className="mt-3 flex items-center justify-between gap-2">
1613
+ <button type="button" onClick=${onPrev} className="rounded-lg border border-slate-600 px-3 py-2 text-sm text-slate-200 hover:bg-slate-800">← Anterior</button>
1614
+ <div className="flex items-center gap-2">
1615
+ ${lockedByNsfw ? null : html`<button type="button" onClick=${handleCopy} className="rounded-lg border border-slate-600 px-3 py-2 text-sm text-slate-200 hover:bg-slate-800">Copiar link</button>`}
1616
+ <button type="button" onClick=${onClose} className="rounded-lg border border-slate-600 px-3 py-2 text-sm text-slate-200 hover:bg-slate-800">Fechar</button>
1617
+ </div>
1618
+ <button type="button" onClick=${onNext} className="rounded-lg border border-slate-600 px-3 py-2 text-sm text-slate-200 hover:bg-slate-800">Próximo →</button>
1619
+ </div>
1620
+ </div>
1621
+ </div>
1622
+ `;
1623
+ }
1624
+
1625
+ function PackPage({ pack, relatedPacks, relatedLoading = false, onLoadRelated, onBack, onOpenRelated, onLike, onDislike, onTagClick, reactionLoading = '', reactionNotice = null, hasNsfwAccess = true, onRequireLogin }) {
1626
+ if (!pack) {
1627
+ return html`
1628
+ <section className="space-y-4">
1629
+ <button type="button" onClick=${onBack} className="inline-flex items-center gap-2 rounded-xl border border-slate-700 px-3 py-2 text-sm text-slate-200 hover:bg-slate-800">← Voltar para catálogo</button>
1630
+ <div className="rounded-2xl border border-dashed border-slate-700 bg-slate-900/50 p-8 text-center">
1631
+ <p className="text-sm font-semibold text-slate-100">Pack não encontrado</p>
1632
+ <p className="mt-1 text-xs text-slate-400">Tente voltar ao catálogo e abrir novamente.</p>
1633
+ </div>
1634
+ </section>
1635
+ `;
1636
+ }
1637
+
1638
+ const items = Array.isArray(pack?.items) ? pack.items : [];
1639
+ const tags = Array.isArray(pack?.tags) ? pack.tags : [];
1640
+ const packLockedByNsfw = isPackMarkedNsfw(pack) && !hasNsfwAccess;
1641
+ const cover = packLockedByNsfw ? NSFW_STICKER_PLACEHOLDER_URL : pack?.cover_preview_url || pack?.cover_url || items?.[0]?.asset_preview_url || items?.[0]?.asset_url || DEFAULT_STICKER_PLACEHOLDER_URL;
1642
+ const whatsappUrl = String(pack?.whatsapp?.url || '').trim();
1643
+ const engagement = getPackEngagement(pack);
1644
+ const hasReactionRequest = Boolean(reactionLoading);
1645
+ const initialVisibleCount = useMemo(() => resolveInitialPackStickerLimit(), []);
1646
+ const [visibleStickerCount, setVisibleStickerCount] = useState(initialVisibleCount);
1647
+ const [previewIndex, setPreviewIndex] = useState(-1);
1648
+ const visibleItems = useMemo(() => items.slice(0, Math.max(1, Number(visibleStickerCount || 0))), [items, visibleStickerCount]);
1649
+ const hasMoreVisibleItems = visibleItems.length < items.length;
1650
+ const currentPreviewItem = previewIndex >= 0 ? visibleItems[previewIndex] : null;
1651
+
1652
+ useEffect(() => {
1653
+ setVisibleStickerCount(initialVisibleCount);
1654
+ setPreviewIndex(-1);
1655
+ }, [pack?.pack_key, initialVisibleCount]);
1656
+
1657
+ return html`
1658
+ <section className="space-y-4 pb-4">
1659
+ <button type="button" onClick=${onBack} className="inline-flex h-10 items-center gap-2 rounded-xl border border-slate-700 bg-slate-900/70 px-3 text-sm text-slate-200 hover:bg-slate-800">← Voltar para catálogo</button>
1660
+
1661
+ <article className="overflow-hidden rounded-2xl border border-slate-700 bg-slate-900/80 shadow-[0_18px_40px_rgba(2,6,23,0.25)]">
1662
+ <div className="relative h-52 sm:h-64 md:h-72 bg-slate-900">
1663
+ <img src=${cover} alt=${`Capa ${pack?.name || 'Pack'}`} className=${`h-full w-full object-cover ${packLockedByNsfw ? 'blur-md scale-105' : ''}`} loading="lazy" />
1664
+ <div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/55 to-transparent"></div>
1665
+ ${packLockedByNsfw ? html`<div className="absolute top-3 left-3 rounded-full border border-amber-300/40 bg-amber-500/25 px-2 py-1 text-[10px] font-semibold text-amber-100">🔞 Conteúdo sensível</div>` : null}
1666
+ <div className="absolute inset-x-0 bottom-0 p-4 sm:p-5">
1667
+ <div className="max-w-4xl">
1668
+ <p className="text-[11px] uppercase tracking-wide text-slate-300/90">Pack público</p>
1669
+ <h1 className="mt-1 text-xl sm:text-2xl font-extrabold tracking-tight text-white drop-shadow-[0_2px_12px_rgba(0,0,0,0.35)]">${pack?.name || 'Pack'}</h1>
1670
+ <p className="mt-1 text-xs sm:text-sm text-slate-200/90">${pack?.publisher || '-'} · ${pack?.created_at ? new Date(pack.created_at).toLocaleDateString('pt-BR') : 'sem data'}</p>
1671
+ </div>
1672
+ </div>
1673
+ </div>
1674
+
1675
+ <div className="space-y-3 p-4 sm:p-5">
1676
+ ${packLockedByNsfw
1677
+ ? html`
1678
+ <div className="rounded-xl border border-amber-500/35 bg-amber-500/10 p-3">
1679
+ <p className="text-sm font-semibold text-amber-100">Este pack foi marcado como sensível (+18).</p>
1680
+ <p className="mt-1 text-xs text-slate-300">Faça login com Google para visualizar os stickers.</p>
1681
+ <button type="button" onClick=${() => onRequireLogin?.()} className="mt-3 inline-flex h-9 items-center rounded-xl border border-amber-400/35 bg-amber-500/15 px-3 text-xs font-semibold text-amber-100">Entrar para desbloquear</button>
1682
+ </div>
1683
+ `
1684
+ : null}
1685
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl border border-slate-800 bg-slate-950/35 px-3 py-2 text-sm">
1686
+ <span className="text-slate-200">👍 <strong className="font-semibold">${shortNum(engagement.likeCount)}</strong></span>
1687
+ <span className="text-slate-200">👎 <strong className="font-semibold">${shortNum(engagement.dislikeCount)}</strong></span>
1688
+ <span className="text-slate-200">🧩 <strong className="font-semibold">${shortNum(Number(pack?.sticker_count || items.length))}</strong></span>
1689
+ <span className="text-slate-200">👁 <strong className="font-semibold">${shortNum(engagement.openCount)}</strong></span>
1690
+ </div>
1691
+
1692
+ ${whatsappUrl && !packLockedByNsfw ? html` <a href=${whatsappUrl} target="_blank" rel="noreferrer noopener" className="inline-flex h-11 w-full items-center justify-center rounded-xl bg-[#25D366] px-5 text-sm font-bold text-[#042d17] shadow-[0_8px_24px_rgba(37,211,102,0.30)] transition hover:brightness-95"> 🟢 Adicionar no WhatsApp </a> ` : null}
1693
+
1694
+ <div className="flex flex-wrap items-center gap-2">
1695
+ <button type="button" onClick=${() => onLike?.(pack?.pack_key)} disabled=${hasReactionRequest || packLockedByNsfw} className="inline-flex h-9 items-center justify-center rounded-xl border border-emerald-500/35 bg-emerald-500/10 px-3 text-xs font-semibold text-emerald-100 transition hover:bg-emerald-500/20 disabled:cursor-not-allowed disabled:opacity-60">${reactionLoading === 'like' ? 'Enviando...' : 'Curtir'}</button>
1696
+ <button type="button" onClick=${() => onDislike?.(pack?.pack_key)} disabled=${hasReactionRequest || packLockedByNsfw} className="inline-flex h-9 items-center justify-center rounded-xl border border-rose-500/35 bg-rose-500/10 px-3 text-xs font-semibold text-rose-100 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-60">${reactionLoading === 'dislike' ? 'Enviando...' : 'Não curtir'}</button>
1697
+ ${reactionNotice?.message ? html` <span className=${`text-xs ${reactionNotice.type === 'error' ? 'text-rose-300' : 'text-emerald-300'}`}> ${reactionNotice.message} </span> ` : null}
1698
+ </div>
1699
+
1700
+ ${pack?.description ? html`<p className="text-sm leading-6 text-slate-300">${pack.description}</p>` : null}
1701
+ ${tags.length
1702
+ ? html`
1703
+ <div className="space-y-1">
1704
+ <p className="text-[11px] uppercase tracking-wide text-slate-500">Tags</p>
1705
+ <div className="chips-scroll -mx-1 flex gap-2 overflow-x-auto px-1 pb-1">${tags.slice(0, 12).map((tag) => html` <button key=${tag} type="button" onClick=${() => onTagClick?.(tag)} className="chip-item shrink-0 rounded-full border border-slate-700 bg-slate-900/90 px-2.5 py-1 text-[11px] text-slate-200 hover:border-cyan-400/30 hover:bg-cyan-500/10">${tagLabel(tag)}</button> `)}</div>
1706
+ </div>
1707
+ `
1708
+ : null}
1709
+ </div>
1710
+ </article>
1711
+
1712
+ <section className="space-y-2.5">
1713
+ <div className="flex items-center justify-between gap-2">
1714
+ <h2 className="text-lg font-bold">Stickers do pack</h2>
1715
+ <span className="text-xs text-slate-400">${visibleItems.length}/${items.length} itens</span>
1716
+ </div>
1717
+
1718
+ ${packLockedByNsfw
1719
+ ? html`
1720
+ <div className="rounded-2xl border border-dashed border-amber-500/35 bg-slate-900/55 p-8 text-center">
1721
+ <p className="text-sm font-semibold text-amber-100">Stickers bloqueados por conteúdo sensível.</p>
1722
+ <p className="mt-1 text-xs text-slate-300">Entre com Google para liberar a visualização deste pack.</p>
1723
+ <button type="button" onClick=${() => onRequireLogin?.()} className="mt-3 inline-flex h-9 items-center rounded-xl border border-amber-400/35 bg-amber-500/15 px-3 text-xs font-semibold text-amber-100">Entrar e desbloquear</button>
1724
+ </div>
1725
+ `
1726
+ : visibleItems.length
1727
+ ? html`
1728
+ <div className="pack-stickers-grid gap-2 sm:gap-3">
1729
+ ${visibleItems.map((item, index) => {
1730
+ const stickerLockedByNsfw = isStickerMarkedNsfw(item) && !hasNsfwAccess;
1731
+ const stickerSrc = item?.asset_preview_url || item?.asset_url || DEFAULT_STICKER_PLACEHOLDER_URL;
1732
+ return html`
1733
+ <button key=${item.sticker_id || item.position || index} type="button" onClick=${() => (stickerLockedByNsfw ? onRequireLogin?.() : setPreviewIndex(index))} className="pack-sticker-card group relative overflow-hidden rounded-xl border border-slate-800 bg-slate-900/80 text-left transition hover:-translate-y-0.5 hover:border-slate-600">
1734
+ <${LazyCatalogImage} src=${stickerLockedByNsfw ? NSFW_STICKER_PLACEHOLDER_URL : stickerSrc} alt=${item.accessibility_label || 'Sticker'} className=${`w-full aspect-square object-contain bg-slate-950 transition-transform duration-300 ${stickerLockedByNsfw ? 'blur-md scale-105' : 'group-hover:scale-105'}`} rootMargin="40px 0px" threshold=${0.05} />
1735
+ ${stickerLockedByNsfw
1736
+ ? html`
1737
+ <div className="absolute inset-0 flex items-center justify-center bg-slate-950/35 p-2">
1738
+ <span className="rounded-lg border border-amber-400/35 bg-amber-500/15 px-2 py-1 text-[10px] font-semibold text-amber-100"> Entrar para desbloquear </span>
1739
+ </div>
1740
+ `
1741
+ : null}
1742
+ </button>
1743
+ `;
1744
+ })}
1745
+ </div>
1746
+ ${hasMoreVisibleItems
1747
+ ? html`
1748
+ <div className="flex justify-center pt-1">
1749
+ <button type="button" onClick=${() => setVisibleStickerCount((prev) => Math.min(items.length, Math.max(1, Number(prev || 0)) + PACK_STICKERS_LOAD_STEP))} className="inline-flex h-9 items-center rounded-xl border border-slate-700 bg-slate-900/80 px-3 text-xs text-slate-200 hover:bg-slate-800">Carregar mais (${items.length - visibleItems.length} restantes)</button>
1750
+ </div>
1751
+ `
1752
+ : null}
1753
+ `
1754
+ : html`
1755
+ <div className="rounded-2xl border border-dashed border-slate-700 bg-slate-900/40 p-8 text-center">
1756
+ <p className="text-sm font-semibold text-slate-100">Este pack ainda não tem stickers visíveis.</p>
1757
+ <p className="mt-1 text-xs text-slate-400">Volte mais tarde ou abra outro pack do catálogo.</p>
1758
+ </div>
1759
+ `}
1760
+ </section>
1761
+
1762
+ ${relatedPacks.length || typeof onLoadRelated === 'function' || relatedLoading
1763
+ ? html`
1764
+ <section className="space-y-3">
1765
+ <div className="flex items-center justify-between gap-2">
1766
+ <h2 className="text-lg font-bold">Packs relacionados</h2>
1767
+ <span className="text-xs text-slate-500">${relatedPacks.length ? `${relatedPacks.length} sugestões` : 'sob demanda'}</span>
1768
+ </div>
1769
+ ${relatedPacks.length
1770
+ ? html`<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2.5 sm:gap-3">
1771
+ ${relatedPacks.map(
1772
+ (entry, index) =>
1773
+ html`<div key=${entry.pack_key || entry.id} className="fade-card">
1774
+ <${PackCard} pack=${entry} index=${index} onOpen=${onOpenRelated} hasNsfwAccess=${hasNsfwAccess} onRequireLogin=${onRequireLogin} />
1775
+ </div>`,
1776
+ )}
1777
+ </div>`
1778
+ : html`
1779
+ <div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4 text-center">
1780
+ <button type="button" onClick=${() => onLoadRelated?.()} disabled=${relatedLoading} className="inline-flex h-9 items-center rounded-xl border border-slate-700 bg-slate-900 px-3 text-xs text-slate-200 hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60">${relatedLoading ? 'Carregando relacionados...' : 'Carregar packs relacionados'}</button>
1781
+ </div>
1782
+ `}
1783
+ </section>
1784
+ `
1785
+ : null}
1786
+ ${previewIndex >= 0 ? html` <${StickerPreview} item=${currentPreviewItem} onClose=${() => setPreviewIndex(-1)} onPrev=${() => setPreviewIndex((value) => (value <= 0 ? visibleItems.length - 1 : value - 1))} onNext=${() => setPreviewIndex((value) => (value >= visibleItems.length - 1 ? 0 : value + 1))} hasNsfwAccess=${hasNsfwAccess} onRequireLogin=${onRequireLogin} /> ` : null}
1787
+ </section>
1788
+ `;
1789
+ }
1790
+
1791
+ function StickersApp() {
1792
+ const root = document.getElementById('stickers-react-root');
1793
+ const config = useMemo(
1794
+ () => ({
1795
+ webPath: root?.dataset.webPath || '/stickers',
1796
+ apiBasePath: root?.dataset.apiBasePath || '/api/sticker-packs',
1797
+ loginPath: root?.dataset.loginPath || '/login',
1798
+ limit: resolveCatalogPageLimit(root?.dataset.defaultLimit),
1799
+ }),
1800
+ [root],
1801
+ );
1802
+ const initialRoute = parseStickersLocation(config.webPath);
1803
+ const initialCatalogSearch = parseCatalogSearchState(window.location.search);
1804
+ const initialCreatorsSearch = parseCreatorsSearchState(window.location.search);
1805
+
1806
+ const [query, setQuery] = useState(initialCatalogSearch.q || '');
1807
+ const [appliedQuery, setAppliedQuery] = useState(initialCatalogSearch.q || '');
1808
+ const [sortBy, setSortBy] = useState(normalizeCatalogSort(initialCatalogSearch.sort || DEFAULT_CATALOG_SORT));
1809
+ const [activeCategory, setActiveCategory] = useState(initialCatalogSearch.category || '');
1810
+ const [catalogFilter, setCatalogFilter] = useState(initialCatalogSearch.filter || '');
1811
+ const [catalogPage, setCatalogPage] = useState(Math.max(FIRST_CATALOG_PAGE, Number(initialCatalogSearch.page || FIRST_CATALOG_PAGE)));
1812
+ const [discoverTab, setDiscoverTab] = useState('growing');
1813
+ const [showAutocomplete, setShowAutocomplete] = useState(false);
1814
+ const [recentSearches, setRecentSearches] = useState([]);
1815
+ const [sortPickerOpen, setSortPickerOpen] = useState(false);
1816
+ const [sortPickerBusy, setSortPickerBusy] = useState(false);
1817
+
1818
+ const [packs, setPacks] = useState([]);
1819
+ const [packHasMore, setPackHasMore] = useState(true);
1820
+ const [packsLoading, setPacksLoading] = useState(false);
1821
+
1822
+ const [error, setError] = useState('');
1823
+
1824
+ const [currentView, setCurrentView] = useState(initialRoute.view || 'catalog');
1825
+ const [currentPackKey, setCurrentPackKey] = useState(initialRoute.packKey || '');
1826
+ const [currentPack, setCurrentPack] = useState(null);
1827
+ const [packLoading, setPackLoading] = useState(false);
1828
+ const [reactionLoading, setReactionLoading] = useState('');
1829
+ const [reactionNotice, setReactionNotice] = useState(null);
1830
+ const [relatedPacks, setRelatedPacks] = useState([]);
1831
+ const [relatedPacksLoading, setRelatedPacksLoading] = useState(false);
1832
+ const [creatorRanking, setCreatorRanking] = useState([]);
1833
+ const [creatorRankingLoading, setCreatorRankingLoading] = useState(false);
1834
+ const [creatorRankingError, setCreatorRankingError] = useState('');
1835
+ const [creatorSort, setCreatorSort] = useState(normalizeCreatorsSort(initialCreatorsSearch.sort || DEFAULT_CREATORS_SORT));
1836
+ const [isScrolled, setIsScrolled] = useState(false);
1837
+ const [supportInfo, setSupportInfo] = useState(null);
1838
+ const [uploadTask, setUploadTask] = useState(null);
1839
+ const [googleAuthConfig, setGoogleAuthConfig] = useState({
1840
+ enabled: false,
1841
+ required: false,
1842
+ clientId: '',
1843
+ });
1844
+ const [googleAuth, setGoogleAuth] = useState(() => readGoogleAuthCache() || { user: null, expiresAt: '' });
1845
+ const [googleAuthError, setGoogleAuthError] = useState('');
1846
+ const [googleAuthBusy, setGoogleAuthBusy] = useState(false);
1847
+ const [googleSessionChecked, setGoogleSessionChecked] = useState(false);
1848
+ const [myPacks, setMyPacks] = useState([]);
1849
+ const [myPacksLoading, setMyPacksLoading] = useState(false);
1850
+ const [myPacksError, setMyPacksError] = useState('');
1851
+ const [myProfileStats, setMyProfileStats] = useState({
1852
+ total: 0,
1853
+ published: 0,
1854
+ drafts: 0,
1855
+ private: 0,
1856
+ unlisted: 0,
1857
+ public: 0,
1858
+ });
1859
+ const [packActionBusyByKey, setPackActionBusyByKey] = useState({});
1860
+ const [profileToasts, setProfileToasts] = useState([]);
1861
+ const [packActionsSheetPack, setPackActionsSheetPack] = useState(null);
1862
+ const [confirmDeletePack, setConfirmDeletePack] = useState(null);
1863
+ const [confirmDeleteBusy, setConfirmDeleteBusy] = useState(false);
1864
+ const [managePackOpen, setManagePackOpen] = useState(false);
1865
+ const [managePackData, setManagePackData] = useState(null);
1866
+ const [managePackLoading, setManagePackLoading] = useState(false);
1867
+ const [managePackError, setManagePackError] = useState('');
1868
+ const [managePackBusyAction, setManagePackBusyAction] = useState('');
1869
+ const [managePackTargetKey, setManagePackTargetKey] = useState('');
1870
+ const [analyticsModalOpen, setAnalyticsModalOpen] = useState(false);
1871
+ const [analyticsModalLoading, setAnalyticsModalLoading] = useState(false);
1872
+ const [analyticsModalError, setAnalyticsModalError] = useState('');
1873
+ const [analyticsModalData, setAnalyticsModalData] = useState(null);
1874
+ const [analyticsModalPack, setAnalyticsModalPack] = useState(null);
1875
+ const googleButtonRef = useRef(null);
1876
+ const googlePromptAttemptedRef = useRef(false);
1877
+ const sortPickerLockRef = useRef(false);
1878
+
1879
+ const dynamicCategoryOptions = useMemo(() => {
1880
+ const scoreByTag = new Map();
1881
+ const ensureTag = (rawTag, baseScore = 1) => {
1882
+ const tag = String(rawTag || '').trim();
1883
+ if (!tag) return;
1884
+ scoreByTag.set(tag, (scoreByTag.get(tag) || 0) + baseScore);
1885
+ };
1886
+
1887
+ packs.forEach((pack) => {
1888
+ const engagement = getPackEngagement(pack);
1889
+ const scoreBoost = 1 + engagement.openCount * 0.02 + engagement.likeCount * 0.08;
1890
+ (Array.isArray(pack?.tags) ? pack.tags : []).forEach((tag) => ensureTag(tag, scoreBoost));
1891
+ });
1892
+
1893
+ const sortedTags = Array.from(scoreByTag.entries())
1894
+ .sort((a, b) => b[1] - a[1])
1895
+ .slice(0, 12)
1896
+ .map(([value]) => {
1897
+ const meta = CATEGORY_META.get(value) || null;
1898
+ if (meta) return { value, label: `${meta.icon} ${meta.label}`, icon: meta.icon };
1899
+ const label = value.replace(/-/g, ' ');
1900
+ return { value, label: `🏷️ ${label}`, icon: '🏷️' };
1901
+ });
1902
+
1903
+ if (activeCategory && !sortedTags.some((entry) => entry.value === activeCategory)) {
1904
+ const meta = CATEGORY_META.get(activeCategory) || null;
1905
+ sortedTags.unshift(meta ? { value: activeCategory, label: `${meta.icon} ${meta.label}`, icon: meta.icon } : { value: activeCategory, label: `🏷️ ${activeCategory.replace(/-/g, ' ')}`, icon: '🏷️' });
1906
+ }
1907
+
1908
+ return [{ value: '', label: '🔥 Em alta', icon: '🔥' }, ...sortedTags];
1909
+ }, [packs, activeCategory]);
1910
+
1911
+ const tagSuggestions = useMemo(() => {
1912
+ const options = new Map();
1913
+
1914
+ dynamicCategoryOptions.forEach((entry) => {
1915
+ if (!entry?.value) return;
1916
+ options.set(entry.value, {
1917
+ value: entry.value,
1918
+ label: String(entry.label || entry.value).replace(/^.+?\s/, ''),
1919
+ icon: entry.icon || '🏷️',
1920
+ });
1921
+ });
1922
+
1923
+ const addTag = (rawTag) => {
1924
+ const tag = String(rawTag || '').trim();
1925
+ if (!tag) return;
1926
+ if (!options.has(tag)) {
1927
+ options.set(tag, {
1928
+ value: tag,
1929
+ label: tag.replace(/-/g, ' '),
1930
+ icon: '🏷',
1931
+ });
1932
+ }
1933
+ };
1934
+
1935
+ packs.forEach((pack) => {
1936
+ (Array.isArray(pack?.tags) ? pack.tags : []).forEach(addTag);
1937
+ });
1938
+
1939
+ return Array.from(options.values());
1940
+ }, [dynamicCategoryOptions, packs]);
1941
+
1942
+ const filteredSuggestions = useMemo(() => {
1943
+ const q = normalizeToken(query);
1944
+ if (!q) {
1945
+ return recentSearches.slice(0, 6).map((entry) => ({
1946
+ value: entry,
1947
+ label: entry,
1948
+ icon: '🕘',
1949
+ }));
1950
+ }
1951
+ return tagSuggestions.filter((item) => normalizeToken(item.value).includes(q) || normalizeToken(item.label).includes(q)).slice(0, 8);
1952
+ }, [query, tagSuggestions, recentSearches]);
1953
+
1954
+ const sortedPacks = useMemo(() => {
1955
+ const list = [...packs];
1956
+ const selectedSort = normalizeCatalogSort(sortBy);
1957
+ list.sort((a, b) => {
1958
+ const completenessDelta = comparePacksByCompleteness(a, b);
1959
+ if (completenessDelta !== 0) return completenessDelta;
1960
+
1961
+ if (selectedSort === 'recent') {
1962
+ return new Date(b?.created_at || b?.updated_at || 0).getTime() - new Date(a?.created_at || a?.updated_at || 0).getTime();
1963
+ }
1964
+ if (selectedSort === 'likes') {
1965
+ return getPackEngagement(b).likeCount - getPackEngagement(a).likeCount;
1966
+ }
1967
+ if (selectedSort === 'downloads') {
1968
+ return getPackEngagement(b).openCount - getPackEngagement(a).openCount;
1969
+ }
1970
+ if (selectedSort === 'comments') {
1971
+ const commentDelta = getPackCommentCount(b) - getPackCommentCount(a);
1972
+ if (commentDelta !== 0) return commentDelta;
1973
+ return getPackEngagement(b).likeCount - getPackEngagement(a).likeCount;
1974
+ }
1975
+ if (selectedSort === 'trending') {
1976
+ const trendDelta = getPackTrendScore(b) - getPackTrendScore(a);
1977
+ if (trendDelta !== 0) return trendDelta;
1978
+ const rankingDelta = getPackRankingScore(b) - getPackRankingScore(a);
1979
+ if (rankingDelta !== 0) return rankingDelta;
1980
+ return getPackEngagement(b).openCount - getPackEngagement(a).openCount;
1981
+ }
1982
+ return getPackRankingScore(b) - getPackRankingScore(a);
1983
+ });
1984
+ return list;
1985
+ }, [packs, sortBy]);
1986
+
1987
+ const categoryActiveLabel = catalogFilter === 'trending' ? 'Em alta agora' : dynamicCategoryOptions.find((entry) => entry.value === activeCategory)?.label?.replace(/^.+?\s/, '') || 'Todas';
1988
+ const growingNowPacks = useMemo(() => {
1989
+ return [...packs]
1990
+ .map((pack) => {
1991
+ const engagement = getPackEngagement(pack);
1992
+ const createdAt = new Date(pack?.created_at || pack?.updated_at || 0).getTime();
1993
+ const recentBonus = Date.now() - createdAt <= 1000 * 60 * 60 * 24 * 7 ? 18 : 0;
1994
+ const growth = engagement.openCount * 1.5 + engagement.likeCount * 3 - engagement.dislikeCount + recentBonus;
1995
+ return { pack, growth };
1996
+ })
1997
+ .sort((a, b) => {
1998
+ const completenessDelta = comparePacksByCompleteness(a.pack, b.pack);
1999
+ if (completenessDelta !== 0) return completenessDelta;
2000
+ return b.growth - a.growth;
2001
+ })
2002
+ .slice(0, Math.max(DESKTOP_DISCOVER_GROWING_LIMIT, MOBILE_DISCOVER_CAROUSEL_LIMIT))
2003
+ .map((entry) => entry.pack);
2004
+ }, [packs]);
2005
+ const topWeekPacks = useMemo(
2006
+ () =>
2007
+ [...packs]
2008
+ .sort((a, b) => {
2009
+ const completenessDelta = comparePacksByCompleteness(a, b);
2010
+ if (completenessDelta !== 0) return completenessDelta;
2011
+ const ea = getPackEngagement(a);
2012
+ const eb = getPackEngagement(b);
2013
+ const sa = ea.openCount + ea.likeCount * 3 - ea.dislikeCount;
2014
+ const sb = eb.openCount + eb.likeCount * 3 - eb.dislikeCount;
2015
+ return sb - sa;
2016
+ })
2017
+ .slice(0, Math.max(DESKTOP_DISCOVER_TOP_LIMIT, MOBILE_DISCOVER_CAROUSEL_LIMIT)),
2018
+ [packs],
2019
+ );
2020
+ const featuredCreators = useMemo(() => {
2021
+ const byPublisher = new Map();
2022
+ packs.forEach((pack) => {
2023
+ const publisher = String(pack?.publisher || 'OmniZap Auto').trim();
2024
+ const current = byPublisher.get(publisher) || {
2025
+ publisher,
2026
+ key: String(publisher || '').toLowerCase(),
2027
+ packCount: 0,
2028
+ likes: 0,
2029
+ opens: 0,
2030
+ avgPackScoreSum: 0,
2031
+ topPack: pack,
2032
+ };
2033
+ const engagement = getPackEngagement(pack);
2034
+ current.packCount += 1;
2035
+ current.likes += engagement.likeCount;
2036
+ current.opens += engagement.openCount;
2037
+ current.avgPackScoreSum += safeNumber(pack?.signals?.pack_score);
2038
+ if (!current.topPack || engagement.likeCount > getPackEngagement(current.topPack).likeCount) {
2039
+ current.topPack = pack;
2040
+ }
2041
+ byPublisher.set(publisher, current);
2042
+ });
2043
+ return Array.from(byPublisher.values())
2044
+ .map((creator) => ({
2045
+ ...creator,
2046
+ downloads: creator.opens,
2047
+ avgPackScore: creator.avgPackScoreSum / Math.max(1, creator.packCount),
2048
+ creatorScore: buildCreatorScore({
2049
+ avgPackScore: creator.avgPackScoreSum / Math.max(1, creator.packCount),
2050
+ likes: creator.likes,
2051
+ downloads: creator.opens,
2052
+ }),
2053
+ avatarUrl: getAvatarUrl(creator.publisher),
2054
+ }))
2055
+ .sort((a, b) => b.creatorScore - a.creatorScore)
2056
+ .slice(0, 3);
2057
+ }, [packs]);
2058
+ const sortedCreatorRanking = useMemo(() => {
2059
+ const list = [...creatorRanking];
2060
+ const selectedSort = normalizeCreatorsSort(creatorSort);
2061
+ if (selectedSort === 'likes') {
2062
+ list.sort((a, b) => b.likes - a.likes);
2063
+ return list;
2064
+ }
2065
+ if (selectedSort === 'downloads') {
2066
+ list.sort((a, b) => b.downloads - a.downloads);
2067
+ return list;
2068
+ }
2069
+ if (selectedSort === 'packs') {
2070
+ list.sort((a, b) => b.packCount - a.packCount);
2071
+ return list;
2072
+ }
2073
+ if (selectedSort === 'name') {
2074
+ list.sort((a, b) => String(a.publisher || '').localeCompare(String(b.publisher || ''), 'pt-BR'));
2075
+ return list;
2076
+ }
2077
+ list.sort((a, b) => b.creatorScore - a.creatorScore);
2078
+ return list;
2079
+ }, [creatorRanking, creatorSort]);
2080
+ const recentPublishedPacks = useMemo(
2081
+ () =>
2082
+ [...packs]
2083
+ .sort((a, b) => {
2084
+ const completenessDelta = comparePacksByCompleteness(a, b);
2085
+ if (completenessDelta !== 0) return completenessDelta;
2086
+ return new Date(b?.created_at || b?.updated_at || 0).getTime() - new Date(a?.created_at || a?.updated_at || 0).getTime();
2087
+ })
2088
+ .slice(0, MOBILE_DISCOVER_CAROUSEL_LIMIT),
2089
+ [packs],
2090
+ );
2091
+
2092
+ const hasAnyResult = packs.length > 0;
2093
+ const canGoCatalogPrev = catalogPage > FIRST_CATALOG_PAGE && !packsLoading;
2094
+ const canGoCatalogNext = packHasMore && !packsLoading;
2095
+ const googleSessionApiPath = `${config.apiBasePath}/auth/google/session`;
2096
+ const myProfileApiPath = `${config.apiBasePath}/me`;
2097
+ const isProfileView = currentView === 'profile';
2098
+ const isCreatorsView = currentView === 'creators';
2099
+ const hasGoogleLogin = Boolean(googleAuth.user?.sub);
2100
+ const hasNsfwAccess = hasGoogleLogin;
2101
+ const googleLoginEnabled = Boolean(googleAuthConfig.enabled && googleAuthConfig.clientId);
2102
+ const shouldRenderGoogleButton = isProfileView && googleLoginEnabled && !hasGoogleLogin && googleSessionChecked && !googleAuthBusy;
2103
+
2104
+ const fetchJson = async (url, options = undefined) => {
2105
+ const opts = options && typeof options === 'object' ? { ...options } : {};
2106
+ const retry = Math.max(0, Number(opts.retry || 0));
2107
+ const retryDelayMs = Math.max(120, Number(opts.retryDelayMs || 350));
2108
+ delete opts.retry;
2109
+ delete opts.retryDelayMs;
2110
+
2111
+ let attempt = 0;
2112
+ while (true) {
2113
+ try {
2114
+ const response = await fetch(url, { credentials: 'include', ...opts });
2115
+ const rawText = await response.text().catch(() => '');
2116
+ let payload = {};
2117
+ if (rawText) {
2118
+ try {
2119
+ payload = JSON.parse(rawText);
2120
+ } catch {
2121
+ payload = { raw: rawText };
2122
+ }
2123
+ }
2124
+
2125
+ if (!response.ok) {
2126
+ const error = new Error(payload?.error || payload?.message || rawText || 'Falha ao carregar catálogo');
2127
+ error.status = Number(response.status || 0);
2128
+ error.code = payload?.code || '';
2129
+ error.payload = payload;
2130
+ error.url = String(url || '');
2131
+ error.retryable = response.status >= 500 || response.status === 429;
2132
+ throw error;
2133
+ }
2134
+
2135
+ return payload;
2136
+ } catch (err) {
2137
+ const normalized = err instanceof Error ? err : new Error('Falha de rede');
2138
+ if (normalized.status === undefined) normalized.status = 0;
2139
+ if (normalized.retryable === undefined) normalized.retryable = !normalized.status || normalized.status >= 500;
2140
+
2141
+ if (attempt >= retry || !normalized.retryable) throw normalized;
2142
+ attempt += 1;
2143
+ await sleep(retryDelayMs * attempt);
2144
+ }
2145
+ }
2146
+ };
2147
+
2148
+ const applyGoogleSessionData = (sessionData) => {
2149
+ if (!sessionData?.authenticated || !sessionData?.user?.sub) {
2150
+ setGoogleAuth({ user: null, expiresAt: '' });
2151
+ clearGoogleAuthCache();
2152
+ return false;
2153
+ }
2154
+ const nextAuth = {
2155
+ user: {
2156
+ sub: String(sessionData.user.sub || ''),
2157
+ email: String(sessionData.user.email || ''),
2158
+ name: String(sessionData.user.name || 'Conta Google'),
2159
+ picture: String(sessionData.user.picture || ''),
2160
+ },
2161
+ expiresAt: String(sessionData.expires_at || ''),
2162
+ };
2163
+ setGoogleAuth(nextAuth);
2164
+ writeGoogleAuthCache(nextAuth);
2165
+ return true;
2166
+ };
2167
+
2168
+ const applyMyProfileData = (payload) => {
2169
+ const data = payload?.data || {};
2170
+ const authGoogle = data?.auth?.google || {};
2171
+ setGoogleAuthConfig({
2172
+ enabled: Boolean(authGoogle?.enabled),
2173
+ required: Boolean(authGoogle?.required),
2174
+ clientId: String(authGoogle?.client_id || '').trim(),
2175
+ });
2176
+ const authenticated = applyGoogleSessionData(data?.session || null);
2177
+ const nextPacks = Array.isArray(data?.packs) ? data.packs : [];
2178
+ setMyPacks(authenticated ? nextPacks : []);
2179
+ const stats = data?.stats && typeof data.stats === 'object' ? data.stats : {};
2180
+ setMyProfileStats({
2181
+ total: Number(stats.total || 0),
2182
+ published: Number(stats.published || 0),
2183
+ drafts: Number(stats.drafts || 0),
2184
+ private: Number(stats.private || 0),
2185
+ unlisted: Number(stats.unlisted || 0),
2186
+ public: Number(stats.public || 0),
2187
+ });
2188
+ setGoogleSessionChecked(true);
2189
+ return { authenticated };
2190
+ };
2191
+
2192
+ const refreshMyProfile = async ({ silent = false } = {}) => {
2193
+ if (!silent) setMyPacksLoading(true);
2194
+ setMyPacksError('');
2195
+ setGoogleAuthError('');
2196
+ if (!silent) setGoogleSessionChecked(false);
2197
+ try {
2198
+ const payload = await fetchJson(myProfileApiPath, { retry: 1 });
2199
+ const applied = applyMyProfileData(payload);
2200
+ if (!applied?.authenticated) {
2201
+ redirectToLogin(`${config.webPath}/perfil`);
2202
+ return;
2203
+ }
2204
+ } catch (err) {
2205
+ if (Number(err?.status || 0) === 401 || Number(err?.status || 0) === 403) {
2206
+ redirectToLogin(`${config.webPath}/perfil`);
2207
+ return;
2208
+ }
2209
+ setMyPacks([]);
2210
+ setMyProfileStats({ total: 0, published: 0, drafts: 0, private: 0, unlisted: 0, public: 0 });
2211
+ setGoogleSessionChecked(true);
2212
+ setMyPacksError(err?.message || 'Falha ao carregar perfil e packs.');
2213
+ } finally {
2214
+ if (!silent) setMyPacksLoading(false);
2215
+ }
2216
+ };
2217
+
2218
+ const pushProfileToast = (message, type = 'success') => {
2219
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2220
+ setProfileToasts((prev) => [...prev, { id, message: String(message || ''), type }].slice(-5));
2221
+ window.setTimeout(() => {
2222
+ setProfileToasts((prev) => prev.filter((item) => item.id !== id));
2223
+ }, 4200);
2224
+ };
2225
+
2226
+ const dismissProfileToast = (toastId) => {
2227
+ setProfileToasts((prev) => prev.filter((item) => item.id !== toastId));
2228
+ };
2229
+
2230
+ const setPackActionBusy = (packKey, action = '') => {
2231
+ if (!packKey) return;
2232
+ setPackActionBusyByKey((prev) => {
2233
+ if (!action) {
2234
+ const next = { ...prev };
2235
+ delete next[packKey];
2236
+ return next;
2237
+ }
2238
+ return { ...prev, [packKey]: action };
2239
+ });
2240
+ };
2241
+
2242
+ const buildManagePackApiPath = (packKey, suffix = '') => `${config.apiBasePath}/${encodeURIComponent(String(packKey || ''))}/manage${suffix}`;
2243
+
2244
+ const getManagedMutationStatus = (payload) =>
2245
+ String(payload?.data?.status || payload?.status || '')
2246
+ .trim()
2247
+ .toLowerCase();
2248
+
2249
+ const applyManagedPackToMyList = (managedData) => {
2250
+ const pack = managedData?.pack || null;
2251
+ if (!pack?.pack_key) return;
2252
+ setMyPacks((prev) => {
2253
+ const list = Array.isArray(prev) ? [...prev] : [];
2254
+ const index = list.findIndex((entry) => String(entry?.pack_key || '') === String(pack.pack_key || ''));
2255
+ if (index >= 0) {
2256
+ list[index] = { ...list[index], ...pack };
2257
+ return list;
2258
+ }
2259
+ return [pack, ...list];
2260
+ });
2261
+ };
2262
+
2263
+ const removePackFromMyList = (packKey) => {
2264
+ if (!packKey) return;
2265
+ setMyPacks((prev) => (Array.isArray(prev) ? prev.filter((entry) => String(entry?.pack_key || '') !== String(packKey)) : []));
2266
+ };
2267
+
2268
+ const loadManagePackData = async (packKey, { openModal = false, silent = false } = {}) => {
2269
+ if (!packKey) return null;
2270
+ if (!silent) setManagePackLoading(true);
2271
+ setManagePackError('');
2272
+ setManagePackTargetKey(String(packKey));
2273
+ if (openModal) setManagePackOpen(true);
2274
+ try {
2275
+ const payload = await fetchJson(buildManagePackApiPath(packKey), { retry: 1 });
2276
+ const managed = payload?.data || null;
2277
+ setManagePackData(managed);
2278
+ applyManagedPackToMyList(managed);
2279
+ return managed;
2280
+ } catch (err) {
2281
+ if (Number(err?.status || 0) === 404) {
2282
+ removePackFromMyList(packKey);
2283
+ if (openModal) setManagePackOpen(false);
2284
+ }
2285
+ setManagePackError(err?.message || 'Falha ao carregar gerenciador do pack.');
2286
+ throw err;
2287
+ } finally {
2288
+ if (!silent) setManagePackLoading(false);
2289
+ }
2290
+ };
2291
+
2292
+ const openManagePackByKey = async (packKey) => {
2293
+ if (!packKey) return;
2294
+ if (packActionBusyByKey?.[packKey]) return;
2295
+ setPackActionBusy(packKey, 'manage');
2296
+ setPackActionsSheetPack(null);
2297
+ try {
2298
+ await loadManagePackData(packKey, { openModal: true });
2299
+ } catch (err) {
2300
+ pushProfileToast(err?.message || 'Falha ao abrir gerenciador do pack.', 'error');
2301
+ setManagePackOpen(false);
2302
+ } finally {
2303
+ setPackActionBusy(packKey, '');
2304
+ }
2305
+ };
2306
+
2307
+ const closeManagePackModal = () => {
2308
+ setManagePackOpen(false);
2309
+ setManagePackBusyAction('');
2310
+ setManagePackError('');
2311
+ setManagePackData(null);
2312
+ setManagePackTargetKey('');
2313
+ };
2314
+
2315
+ const refreshManagePackData = async () => {
2316
+ if (!managePackTargetKey) return;
2317
+ try {
2318
+ await loadManagePackData(managePackTargetKey, { openModal: true, silent: true });
2319
+ } catch {
2320
+ // ignore transient refresh failures
2321
+ }
2322
+ };
2323
+
2324
+ const applyManagedMutationResult = async (payloadData, { successMessage = '' } = {}) => {
2325
+ const envelope = payloadData?.data ? payloadData : { data: payloadData || {} };
2326
+ const status = getManagedMutationStatus(envelope);
2327
+ const managed = envelope?.data?.pack ? envelope.data : envelope?.pack ? envelope : null;
2328
+
2329
+ if (status === 'already_deleted' && !managed?.pack) {
2330
+ const deletedPackKey = String(envelope?.data?.pack_key || managePackTargetKey || '').trim();
2331
+ if (deletedPackKey) removePackFromMyList(deletedPackKey);
2332
+ if (deletedPackKey && String(managePackTargetKey || '') === deletedPackKey) closeManagePackModal();
2333
+ await refreshMyProfile({ silent: true }).catch(() => {});
2334
+ return null;
2335
+ }
2336
+
2337
+ if (managed?.pack) {
2338
+ setManagePackData(managed);
2339
+ applyManagedPackToMyList(managed);
2340
+ }
2341
+
2342
+ const targetKey = String(managed?.pack?.pack_key || envelope?.data?.pack_key || managePackTargetKey || '').trim() || '';
2343
+ if (targetKey && managePackOpen && String(managePackTargetKey || '') === targetKey) {
2344
+ try {
2345
+ await loadManagePackData(targetKey, { openModal: true, silent: true });
2346
+ } catch (err) {
2347
+ if (Number(err?.status || 0) === 404) {
2348
+ removePackFromMyList(targetKey);
2349
+ closeManagePackModal();
2350
+ }
2351
+ }
2352
+ }
2353
+
2354
+ await refreshMyProfile({ silent: true }).catch(() => {});
2355
+ if (successMessage && status !== 'noop' && status !== 'unchanged' && status !== 'already_deleted') {
2356
+ pushProfileToast(successMessage, 'success');
2357
+ }
2358
+ if (status === 'noop') {
2359
+ pushProfileToast('Nenhuma alteração necessária (estado já atualizado).', 'warning');
2360
+ }
2361
+ if (status === 'unchanged') {
2362
+ pushProfileToast('Nenhuma alteração detectada.', 'warning');
2363
+ }
2364
+ return managed;
2365
+ };
2366
+
2367
+ const openAnalyticsModalForPack = async (pack) => {
2368
+ if (!pack?.pack_key) return;
2369
+ setAnalyticsModalPack(pack);
2370
+ setAnalyticsModalOpen(true);
2371
+ setAnalyticsModalError('');
2372
+ setAnalyticsModalLoading(true);
2373
+ try {
2374
+ const payload = await fetchJson(buildManagePackApiPath(pack.pack_key, '/analytics'), {
2375
+ retry: 1,
2376
+ });
2377
+ setAnalyticsModalData(payload?.data || null);
2378
+ } catch (err) {
2379
+ setAnalyticsModalError(err?.message || 'Falha ao carregar analytics do pack.');
2380
+ } finally {
2381
+ setAnalyticsModalLoading(false);
2382
+ }
2383
+ };
2384
+
2385
+ const closeAnalyticsModal = () => {
2386
+ setAnalyticsModalOpen(false);
2387
+ setAnalyticsModalError('');
2388
+ setAnalyticsModalLoading(false);
2389
+ };
2390
+
2391
+ const mergeEngagementInPack = (pack, engagement) => {
2392
+ if (!pack || !engagement) return pack;
2393
+ return {
2394
+ ...pack,
2395
+ engagement: {
2396
+ ...(pack.engagement || {}),
2397
+ ...engagement,
2398
+ },
2399
+ };
2400
+ };
2401
+
2402
+ const applyPackEngagement = (packKey, engagement) => {
2403
+ if (!packKey || !engagement) return;
2404
+ setPacks((prev) => prev.map((entry) => (entry?.pack_key === packKey ? mergeEngagementInPack(entry, engagement) : entry)));
2405
+ setRelatedPacks((prev) => prev.map((entry) => (entry?.pack_key === packKey ? mergeEngagementInPack(entry, engagement) : entry)));
2406
+ setCurrentPack((prev) => (prev?.pack_key === packKey ? mergeEngagementInPack(prev, engagement) : prev));
2407
+ };
2408
+
2409
+ const registerPackInteraction = async (packKey, action, { silent = false } = {}) => {
2410
+ if (!packKey || !['open', 'like', 'dislike'].includes(action)) return null;
2411
+ try {
2412
+ const payload = await fetchJson(`${config.apiBasePath}/${encodeURIComponent(packKey)}/${action}`, { method: 'POST' });
2413
+ const engagement = payload?.data?.engagement || null;
2414
+ if (engagement) applyPackEngagement(packKey, engagement);
2415
+ return engagement;
2416
+ } catch (err) {
2417
+ if (!silent) setError(err?.message || 'Falha ao registrar interação');
2418
+ return null;
2419
+ }
2420
+ };
2421
+
2422
+ const buildParams = ({ q, category, sort, limit, offset, includeVisibility = false }) => {
2423
+ const params = new URLSearchParams();
2424
+ if (q) params.set('q', q);
2425
+ if (category) params.set('categories', category);
2426
+ if (sort) params.set('sort', normalizeCatalogSort(sort));
2427
+ params.set('limit', String(limit));
2428
+ if (Number.isFinite(offset)) params.set('offset', String(offset));
2429
+ if (includeVisibility) params.set('visibility', 'public');
2430
+ return params;
2431
+ };
2432
+
2433
+ const loadPacks = async ({ page = catalogPage } = {}) => {
2434
+ const safePage = Math.max(FIRST_CATALOG_PAGE, Number(page || FIRST_CATALOG_PAGE));
2435
+ const nextOffset = (safePage - 1) * config.limit;
2436
+ setPacksLoading(true);
2437
+ setError('');
2438
+ try {
2439
+ const effectiveSort = catalogFilter === 'trending' ? 'trending' : sortBy;
2440
+ const params = buildParams({
2441
+ q: catalogFilter === 'trending' ? '' : appliedQuery,
2442
+ category: catalogFilter === 'trending' ? '' : activeCategory,
2443
+ sort: effectiveSort,
2444
+ limit: config.limit,
2445
+ offset: nextOffset,
2446
+ includeVisibility: true,
2447
+ });
2448
+
2449
+ const payload = await fetchJson(`${config.apiBasePath}?${params.toString()}`);
2450
+ const data = Array.isArray(payload?.data) ? payload.data : [];
2451
+ const hasMore = Boolean(payload?.pagination?.has_more);
2452
+ setPacks(data);
2453
+ setPackHasMore(hasMore);
2454
+ } catch (err) {
2455
+ setError(err?.message || 'Falha ao carregar packs');
2456
+ setPacks([]);
2457
+ setPackHasMore(false);
2458
+ } finally {
2459
+ setPacksLoading(false);
2460
+ }
2461
+ };
2462
+
2463
+ const loadCreatorRanking = async () => {
2464
+ setCreatorRankingLoading(true);
2465
+ setCreatorRankingError('');
2466
+ try {
2467
+ const params = new URLSearchParams();
2468
+ params.set('visibility', 'public');
2469
+ params.set('limit', '100');
2470
+ params.set('sort', normalizeCreatorsSort(creatorSort));
2471
+ const payload = await fetchJson(`${config.apiBasePath}/creators?${params.toString()}`, {
2472
+ retry: 1,
2473
+ });
2474
+ const rows = (Array.isArray(payload?.data) ? payload.data : []).map((entry, index) => {
2475
+ const publisher = String(entry?.publisher || '').trim() || `Criador ${index + 1}`;
2476
+ const stats = entry?.stats || {};
2477
+ const avgPackScore = safeNumber(stats?.avg_pack_score);
2478
+ const likes = safeNumber(stats?.total_likes);
2479
+ const downloads = safeNumber(stats?.total_opens);
2480
+ const packCount = safeNumber(stats?.packs_count);
2481
+ return {
2482
+ key: String(publisher || '').toLowerCase(),
2483
+ publisher,
2484
+ verified: Boolean(entry?.verified),
2485
+ badges: Array.isArray(entry?.badges) ? entry.badges : [],
2486
+ packCount,
2487
+ likes,
2488
+ downloads,
2489
+ avgPackScore,
2490
+ creatorScore: buildCreatorScore({ avgPackScore, likes, downloads }),
2491
+ avatarUrl: getAvatarUrl(publisher),
2492
+ topPack: entry?.top_pack || null,
2493
+ raw: entry,
2494
+ };
2495
+ });
2496
+ setCreatorRanking(rows);
2497
+ } catch (err) {
2498
+ setCreatorRanking([]);
2499
+ setCreatorRankingError(err?.message || 'Falha ao carregar ranking de criadores.');
2500
+ } finally {
2501
+ setCreatorRankingLoading(false);
2502
+ }
2503
+ };
2504
+
2505
+ const loadRelatedPacksForPack = async (pack) => {
2506
+ const sourcePackKey = String(pack?.pack_key || '').trim();
2507
+ if (!sourcePackKey) return;
2508
+ const q = String(pack?.publisher || '').trim();
2509
+ const relatedParams = new URLSearchParams();
2510
+ relatedParams.set('visibility', 'public');
2511
+ relatedParams.set('limit', '6');
2512
+ if (q) relatedParams.set('q', q);
2513
+ else if (Array.isArray(pack?.tags) && pack.tags[0]) relatedParams.set('categories', pack.tags[0]);
2514
+
2515
+ setRelatedPacksLoading(true);
2516
+ try {
2517
+ const relatedPayload = await fetchJson(`${config.apiBasePath}?${relatedParams.toString()}`);
2518
+ const relatedList = (Array.isArray(relatedPayload?.data) ? relatedPayload.data : []).filter((entry) => entry.pack_key && entry.pack_key !== sourcePackKey).slice(0, 4);
2519
+ setRelatedPacks(relatedList);
2520
+ } catch {
2521
+ setRelatedPacks([]);
2522
+ } finally {
2523
+ setRelatedPacksLoading(false);
2524
+ }
2525
+ };
2526
+
2527
+ const tryLoadManagedPackDetail = async (packKey) => {
2528
+ try {
2529
+ const managedPayload = await fetchJson(buildManagePackApiPath(packKey), { retry: 1 });
2530
+ const managedPack = managedPayload?.data?.pack || null;
2531
+ return managedPack?.pack_key ? managedPack : null;
2532
+ } catch {
2533
+ return null;
2534
+ }
2535
+ };
2536
+
2537
+ const loadPackDetail = async (packKey) => {
2538
+ if (!packKey) return;
2539
+ setPackLoading(true);
2540
+ setCurrentPack(null);
2541
+ setRelatedPacks([]);
2542
+ setRelatedPacksLoading(false);
2543
+ setError('');
2544
+
2545
+ try {
2546
+ let pack = null;
2547
+ try {
2548
+ const payload = await fetchJson(`${config.apiBasePath}/${encodeURIComponent(packKey)}`);
2549
+ pack = payload?.data || null;
2550
+ } catch (publicError) {
2551
+ if (Number(publicError?.status || 0) !== 404) throw publicError;
2552
+ pack = await tryLoadManagedPackDetail(packKey);
2553
+ if (!pack) throw publicError;
2554
+ }
2555
+
2556
+ setCurrentPack(pack);
2557
+ const resolvedPackKey = String(pack?.pack_key || packKey).trim();
2558
+ if (resolvedPackKey && resolvedPackKey !== packKey) {
2559
+ window.history.replaceState({}, '', `${config.webPath}/${encodeURIComponent(resolvedPackKey)}`);
2560
+ setCurrentPackKey(resolvedPackKey);
2561
+ }
2562
+
2563
+ void registerPackInteraction(resolvedPackKey || packKey, 'open', { silent: true });
2564
+ } catch (err) {
2565
+ setError(err?.message || 'Não foi possível abrir o pack');
2566
+ } finally {
2567
+ setPackLoading(false);
2568
+ }
2569
+ };
2570
+
2571
+ const requestRelatedPacksForCurrentPack = async () => {
2572
+ if (!currentPack || relatedPacksLoading || relatedPacks.length) return;
2573
+ await loadRelatedPacksForPack(currentPack);
2574
+ };
2575
+
2576
+ const buildCatalogWebUrl = ({ q = appliedQuery, category = activeCategory, sort = sortBy, filter = catalogFilter, page = catalogPage } = {}) => {
2577
+ const params = new URLSearchParams();
2578
+ const safePage = Math.max(FIRST_CATALOG_PAGE, Number(page || FIRST_CATALOG_PAGE));
2579
+ const normalizedFilter =
2580
+ String(filter || '')
2581
+ .trim()
2582
+ .toLowerCase() === 'trending'
2583
+ ? 'trending'
2584
+ : '';
2585
+ const normalizedSort = normalizeCatalogSort(sort || DEFAULT_CATALOG_SORT);
2586
+ if (normalizedFilter === 'trending') {
2587
+ params.set('filter', 'trending');
2588
+ } else {
2589
+ if (String(q || '').trim()) params.set('q', String(q).trim());
2590
+ if (String(category || '').trim()) params.set('category', String(category).trim().toLowerCase());
2591
+ if (normalizedSort && normalizedSort !== DEFAULT_CATALOG_SORT) params.set('sort', normalizedSort);
2592
+ }
2593
+ if (safePage > FIRST_CATALOG_PAGE) params.set('page', String(safePage));
2594
+ const qs = params.toString();
2595
+ return `${config.webPath}/${qs ? `?${qs}` : ''}`;
2596
+ };
2597
+
2598
+ const buildCreatorsWebUrl = ({ sort = creatorSort } = {}) => {
2599
+ const params = new URLSearchParams();
2600
+ params.set('sort', normalizeCreatorsSort(sort || DEFAULT_CREATORS_SORT));
2601
+ return `${config.webPath}/creators?${params.toString()}`;
2602
+ };
2603
+
2604
+ const applyCatalogViewState = ({ q = '', category = '', sort = DEFAULT_CATALOG_SORT, filter = '', page = FIRST_CATALOG_PAGE } = {}) => {
2605
+ const normalizedFilter =
2606
+ String(filter || '')
2607
+ .trim()
2608
+ .toLowerCase() === 'trending'
2609
+ ? 'trending'
2610
+ : '';
2611
+ const normalizedSort = normalizeCatalogSort(normalizedFilter ? 'trending' : sort || DEFAULT_CATALOG_SORT);
2612
+ const nextQ = normalizedFilter ? '' : String(q || '').trim();
2613
+ const nextCategory = normalizedFilter
2614
+ ? ''
2615
+ : String(category || '')
2616
+ .trim()
2617
+ .toLowerCase();
2618
+ const nextPage = Math.max(FIRST_CATALOG_PAGE, Number(page || FIRST_CATALOG_PAGE));
2619
+
2620
+ setCatalogFilter(normalizedFilter);
2621
+ setSortBy(normalizedSort);
2622
+ setQuery(nextQ);
2623
+ setAppliedQuery(nextQ);
2624
+ setActiveCategory(nextCategory);
2625
+ setCatalogPage(nextPage);
2626
+ };
2627
+
2628
+ const scrollToTopIfMobile = ({ behavior = 'smooth' } = {}) => {
2629
+ const isMobile = window.matchMedia ? window.matchMedia('(max-width: 1023px)').matches : window.innerWidth < 1024;
2630
+ if (!isMobile) return;
2631
+ window.requestAnimationFrame(() => {
2632
+ window.scrollTo({ top: 0, behavior });
2633
+ });
2634
+ };
2635
+
2636
+ const openPack = (packKey, push = true) => {
2637
+ if (!packKey) return;
2638
+ if (push) window.history.pushState({}, '', `${config.webPath}/${encodeURIComponent(packKey)}`);
2639
+ setCurrentView('pack');
2640
+ setCurrentPackKey(packKey);
2641
+ setSortPickerOpen(false);
2642
+ scrollToTopIfMobile({ behavior: 'smooth' });
2643
+ };
2644
+
2645
+ const goCatalog = (push = true) => {
2646
+ if (push) window.history.pushState({}, '', buildCatalogWebUrl());
2647
+ setCurrentView('catalog');
2648
+ setCurrentPackKey('');
2649
+ setCurrentPack(null);
2650
+ setRelatedPacks([]);
2651
+ setRelatedPacksLoading(false);
2652
+ setSortPickerOpen(false);
2653
+ };
2654
+
2655
+ const openCatalogWithState = ({ q = '', category = '', sort = DEFAULT_CATALOG_SORT, filter = '', page = FIRST_CATALOG_PAGE, push = true } = {}) => {
2656
+ const nextState = {
2657
+ q,
2658
+ category,
2659
+ sort: normalizeCatalogSort(sort || DEFAULT_CATALOG_SORT),
2660
+ page: Math.max(FIRST_CATALOG_PAGE, Number(page || FIRST_CATALOG_PAGE)),
2661
+ filter:
2662
+ String(filter || '')
2663
+ .trim()
2664
+ .toLowerCase() === 'trending'
2665
+ ? 'trending'
2666
+ : '',
2667
+ };
2668
+ if (push) window.history.pushState({}, '', buildCatalogWebUrl(nextState));
2669
+ applyCatalogViewState(nextState);
2670
+ setCurrentView('catalog');
2671
+ setCurrentPackKey('');
2672
+ setCurrentPack(null);
2673
+ setRelatedPacks([]);
2674
+ setRelatedPacksLoading(false);
2675
+ setError('');
2676
+ setSortPickerOpen(false);
2677
+ };
2678
+
2679
+ const goToCatalogPage = (nextPage, { push = true } = {}) => {
2680
+ const safePage = Math.max(FIRST_CATALOG_PAGE, Number(nextPage || FIRST_CATALOG_PAGE));
2681
+ if (safePage === catalogPage && currentView === 'catalog' && !currentPackKey) return;
2682
+ openCatalogWithState({
2683
+ q: catalogFilter === 'trending' ? '' : appliedQuery,
2684
+ category: catalogFilter === 'trending' ? '' : activeCategory,
2685
+ sort: sortBy,
2686
+ filter: catalogFilter,
2687
+ page: safePage,
2688
+ push,
2689
+ });
2690
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2691
+ };
2692
+
2693
+ const scrollToCatalogPacksSection = ({ behavior = 'smooth' } = {}) => {
2694
+ const tryScroll = () => {
2695
+ const section = document.getElementById('catalog-packs-section');
2696
+ if (!section) return false;
2697
+ section.scrollIntoView({ behavior, block: 'start' });
2698
+ return true;
2699
+ };
2700
+
2701
+ if (tryScroll()) return;
2702
+ let tries = 0;
2703
+ const retry = () => {
2704
+ tries += 1;
2705
+ if (tryScroll() || tries >= 8) return;
2706
+ window.setTimeout(retry, 80);
2707
+ };
2708
+ window.setTimeout(retry, 80);
2709
+ };
2710
+
2711
+ const openTrendingCatalog = () => {
2712
+ openCatalogWithState({ q: '', category: '', sort: 'trending', filter: 'trending', push: true });
2713
+ setDiscoverTab('growing');
2714
+ scrollToCatalogPacksSection({ behavior: 'smooth' });
2715
+ };
2716
+
2717
+ const openCreatorsRanking = (sort = DEFAULT_CREATORS_SORT, push = true) => {
2718
+ const nextSort = normalizeCreatorsSort(sort || DEFAULT_CREATORS_SORT);
2719
+ if (push) window.history.pushState({}, '', buildCreatorsWebUrl({ sort: nextSort }));
2720
+ setCreatorSort(nextSort);
2721
+ setCurrentView('creators');
2722
+ setCurrentPackKey('');
2723
+ setCurrentPack(null);
2724
+ setRelatedPacks([]);
2725
+ setRelatedPacksLoading(false);
2726
+ setError('');
2727
+ setSortPickerOpen(false);
2728
+ };
2729
+
2730
+ const openCreatorProfileFromRanking = (creator) => {
2731
+ const publisher = String(creator?.publisher || '').trim();
2732
+ if (!publisher) return;
2733
+ openCatalogWithState({
2734
+ q: publisher,
2735
+ category: '',
2736
+ sort: DEFAULT_CATALOG_SORT,
2737
+ filter: '',
2738
+ push: true,
2739
+ });
2740
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2741
+ };
2742
+
2743
+ const openCatalogTagFilter = (tag) => {
2744
+ const nextTag = String(tag || '')
2745
+ .trim()
2746
+ .toLowerCase();
2747
+ if (!nextTag) return;
2748
+ openCatalogWithState({
2749
+ q: '',
2750
+ category: nextTag,
2751
+ sort: DEFAULT_CATALOG_SORT,
2752
+ filter: '',
2753
+ push: true,
2754
+ });
2755
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2756
+ };
2757
+
2758
+ const buildLoginRedirectUrl = (nextPath = '') => {
2759
+ const next = String(nextPath || '').trim() || `${config.webPath}/perfil`;
2760
+ const loginUrl = new URL(config.loginPath || '/login', window.location.origin);
2761
+ loginUrl.searchParams.set('next', next);
2762
+ return `${loginUrl.pathname}${loginUrl.search}`;
2763
+ };
2764
+
2765
+ const redirectToLogin = (nextPath = '') => {
2766
+ window.location.assign(buildLoginRedirectUrl(nextPath));
2767
+ };
2768
+
2769
+ const openProfile = (push = true) => {
2770
+ googlePromptAttemptedRef.current = false;
2771
+ if (push) window.history.pushState({}, '', `${config.webPath}/perfil`);
2772
+ setCurrentView('profile');
2773
+ setCurrentPackKey('');
2774
+ setCurrentPack(null);
2775
+ setRelatedPacks([]);
2776
+ setRelatedPacksLoading(false);
2777
+ setError('');
2778
+ setSortPickerOpen(false);
2779
+ };
2780
+
2781
+ const requestNsfwUnlock = () => {
2782
+ openProfile(true);
2783
+ };
2784
+
2785
+ const cycleVisibilityValue = (currentVisibility) => {
2786
+ const normalized = String(currentVisibility || '').toLowerCase();
2787
+ if (normalized === 'public') return 'unlisted';
2788
+ if (normalized === 'unlisted') return 'private';
2789
+ return 'public';
2790
+ };
2791
+
2792
+ const openPackActionsSheet = (pack) => {
2793
+ if (!pack?.pack_key) return;
2794
+ setPackActionsSheetPack(pack);
2795
+ };
2796
+
2797
+ const closePackActionsSheet = () => {
2798
+ setPackActionsSheetPack(null);
2799
+ };
2800
+
2801
+ const runPackQuickMutation = async (packKey, actionName, task) => {
2802
+ if (!packKey || typeof task !== 'function') return null;
2803
+ if (packActionBusyByKey?.[packKey]) return null;
2804
+ setPackActionBusy(packKey, actionName);
2805
+ try {
2806
+ return await task();
2807
+ } finally {
2808
+ setPackActionBusy(packKey, '');
2809
+ }
2810
+ };
2811
+
2812
+ const handlePackVisibilityQuickToggle = async (pack) => {
2813
+ if (!pack?.pack_key) return;
2814
+ const nextVisibility = cycleVisibilityValue(pack.visibility);
2815
+ await runPackQuickMutation(pack.pack_key, 'visibility', async () => {
2816
+ const payload = await fetchJson(buildManagePackApiPath(pack.pack_key), {
2817
+ method: 'PATCH',
2818
+ retry: 1,
2819
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
2820
+ body: JSON.stringify({ visibility: nextVisibility }),
2821
+ });
2822
+ await applyManagedMutationResult(payload?.data ? payload : { data: payload }, {
2823
+ successMessage: `Visibilidade alterada para ${nextVisibility}.`,
2824
+ });
2825
+ }).catch((err) => {
2826
+ pushProfileToast(err?.message || 'Falha ao alterar visibilidade.', 'error');
2827
+ });
2828
+ };
2829
+
2830
+ const handlePackDuplicate = async (pack) => {
2831
+ if (!pack?.pack_key) return;
2832
+ await runPackQuickMutation(pack.pack_key, 'duplicate', async () => {
2833
+ await fetchJson(buildManagePackApiPath(pack.pack_key, '/clone'), {
2834
+ method: 'POST',
2835
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
2836
+ body: JSON.stringify({}),
2837
+ });
2838
+ await refreshMyProfile({ silent: true });
2839
+ pushProfileToast('Pack duplicado com sucesso.', 'success');
2840
+ }).catch((err) => {
2841
+ pushProfileToast(err?.message || 'Falha ao duplicar pack.', 'error');
2842
+ });
2843
+ };
2844
+
2845
+ const requestDeletePack = (pack) => {
2846
+ if (!pack?.pack_key) return;
2847
+ setConfirmDeletePack(pack);
2848
+ setConfirmDeleteBusy(false);
2849
+ closePackActionsSheet();
2850
+ };
2851
+
2852
+ const handleDeletePackConfirmed = async () => {
2853
+ const pack = confirmDeletePack;
2854
+ if (!pack?.pack_key || confirmDeleteBusy) return;
2855
+ setConfirmDeleteBusy(true);
2856
+ try {
2857
+ const payload = await fetchJson(buildManagePackApiPath(pack.pack_key), {
2858
+ method: 'DELETE',
2859
+ retry: 1,
2860
+ });
2861
+ const status = getManagedMutationStatus(payload);
2862
+ removePackFromMyList(pack.pack_key);
2863
+ await refreshMyProfile({ silent: true });
2864
+ if (managePackOpen && String(managePackTargetKey || '') === String(pack.pack_key)) {
2865
+ closeManagePackModal();
2866
+ }
2867
+ pushProfileToast(status === 'already_deleted' ? 'Pack já havia sido apagado.' : 'Pack apagado com sucesso.', 'success');
2868
+ setConfirmDeletePack(null);
2869
+ } catch (err) {
2870
+ pushProfileToast(err?.message || 'Falha ao apagar pack.', 'error');
2871
+ } finally {
2872
+ setConfirmDeleteBusy(false);
2873
+ }
2874
+ };
2875
+
2876
+ const handlePackActionsSheetAction = async (actionKey, pack) => {
2877
+ if (!pack?.pack_key) return;
2878
+ if (actionKey === 'manage' || actionKey === 'edit') {
2879
+ await openManagePackByKey(pack.pack_key);
2880
+ return;
2881
+ }
2882
+ if (actionKey === 'visibility') {
2883
+ closePackActionsSheet();
2884
+ await handlePackVisibilityQuickToggle(pack);
2885
+ return;
2886
+ }
2887
+ if (actionKey === 'duplicate') {
2888
+ closePackActionsSheet();
2889
+ await handlePackDuplicate(pack);
2890
+ return;
2891
+ }
2892
+ if (actionKey === 'analytics') {
2893
+ closePackActionsSheet();
2894
+ await openAnalyticsModalForPack(pack);
2895
+ return;
2896
+ }
2897
+ if (actionKey === 'delete') {
2898
+ requestDeletePack(pack);
2899
+ }
2900
+ };
2901
+
2902
+ const runManagePackMutation = async (actionName, task, successMessage = '') => {
2903
+ if (!managePackTargetKey) return null;
2904
+ if (managePackBusyAction) return null;
2905
+ setManagePackBusyAction(actionName);
2906
+ setManagePackError('');
2907
+ try {
2908
+ const result = await task();
2909
+ await applyManagedMutationResult(result, { successMessage });
2910
+ return result;
2911
+ } catch (err) {
2912
+ if (Number(err?.status || 0) === 404) {
2913
+ removePackFromMyList(managePackTargetKey);
2914
+ closeManagePackModal();
2915
+ }
2916
+ setManagePackError(err?.message || 'Falha ao atualizar pack.');
2917
+ pushProfileToast(err?.message || 'Falha ao atualizar pack.', 'error');
2918
+ try {
2919
+ err.handledByUi = true;
2920
+ } catch {
2921
+ // ignore assignment failures on unknown error objects
2922
+ }
2923
+ throw err;
2924
+ } finally {
2925
+ setManagePackBusyAction('');
2926
+ }
2927
+ };
2928
+
2929
+ const handleManageSaveMetadata = async (values) => {
2930
+ if (!managePackTargetKey) return;
2931
+ await runManagePackMutation(
2932
+ 'saveMetadata',
2933
+ async () =>
2934
+ fetchJson(buildManagePackApiPath(managePackTargetKey), {
2935
+ method: 'PATCH',
2936
+ retry: 1,
2937
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
2938
+ body: JSON.stringify({
2939
+ name: values?.name,
2940
+ publisher: values?.publisher,
2941
+ description: values?.description,
2942
+ tags: Array.isArray(values?.tags) ? values.tags : [],
2943
+ visibility: values?.visibility,
2944
+ }),
2945
+ }),
2946
+ 'Pack atualizado.',
2947
+ ).catch(() => {});
2948
+ };
2949
+
2950
+ const handleManageAddSticker = async (file) => {
2951
+ if (!managePackTargetKey || !file || managePackBusyAction) return;
2952
+ try {
2953
+ const stickerDataUrl = await readFileAsDataUrl(file);
2954
+ await runManagePackMutation(
2955
+ 'addSticker',
2956
+ async () =>
2957
+ fetchJson(buildManagePackApiPath(managePackTargetKey, '/stickers'), {
2958
+ method: 'POST',
2959
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
2960
+ body: JSON.stringify({ sticker_data_url: stickerDataUrl }),
2961
+ }),
2962
+ 'Sticker adicionado ao pack.',
2963
+ );
2964
+ } catch (err) {
2965
+ if (err?.handledByUi) return;
2966
+ pushProfileToast(err?.message || 'Falha ao adicionar sticker.', 'error');
2967
+ }
2968
+ };
2969
+
2970
+ const handleManageSetCover = async (stickerId) => {
2971
+ if (!managePackTargetKey || !stickerId) return;
2972
+ await runManagePackMutation(
2973
+ 'setCover',
2974
+ async () =>
2975
+ fetchJson(buildManagePackApiPath(managePackTargetKey, '/cover'), {
2976
+ method: 'POST',
2977
+ retry: 1,
2978
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
2979
+ body: JSON.stringify({ sticker_id: stickerId }),
2980
+ }),
2981
+ 'Capa atualizada.',
2982
+ ).catch(() => {});
2983
+ };
2984
+
2985
+ const handleManageRemoveSticker = async (stickerId) => {
2986
+ if (!managePackTargetKey || !stickerId) return;
2987
+ await runManagePackMutation(
2988
+ 'removeSticker',
2989
+ async () =>
2990
+ fetchJson(`${buildManagePackApiPath(managePackTargetKey, '/stickers')}/${encodeURIComponent(stickerId)}`, {
2991
+ method: 'DELETE',
2992
+ retry: 1,
2993
+ }),
2994
+ 'Sticker removido do pack.',
2995
+ ).catch(() => {});
2996
+ };
2997
+
2998
+ const handleManageReplaceSticker = async (stickerId, file) => {
2999
+ if (!managePackTargetKey || !stickerId || !file || managePackBusyAction) return;
3000
+ try {
3001
+ const stickerDataUrl = await readFileAsDataUrl(file);
3002
+ await runManagePackMutation(
3003
+ 'replaceSticker',
3004
+ async () =>
3005
+ fetchJson(`${buildManagePackApiPath(managePackTargetKey, '/stickers')}/${encodeURIComponent(stickerId)}/replace`, {
3006
+ method: 'POST',
3007
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
3008
+ body: JSON.stringify({ sticker_data_url: stickerDataUrl }),
3009
+ }),
3010
+ 'Sticker substituído com sucesso.',
3011
+ );
3012
+ } catch (err) {
3013
+ if (err?.handledByUi) return;
3014
+ pushProfileToast(err?.message || 'Falha ao substituir sticker.', 'error');
3015
+ }
3016
+ };
3017
+
3018
+ const handleManageReorder = async (orderStickerIds) => {
3019
+ if (!managePackTargetKey || !Array.isArray(orderStickerIds) || !orderStickerIds.length) return;
3020
+ await runManagePackMutation(
3021
+ 'reorder',
3022
+ async () =>
3023
+ fetchJson(buildManagePackApiPath(managePackTargetKey, '/reorder'), {
3024
+ method: 'POST',
3025
+ retry: 1,
3026
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
3027
+ body: JSON.stringify({ order_sticker_ids: orderStickerIds }),
3028
+ }),
3029
+ 'Ordem dos stickers salva.',
3030
+ ).catch(() => {});
3031
+ };
3032
+
3033
+ const openSortPicker = () => {
3034
+ if (sortPickerBusy || sortPickerLockRef.current) return;
3035
+ setSortPickerOpen(true);
3036
+ };
3037
+
3038
+ const closeSortPicker = () => {
3039
+ if (sortPickerBusy) return;
3040
+ setSortPickerOpen(false);
3041
+ };
3042
+
3043
+ const handleCatalogSortSelection = async (sortValue) => {
3044
+ const nextSort = normalizeCatalogSort(sortValue, sortBy);
3045
+ if (sortPickerBusy || sortPickerLockRef.current) return;
3046
+ sortPickerLockRef.current = true;
3047
+ setSortPickerBusy(true);
3048
+ if (catalogFilter) setCatalogFilter('');
3049
+ setSortBy(nextSort);
3050
+ setCatalogPage(FIRST_CATALOG_PAGE);
3051
+ setSortPickerOpen(false);
3052
+ scrollToCatalogPacksSection({ behavior: 'smooth' });
3053
+ window.setTimeout(() => {
3054
+ sortPickerLockRef.current = false;
3055
+ setSortPickerBusy(false);
3056
+ }, 220);
3057
+ };
3058
+
3059
+ const handleCreatorsSortChange = (sortValue) => {
3060
+ setCreatorSort(normalizeCreatorsSort(sortValue, creatorSort));
3061
+ };
3062
+
3063
+ const handleCategoryChipPress = (value) => {
3064
+ const nextValue = String(value || '')
3065
+ .trim()
3066
+ .toLowerCase();
3067
+ const previousScrollY = window.scrollY || 0;
3068
+
3069
+ if (!nextValue) {
3070
+ openTrendingCatalog();
3071
+ window.requestAnimationFrame(() => window.scrollTo({ top: previousScrollY }));
3072
+ return;
3073
+ }
3074
+
3075
+ const toggledOff = catalogFilter !== 'trending' && activeCategory === nextValue;
3076
+ const nextSort = catalogFilter === 'trending' ? DEFAULT_CATALOG_SORT : sortBy;
3077
+
3078
+ openCatalogWithState({
3079
+ q: catalogFilter === 'trending' ? '' : appliedQuery,
3080
+ category: toggledOff ? '' : nextValue,
3081
+ sort: nextSort,
3082
+ filter: '',
3083
+ push: true,
3084
+ });
3085
+
3086
+ window.requestAnimationFrame(() => window.scrollTo({ top: previousScrollY }));
3087
+ };
3088
+
3089
+ const getKnownPackByKey = (packKey) => {
3090
+ if (!packKey) return null;
3091
+ if (currentPack?.pack_key === packKey) return currentPack;
3092
+ return packs.find((entry) => entry?.pack_key === packKey) || relatedPacks.find((entry) => entry?.pack_key === packKey) || null;
3093
+ };
3094
+
3095
+ const applyOptimisticPackReaction = (packKey, action) => {
3096
+ const sourcePack = getKnownPackByKey(packKey);
3097
+ if (!sourcePack) return null;
3098
+ const previous = {
3099
+ open_count: safeNumber(sourcePack?.engagement?.open_count),
3100
+ like_count: safeNumber(sourcePack?.engagement?.like_count),
3101
+ dislike_count: safeNumber(sourcePack?.engagement?.dislike_count),
3102
+ comment_count: safeNumber(sourcePack?.engagement?.comment_count),
3103
+ score: safeNumber(sourcePack?.engagement?.score) || safeNumber(sourcePack?.engagement?.like_count) - safeNumber(sourcePack?.engagement?.dislike_count),
3104
+ updated_at: sourcePack?.engagement?.updated_at || null,
3105
+ };
3106
+ const optimistic = {
3107
+ ...previous,
3108
+ updated_at: new Date().toISOString(),
3109
+ };
3110
+ if (action === 'like') {
3111
+ optimistic.like_count += 1;
3112
+ optimistic.score += 1;
3113
+ }
3114
+ if (action === 'dislike') {
3115
+ optimistic.dislike_count += 1;
3116
+ optimistic.score -= 1;
3117
+ }
3118
+ applyPackEngagement(packKey, optimistic);
3119
+ return previous;
3120
+ };
3121
+
3122
+ const emitReactionNotice = (message, type = 'success') => {
3123
+ setReactionNotice({ message: String(message || ''), type });
3124
+ };
3125
+
3126
+ const handleLike = async (packKey) => {
3127
+ if (!packKey || reactionLoading) return;
3128
+ const previousEngagement = applyOptimisticPackReaction(packKey, 'like');
3129
+ setReactionLoading('like');
3130
+ emitReactionNotice('👍 Curtido');
3131
+ const engagement = await registerPackInteraction(packKey, 'like');
3132
+ if (!engagement && previousEngagement) {
3133
+ applyPackEngagement(packKey, previousEngagement);
3134
+ emitReactionNotice('Falha ao curtir', 'error');
3135
+ }
3136
+ setReactionLoading('');
3137
+ };
3138
+
3139
+ const handleDislike = async (packKey) => {
3140
+ if (!packKey || reactionLoading) return;
3141
+ const previousEngagement = applyOptimisticPackReaction(packKey, 'dislike');
3142
+ setReactionLoading('dislike');
3143
+ emitReactionNotice('👎 Avaliação enviada');
3144
+ const engagement = await registerPackInteraction(packKey, 'dislike');
3145
+ if (!engagement && previousEngagement) {
3146
+ applyPackEngagement(packKey, previousEngagement);
3147
+ emitReactionNotice('Falha ao enviar avaliação', 'error');
3148
+ }
3149
+ setReactionLoading('');
3150
+ };
3151
+
3152
+ useEffect(() => {
3153
+ const applyRoute = () => {
3154
+ const route = parseStickersLocation(config.webPath);
3155
+ if (route.view === 'creators') {
3156
+ const creatorsSearch = parseCreatorsSearchState(window.location.search);
3157
+ setCreatorSort(normalizeCreatorsSort(creatorsSearch.sort || DEFAULT_CREATORS_SORT));
3158
+ openCreatorsRanking(creatorsSearch.sort || DEFAULT_CREATORS_SORT, false);
3159
+ return;
3160
+ }
3161
+ if (route.view === 'profile') {
3162
+ openProfile(false);
3163
+ return;
3164
+ }
3165
+ if (route.view === 'pack' && route.packKey) {
3166
+ openPack(route.packKey, false);
3167
+ return;
3168
+ }
3169
+ applyCatalogViewState(parseCatalogSearchState(window.location.search));
3170
+ goCatalog(false);
3171
+ };
3172
+
3173
+ applyRoute();
3174
+
3175
+ const onPopState = () => {
3176
+ applyRoute();
3177
+ };
3178
+
3179
+ window.addEventListener('popstate', onPopState);
3180
+ return () => window.removeEventListener('popstate', onPopState);
3181
+ }, [config.webPath]);
3182
+
3183
+ useEffect(() => {
3184
+ const onScroll = () => setIsScrolled(window.scrollY > 8);
3185
+ onScroll();
3186
+ window.addEventListener('scroll', onScroll, { passive: true });
3187
+ return () => window.removeEventListener('scroll', onScroll);
3188
+ }, []);
3189
+
3190
+ useEffect(() => {
3191
+ if (!reactionNotice?.message) return undefined;
3192
+ const timer = window.setTimeout(() => setReactionNotice(null), 1800);
3193
+ return () => window.clearTimeout(timer);
3194
+ }, [reactionNotice]);
3195
+
3196
+ useEffect(() => {
3197
+ try {
3198
+ const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
3199
+ const parsed = JSON.parse(raw || '[]');
3200
+ if (Array.isArray(parsed)) {
3201
+ setRecentSearches(
3202
+ parsed
3203
+ .map((entry) => String(entry || '').trim())
3204
+ .filter(Boolean)
3205
+ .slice(0, 8),
3206
+ );
3207
+ }
3208
+ } catch {
3209
+ // ignore malformed local storage payloads
3210
+ }
3211
+ }, []);
3212
+
3213
+ useEffect(() => {
3214
+ const onKeyDown = (event) => {
3215
+ if ((event.ctrlKey || event.metaKey) && String(event.key || '').toLowerCase() === 'k') {
3216
+ event.preventDefault();
3217
+ const input = document.querySelector('input[type="search"]');
3218
+ if (input && typeof input.focus === 'function') input.focus();
3219
+ }
3220
+ };
3221
+ window.addEventListener('keydown', onKeyDown);
3222
+ return () => window.removeEventListener('keydown', onKeyDown);
3223
+ }, []);
3224
+
3225
+ useEffect(() => {
3226
+ let mounted = true;
3227
+ fetchJson(`${config.apiBasePath}/support`)
3228
+ .then((payload) => {
3229
+ if (!mounted) return;
3230
+ setSupportInfo(payload?.data || null);
3231
+ })
3232
+ .catch(() => {
3233
+ if (!mounted) return;
3234
+ setSupportInfo(null);
3235
+ });
3236
+ return () => {
3237
+ mounted = false;
3238
+ };
3239
+ }, [config.apiBasePath]);
3240
+
3241
+ useEffect(() => {
3242
+ if (currentView !== 'catalog' || currentPackKey) return;
3243
+ const nextUrl = buildCatalogWebUrl();
3244
+ const currentUrl = `${window.location.pathname}${window.location.search}`;
3245
+ if (nextUrl !== currentUrl) {
3246
+ window.history.replaceState({}, '', nextUrl);
3247
+ }
3248
+ }, [currentView, currentPackKey, appliedQuery, activeCategory, sortBy, catalogFilter, catalogPage, config.webPath]);
3249
+
3250
+ useEffect(() => {
3251
+ if (currentView !== 'creators') return;
3252
+ const nextUrl = buildCreatorsWebUrl();
3253
+ const currentUrl = `${window.location.pathname}${window.location.search}`;
3254
+ if (nextUrl !== currentUrl) {
3255
+ window.history.replaceState({}, '', nextUrl);
3256
+ }
3257
+ }, [currentView, creatorSort, config.webPath]);
3258
+
3259
+ useEffect(() => {
3260
+ if (currentView === 'pack' && currentPackKey) {
3261
+ void loadPackDetail(currentPackKey);
3262
+ return;
3263
+ }
3264
+ if (currentView === 'creators') {
3265
+ void loadCreatorRanking();
3266
+ return;
3267
+ }
3268
+ if (currentView !== 'catalog') return;
3269
+ void loadPacks({ page: catalogPage });
3270
+ }, [appliedQuery, activeCategory, sortBy, catalogFilter, currentView, currentPackKey, creatorSort, catalogPage]);
3271
+
3272
+ useEffect(() => {
3273
+ if (currentView !== 'catalog' || currentPackKey) return undefined;
3274
+ const timer = setInterval(() => {
3275
+ void loadPacks({ page: catalogPage });
3276
+ }, 60 * 1000);
3277
+ return () => clearInterval(timer);
3278
+ }, [currentView, currentPackKey, appliedQuery, activeCategory, sortBy, catalogFilter, catalogPage]);
3279
+
3280
+ useEffect(() => {
3281
+ const sync = () => setUploadTask(readUploadTask());
3282
+ sync();
3283
+
3284
+ const interval = setInterval(sync, 1000);
3285
+ const onStorage = (event) => {
3286
+ if (event?.key === PACK_UPLOAD_TASK_KEY) {
3287
+ sync();
3288
+ }
3289
+ };
3290
+ window.addEventListener('storage', onStorage);
3291
+ return () => {
3292
+ clearInterval(interval);
3293
+ window.removeEventListener('storage', onStorage);
3294
+ };
3295
+ }, []);
3296
+
3297
+ useEffect(() => {
3298
+ if (!isProfileView) {
3299
+ googlePromptAttemptedRef.current = false;
3300
+ return;
3301
+ }
3302
+ void refreshMyProfile();
3303
+ }, [isProfileView, myProfileApiPath]);
3304
+
3305
+ useEffect(() => {
3306
+ const clearGoogleButton = () => {
3307
+ if (googleButtonRef.current) googleButtonRef.current.innerHTML = '';
3308
+ try {
3309
+ window.google?.accounts?.id?.cancel?.();
3310
+ } catch {
3311
+ // ignore sdk cleanup errors
3312
+ }
3313
+ };
3314
+
3315
+ if (!shouldRenderGoogleButton) {
3316
+ clearGoogleButton();
3317
+ return;
3318
+ }
3319
+ if (!googleButtonRef.current) return;
3320
+
3321
+ let cancelled = false;
3322
+ setGoogleAuthError('');
3323
+
3324
+ loadScript(GOOGLE_GSI_SCRIPT_SRC)
3325
+ .then(() => {
3326
+ if (cancelled) return;
3327
+ const accounts = window.google?.accounts?.id;
3328
+ if (!accounts) throw new Error('SDK do Google não disponível.');
3329
+
3330
+ accounts.initialize({
3331
+ client_id: googleAuthConfig.clientId,
3332
+ callback: (response) => {
3333
+ const credential = String(response?.credential || '').trim();
3334
+ const claims = decodeJwtPayload(credential);
3335
+ if (!credential || !claims?.sub) {
3336
+ setGoogleAuthError('Falha ao concluir login Google.');
3337
+ return;
3338
+ }
3339
+ setGoogleAuthBusy(true);
3340
+ fetchJson(googleSessionApiPath, {
3341
+ method: 'POST',
3342
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
3343
+ body: JSON.stringify({ google_id_token: credential }),
3344
+ })
3345
+ .then(async () => {
3346
+ googlePromptAttemptedRef.current = true;
3347
+ const profilePayload = await fetchJson(myProfileApiPath);
3348
+ applyMyProfileData(profilePayload);
3349
+ setMyPacksError('');
3350
+ })
3351
+ .catch((sessionError) => {
3352
+ setGoogleAuthError(sessionError?.message || 'Falha ao salvar sessão Google.');
3353
+ })
3354
+ .finally(() => setGoogleAuthBusy(false));
3355
+ },
3356
+ auto_select: false,
3357
+ cancel_on_tap_outside: true,
3358
+ });
3359
+
3360
+ if (googleButtonRef.current) {
3361
+ googleButtonRef.current.innerHTML = '';
3362
+ const measuredWidth = Math.floor(Number(googleButtonRef.current.clientWidth || 0));
3363
+ const buttonWidth = Math.max(180, Math.min(320, measuredWidth || 320));
3364
+ accounts.renderButton(googleButtonRef.current, {
3365
+ type: 'standard',
3366
+ theme: 'filled_black',
3367
+ size: 'large',
3368
+ text: 'signin_with',
3369
+ shape: 'pill',
3370
+ logo_alignment: 'left',
3371
+ width: buttonWidth,
3372
+ });
3373
+ }
3374
+
3375
+ if (!googlePromptAttemptedRef.current) {
3376
+ googlePromptAttemptedRef.current = true;
3377
+ try {
3378
+ accounts.prompt(() => {});
3379
+ } catch {
3380
+ // prompt may be blocked by browser/privacy settings
3381
+ }
3382
+ }
3383
+ })
3384
+ .catch((sdkError) => {
3385
+ if (cancelled) return;
3386
+ setGoogleAuthError(sdkError?.message || 'Falha ao carregar login Google.');
3387
+ });
3388
+
3389
+ return () => {
3390
+ cancelled = true;
3391
+ clearGoogleButton();
3392
+ };
3393
+ }, [shouldRenderGoogleButton, googleAuthConfig.clientId, googleSessionApiPath, myProfileApiPath]);
3394
+
3395
+ const handleGoogleLogout = async () => {
3396
+ setGoogleAuthBusy(true);
3397
+ setGoogleAuthError('');
3398
+ setGoogleAuth({ user: null, expiresAt: '' });
3399
+ clearGoogleAuthCache();
3400
+ try {
3401
+ await fetchJson(googleSessionApiPath, { method: 'DELETE' });
3402
+ } catch {
3403
+ // still refresh local state after logout attempts
3404
+ } finally {
3405
+ setGoogleAuthBusy(false);
3406
+ }
3407
+ googlePromptAttemptedRef.current = false;
3408
+ await refreshMyProfile({ silent: false });
3409
+ };
3410
+
3411
+ const onSubmit = (event) => {
3412
+ event.preventDefault();
3413
+ setShowAutocomplete(false);
3414
+ const next = query.trim();
3415
+ setCatalogFilter('');
3416
+ setCatalogPage(FIRST_CATALOG_PAGE);
3417
+ setAppliedQuery(next);
3418
+ if (next) {
3419
+ const nextHistory = [next, ...recentSearches.filter((entry) => entry !== next)].slice(0, 8);
3420
+ setRecentSearches(nextHistory);
3421
+ try {
3422
+ localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(nextHistory));
3423
+ } catch {
3424
+ // ignore storage quota/security errors
3425
+ }
3426
+ }
3427
+ };
3428
+
3429
+ const applySuggestion = (item) => {
3430
+ const value = String(item?.value || '').trim();
3431
+ if (!value) return;
3432
+ setQuery(value);
3433
+ setCatalogFilter('');
3434
+ setCatalogPage(FIRST_CATALOG_PAGE);
3435
+ setAppliedQuery(value);
3436
+ if (dynamicCategoryOptions.some((entry) => entry.value === value)) {
3437
+ setActiveCategory(value);
3438
+ }
3439
+ setShowAutocomplete(false);
3440
+ };
3441
+
3442
+ const clearFilters = () => {
3443
+ setQuery('');
3444
+ setAppliedQuery('');
3445
+ setActiveCategory('');
3446
+ setCatalogFilter('');
3447
+ setCatalogPage(FIRST_CATALOG_PAGE);
3448
+ };
3449
+
3450
+ return html`
3451
+ <div className="min-h-screen bg-slate-950 text-slate-100">
3452
+ <style>
3453
+ ${`@keyframes fadeInCard { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
3454
+ .fade-card { animation: fadeInCard 260ms ease both; }
3455
+ .line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
3456
+ .chips-scroll {
3457
+ scroll-snap-type: x mandatory;
3458
+ -webkit-overflow-scrolling: touch;
3459
+ scroll-behavior: smooth;
3460
+ scrollbar-width: none;
3461
+ overscroll-behavior-x: contain;
3462
+ touch-action: pan-x;
3463
+ }
3464
+ .chips-scroll::-webkit-scrollbar { display: none; }
3465
+ .chip-item { scroll-snap-align: start; touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
3466
+ .pack-stickers-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
3467
+ @media (min-width: 1024px) {
3468
+ .pack-stickers-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
3469
+ }
3470
+ @media (prefers-reduced-motion: reduce) {
3471
+ .fade-card { animation: none; }
3472
+ }`}
3473
+ </style>
3474
+
3475
+ <header className=${`sticky top-0 z-30 border-b border-slate-800 bg-slate-950/95 backdrop-blur transition-shadow ${isScrolled ? 'shadow-[0_8px_24px_rgba(2,6,23,0.45)]' : ''}`}>
3476
+ <div className="max-w-7xl mx-auto h-14 px-3 flex items-center gap-2.5">
3477
+ <a href="/" className="shrink-0 flex items-center gap-2">
3478
+ <img src=${OMNIZAP_LOGO_DATA_URL} alt="OmniZap" className="w-7 h-7 rounded-full border border-slate-700" decoding="async" />
3479
+ <span className="hidden sm:inline text-sm font-semibold">OmniZap</span>
3480
+ </a>
3481
+
3482
+ ${currentView === 'catalog'
3483
+ ? html`
3484
+ <form onSubmit=${onSubmit} className="flex-1 relative">
3485
+ <span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-slate-400">🔎</span>
3486
+ <input
3487
+ type="search"
3488
+ value=${query}
3489
+ onChange=${(e) => setQuery(e.target.value)}
3490
+ onFocus=${() => setShowAutocomplete(true)}
3491
+ onBlur=${() => setTimeout(() => setShowAutocomplete(false), 120)}
3492
+ onKeyDown=${(event) => {
3493
+ if (event.key === 'Escape') {
3494
+ setShowAutocomplete(false);
3495
+ }
3496
+ }}
3497
+ placeholder="Buscar packs..."
3498
+ className="w-full h-9 sm:h-10 rounded-2xl border border-slate-800 bg-slate-900 pl-[34px] sm:pl-9 pr-3 text-sm text-slate-100 placeholder:text-slate-500 outline-none transition focus:border-emerald-400/50 focus:ring-2 focus:ring-emerald-400/15"
3499
+ />
3500
+ ${showAutocomplete && filteredSuggestions.length
3501
+ ? html`
3502
+ <div className="absolute z-40 mt-2 w-full rounded-2xl border border-slate-800 bg-slate-900 shadow-xl overflow-hidden">
3503
+ ${filteredSuggestions.map(
3504
+ (item) => html`
3505
+ <button key=${item.value} type="button" onClick=${() => applySuggestion(item)} className="w-full px-3 py-2.5 text-left text-sm text-slate-200 hover:bg-slate-800 flex items-center justify-between gap-2 border-b border-slate-800 last:border-b-0">
3506
+ <span className="inline-flex items-center gap-2">
3507
+ <span>${item.icon || '🏷'}</span>
3508
+ <span className="truncate">${item.label}</span>
3509
+ </span>
3510
+ <span className="text-xs text-slate-400 truncate">${item.value}</span>
3511
+ </button>
3512
+ `,
3513
+ )}
3514
+ </div>
3515
+ `
3516
+ : null}
3517
+ </form>
3518
+ `
3519
+ : html`<div className="flex-1"></div>`}
3520
+
3521
+ <div className="flex items-center gap-2">
3522
+ <button type="button" className=${`text-xs rounded-lg border px-3 py-2 ${isProfileView ? 'border-amber-400/40 bg-amber-400/10 text-amber-200' : 'border-amber-500/30 bg-amber-500/5 text-amber-200 hover:bg-amber-500/10'}`} onClick=${() => openProfile(true)} title="Meu perfil e packs">
3523
+ <span className="sm:hidden">👤</span>
3524
+ <span className="hidden sm:inline">Meus Packs</span>
3525
+ </button>
3526
+ <a className="text-xs rounded-lg border border-cyan-500/40 bg-cyan-500/10 px-3 py-2 text-cyan-200 hover:bg-cyan-500/20" href="/stickers/create/" title="Criar pack">
3527
+ <span className="sm:hidden">➕</span>
3528
+ <span className="hidden sm:inline">✨ Criar pack agora</span>
3529
+ </a>
3530
+ ${supportInfo?.url
3531
+ ? html`
3532
+ <a className="text-xs rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-emerald-200 hover:bg-emerald-500/20" href=${supportInfo.url} target="_blank" rel="noreferrer noopener" title="Suporte no WhatsApp">
3533
+ <span className="sm:hidden">💬</span>
3534
+ <span className="hidden sm:inline">Suporte</span>
3535
+ </a>
3536
+ `
3537
+ : null}
3538
+ <div className="hidden sm:flex items-center gap-2">
3539
+ <a className="text-xs rounded-lg border border-slate-700 px-3 py-2 text-slate-300 hover:bg-slate-800" href="/api-docs/">API</a>
3540
+ <a className="text-xs rounded-lg border border-slate-700 px-3 py-2 text-slate-300 hover:bg-slate-800" href="https://github.com/Omnizap-System/omnizap" target="_blank" rel="noreferrer noopener">GitHub</a>
3541
+ </div>
3542
+ </div>
3543
+ </div>
3544
+ </header>
3545
+
3546
+ <main className="max-w-7xl mx-auto px-3 py-2.5 sm:py-3 space-y-3 pb-[calc(1rem+env(safe-area-inset-bottom))]">
3547
+ ${error ? html`<div className="rounded-2xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">${error}</div>` : null}
3548
+ ${isProfileView
3549
+ ? html` <${CreatorProfileDashboard} googleAuthConfig=${googleAuthConfig} googleAuth=${googleAuth} googleAuthBusy=${googleAuthBusy} googleAuthError=${googleAuthError} googleSessionChecked=${googleSessionChecked} myPacks=${myPacks} myPacksLoading=${myPacksLoading} myPacksError=${myPacksError} myProfileStats=${myProfileStats} onBack=${goCatalog} onRefresh=${() => refreshMyProfile()} onLogout=${handleGoogleLogout} onOpenPublicPack=${openPack} onOpenPackActions=${openPackActionsSheet} onOpenManagePack=${(pack) => openManagePackByKey(pack?.pack_key || '')} onRequestDeletePack=${requestDeletePack} packActionBusyByKey=${packActionBusyByKey} /> `
3550
+ : isCreatorsView
3551
+ ? html` <${CreatorsRankingPage} creators=${sortedCreatorRanking} loading=${creatorRankingLoading} error=${creatorRankingError} sort=${creatorSort} onSortChange=${handleCreatorsSortChange} onBack=${goCatalog} onRetry=${loadCreatorRanking} onOpenCreator=${openCreatorProfileFromRanking} onOpenPack=${openPack} /> `
3552
+ : currentPackKey
3553
+ ? html` ${packLoading ? html`<${PackPageSkeleton} />` : html`<${PackPage} pack=${currentPack} relatedPacks=${relatedPacks} relatedLoading=${relatedPacksLoading} onLoadRelated=${requestRelatedPacksForCurrentPack} onBack=${goCatalog} onOpenRelated=${openPack} onLike=${handleLike} onDislike=${handleDislike} onTagClick=${openCatalogTagFilter} reactionLoading=${reactionLoading} reactionNotice=${reactionNotice} hasNsfwAccess=${hasNsfwAccess} onRequireLogin=${requestNsfwUnlock} />`} `
3554
+ : html`
3555
+ <div className="lg:grid lg:grid-cols-[220px_minmax(0,1fr)] lg:gap-4">
3556
+ <aside className="hidden lg:block">
3557
+ <div className="sticky top-[72px] space-y-2.5 rounded-2xl border border-slate-800 bg-slate-900/80 p-2.5">
3558
+ <div className="rounded-xl border border-slate-800 bg-slate-950/40 p-2">
3559
+ <div className="flex items-center justify-between gap-2">
3560
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-slate-300">Filtros</h3>
3561
+ <button type="button" onClick=${clearFilters} className="h-8 rounded-lg border border-slate-700 px-2 text-[11px] text-slate-200 hover:bg-slate-800">Limpar</button>
3562
+ </div>
3563
+ <p className="mt-1 text-[11px] text-slate-500">${packs.length} packs nesta página</p>
3564
+ </div>
3565
+
3566
+ <details open className="rounded-xl border border-slate-800 bg-slate-950/40 p-2">
3567
+ <summary className="cursor-pointer list-none text-xs font-semibold text-slate-200">Ordenar catálogo</summary>
3568
+ <div className="mt-2 space-y-1.5">${CATALOG_SORT_OPTIONS.map((option) => html` <button key=${option.value} type="button" onClick=${() => handleCatalogSortSelection(option.value)} disabled=${sortPickerBusy} className=${`w-full h-9 rounded-xl border text-xs disabled:opacity-60 ${normalizeCatalogSort(sortBy) === option.value ? 'border-emerald-400 bg-emerald-400/10 text-emerald-200' : 'border-slate-700 text-slate-300 hover:bg-slate-800'}`}>${option.icon} ${option.label}</button> `)}</div>
3569
+ </details>
3570
+
3571
+ ${supportInfo?.url ? html` <a href=${supportInfo.url} target="_blank" rel="noreferrer noopener" className="w-full h-9 inline-flex items-center justify-center rounded-xl border border-emerald-500/35 bg-emerald-500/10 text-xs text-emerald-200 hover:bg-emerald-500/20"> 💬 Suporte no WhatsApp </a> ` : null}
3572
+ </div>
3573
+ </aside>
3574
+
3575
+ <div className="space-y-3 min-w-0">
3576
+ <section className="space-y-2 min-w-0">
3577
+ <div className="relative min-w-0">
3578
+ <div className="absolute left-0 top-0 bottom-0 w-5 bg-gradient-to-r from-slate-950 to-transparent pointer-events-none z-10"></div>
3579
+ <div className="absolute right-0 top-0 bottom-0 w-5 bg-gradient-to-l from-slate-950 to-transparent pointer-events-none z-10"></div>
3580
+ <div className="chips-scroll flex max-w-full gap-1.5 overflow-x-auto pb-1 pr-1">${dynamicCategoryOptions.map((item) => html` <button key=${item.value || 'all'} type="button" onClick=${() => handleCategoryChipPress(item.value)} className=${`chip-item h-8 whitespace-nowrap rounded-full px-3 text-[11px] border transition ${activeCategory === item.value ? 'bg-emerald-400 text-slate-900 border-emerald-300 font-semibold shadow-[0_0_0_2px_rgba(16,185,129,0.18)]' : 'bg-slate-900 text-slate-300 border-slate-800 hover:bg-slate-800'}`}>${item.label}</button> `)}</div>
3581
+ </div>
3582
+ </section>
3583
+
3584
+ ${packs.length
3585
+ ? html`
3586
+ <section className="space-y-2">
3587
+ <div className="rounded-2xl border border-slate-800 bg-slate-900/70 p-2.5">
3588
+ <div className="flex flex-wrap items-center justify-between gap-2">
3589
+ <div>
3590
+ <p className="text-[11px] uppercase tracking-wide text-slate-400">Descobrir</p>
3591
+ <h3 className="text-sm font-semibold text-slate-100">Painel oficial do marketplace</h3>
3592
+ </div>
3593
+ </div>
3594
+
3595
+ <div className="mt-2 flex flex-wrap gap-1.5">
3596
+ ${[
3597
+ { key: 'growing', label: '🔥 Crescendo' },
3598
+ { key: 'top', label: '🏆 Top 10' },
3599
+ { key: 'creators', label: '⭐ Criadores' },
3600
+ ].map((tab) => html` <button key=${tab.key} type="button" onClick=${() => setDiscoverTab(tab.key)} className=${`h-8 rounded-full border px-2.5 text-[11px] touch-manipulation ${discoverTab === tab.key ? 'border-cyan-400/35 bg-cyan-500/10 text-cyan-100' : 'border-slate-700 bg-slate-950/40 text-slate-300 hover:bg-slate-800'}`}>${tab.label}</button> `)}
3601
+ </div>
3602
+
3603
+ <div className="mt-2 hidden lg:block">
3604
+ ${discoverTab === 'growing'
3605
+ ? html` <div className="grid grid-cols-1 xl:grid-cols-2 gap-2">${growingNowPacks.slice(0, DESKTOP_DISCOVER_GROWING_LIMIT).map((entry) => html`<${DiscoverPackRowItem} key=${`grow-${entry.pack_key}`} pack=${entry} onOpen=${openPack} hasNsfwAccess=${hasNsfwAccess} onRequireLogin=${requestNsfwUnlock} />`)}</div> `
3606
+ : discoverTab === 'top'
3607
+ ? html` <div className="grid grid-cols-1 xl:grid-cols-2 gap-2">${topWeekPacks.slice(0, DESKTOP_DISCOVER_TOP_LIMIT).map((entry, idx) => html`<${DiscoverPackRowItem} key=${`top-${entry.pack_key}`} pack=${entry} onOpen=${openPack} rank=${idx + 1} hasNsfwAccess=${hasNsfwAccess} onRequireLogin=${requestNsfwUnlock} />`)}</div> `
3608
+ : html`
3609
+ <div className="grid grid-cols-1 xl:grid-cols-2 gap-2">
3610
+ ${featuredCreators.map(
3611
+ (creator) => html`
3612
+ <button
3613
+ key=${creator.publisher}
3614
+ onClick=${() =>
3615
+ openCatalogWithState({
3616
+ q: creator.publisher,
3617
+ category: '',
3618
+ sort: DEFAULT_CATALOG_SORT,
3619
+ filter: '',
3620
+ push: true,
3621
+ })}
3622
+ className="w-full flex items-center gap-2 rounded-xl border border-slate-800 bg-slate-900/50 px-2 py-1.5 text-left hover:bg-slate-800/90"
3623
+ >
3624
+ <img src=${getAvatarUrl(creator.publisher)} alt="" className="w-9 h-9 rounded-full bg-slate-800" />
3625
+ <span className="min-w-0 flex-1">
3626
+ <span className="block truncate text-xs font-medium text-slate-100">${creator.publisher}</span>
3627
+ <span className="block truncate text-[10px] text-slate-400">${creator.packCount} packs · ❤️ ${shortNum(creator.likes)} · ⬇ ${shortNum(creator.opens)}</span>
3628
+ </span>
3629
+ <span className="text-[10px] text-slate-500">filtrar</span>
3630
+ </button>
3631
+ `,
3632
+ )}
3633
+ </div>
3634
+ `}
3635
+ </div>
3636
+
3637
+ <div className="mt-2 lg:hidden">
3638
+ ${discoverTab === 'growing'
3639
+ ? html`
3640
+ <div className="space-y-2">
3641
+ <section className="space-y-1.5">
3642
+ <div className="flex items-center justify-between">
3643
+ <h4 className="text-xs font-semibold text-slate-200">🔥 Em alta agora</h4>
3644
+ <button type="button" onClick=${openTrendingCatalog} className="inline-flex h-7 items-center gap-1 rounded-full border border-cyan-400/35 bg-cyan-500/10 px-2.5 text-[10px] font-semibold text-cyan-100 transition hover:bg-cyan-500/20 active:scale-[0.98]">
3645
+ <span>ver lista</span>
3646
+ <span aria-hidden="true">↗</span>
3647
+ </button>
3648
+ </div>
3649
+ <div className="flex gap-2 overflow-x-auto pb-1">${growingNowPacks.slice(0, MOBILE_DISCOVER_CAROUSEL_LIMIT).map((entry) => html`<${DiscoverPackMiniCard} key=${`mobile-grow-${entry.pack_key}`} pack=${entry} onOpen=${openPack} hasNsfwAccess=${hasNsfwAccess} onRequireLogin=${requestNsfwUnlock} />`)}</div>
3650
+ </section>
3651
+ <section className="space-y-1.5">
3652
+ <div className="flex items-center justify-between">
3653
+ <h4 className="text-xs font-semibold text-slate-200">🆕 Recém publicados</h4>
3654
+ <button type="button" onClick=${openSortPicker} disabled=${sortPickerBusy} className="inline-flex h-7 items-center gap-1 rounded-full border border-emerald-400/35 bg-emerald-500/10 px-2.5 text-[10px] font-semibold text-emerald-100 transition hover:bg-emerald-500/20 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100">
3655
+ <span>ordenar</span>
3656
+ <span aria-hidden="true">⇅</span>
3657
+ </button>
3658
+ </div>
3659
+ <div className="flex gap-2 overflow-x-auto pb-1">${recentPublishedPacks.slice(0, MOBILE_DISCOVER_CAROUSEL_LIMIT).map((entry) => html`<${DiscoverPackMiniCard} key=${`mobile-new-${entry.pack_key}`} pack=${entry} onOpen=${openPack} hasNsfwAccess=${hasNsfwAccess} onRequireLogin=${requestNsfwUnlock} />`)}</div>
3660
+ </section>
3661
+ </div>
3662
+ `
3663
+ : discoverTab === 'top'
3664
+ ? html`
3665
+ <section className="space-y-1.5">
3666
+ <div className="flex items-center justify-between">
3667
+ <h4 className="text-xs font-semibold text-slate-200">🏆 Top 10 da semana</h4>
3668
+ <button
3669
+ type="button"
3670
+ onClick=${() =>
3671
+ openCatalogWithState({
3672
+ q: '',
3673
+ category: '',
3674
+ sort: 'trending',
3675
+ filter: '',
3676
+ push: true,
3677
+ })}
3678
+ className="text-[10px] text-cyan-300"
3679
+ >
3680
+ ver lista
3681
+ </button>
3682
+ </div>
3683
+ <div className="flex gap-2 overflow-x-auto pb-1">${topWeekPacks.slice(0, MOBILE_DISCOVER_CAROUSEL_LIMIT).map((entry) => html`<${DiscoverPackMiniCard} key=${`mobile-top-${entry.pack_key}`} pack=${entry} onOpen=${openPack} hasNsfwAccess=${hasNsfwAccess} onRequireLogin=${requestNsfwUnlock} />`)}</div>
3684
+ </section>
3685
+ `
3686
+ : html`
3687
+ <section className="space-y-1.5">
3688
+ <div className="flex items-center justify-between">
3689
+ <h4 className="text-xs font-semibold text-slate-200">👑 Criadores populares</h4>
3690
+ <button type="button" onClick=${() => openCreatorsRanking('popular', true)} className="text-[10px] text-cyan-300">ver lista</button>
3691
+ </div>
3692
+ <div className="flex gap-2 overflow-x-auto pb-1">
3693
+ ${featuredCreators.map(
3694
+ (creator) => html`
3695
+ <${DiscoverCreatorMiniCard}
3696
+ key=${`mobile-tab-creator-${creator.publisher}`}
3697
+ creator=${creator}
3698
+ onPick=${(publisher) =>
3699
+ openCatalogWithState({
3700
+ q: publisher,
3701
+ category: '',
3702
+ sort: DEFAULT_CATALOG_SORT,
3703
+ filter: '',
3704
+ push: true,
3705
+ })}
3706
+ />
3707
+ `,
3708
+ )}
3709
+ </div>
3710
+ </section>
3711
+ `}
3712
+ </div>
3713
+ </div>
3714
+
3715
+ <div className="hidden lg:block rounded-2xl border border-emerald-500/20 bg-gradient-to-r from-emerald-500/10 to-cyan-500/5 p-2.5">
3716
+ <div className="flex items-center justify-between gap-3">
3717
+ <div>
3718
+ <p className="text-xs font-semibold text-emerald-100">Quer aparecer em destaque?</p>
3719
+ <p className="text-[11px] text-slate-300">Publique seu pack e melhore capa/tags para ganhar mais cliques.</p>
3720
+ </div>
3721
+ <a href="/stickers/create/" className="inline-flex h-8 items-center rounded-lg border border-emerald-400/35 bg-emerald-500/10 px-3 text-[11px] font-semibold text-emerald-100 hover:bg-emerald-500/20"> Publicar pack </a>
3722
+ </div>
3723
+ </div>
3724
+ </section>
3725
+ `
3726
+ : null}
3727
+ ${packs.length
3728
+ ? html`
3729
+ <section id="catalog-packs-section" className="space-y-3 min-w-0">
3730
+ <div className="flex items-end justify-between gap-3">
3731
+ <div>
3732
+ <h2 className="text-lg sm:text-xl font-bold">Packs</h2>
3733
+ <p className="text-xs text-slate-400">Página ${catalogPage} · ${sortedPacks.length} resultados · ${categoryActiveLabel}</p>
3734
+ </div>
3735
+ <div className="hidden md:flex items-center gap-2">
3736
+ <span className="text-xs text-slate-400">Ordenar por</span>
3737
+ <button type="button" onClick=${openSortPicker} disabled=${sortPickerBusy} className="inline-flex h-8 items-center gap-2 rounded-xl border border-slate-700 bg-slate-900 px-3 text-xs text-slate-200 hover:bg-slate-800 disabled:opacity-60">
3738
+ <span>${catalogSortLabel(sortBy)}</span>
3739
+ <span className="text-[10px] text-slate-400">▾</span>
3740
+ </button>
3741
+ </div>
3742
+ </div>
3743
+ <div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-800 bg-slate-900/60 px-3 py-2">
3744
+ <span className="text-xs text-slate-400">Página ${catalogPage}${packHasMore ? ' · há mais resultados' : ' · fim da lista'}</span>
3745
+ <div className="flex items-center gap-2">
3746
+ <button type="button" onClick=${() => goToCatalogPage(catalogPage - 1, { push: true })} disabled=${!canGoCatalogPrev} className="inline-flex h-8 items-center rounded-lg border border-slate-700 px-3 text-xs text-slate-200 hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50">Anterior</button>
3747
+ <button type="button" onClick=${() => goToCatalogPage(catalogPage + 1, { push: true })} disabled=${!canGoCatalogNext} className="inline-flex h-8 items-center rounded-lg border border-slate-700 px-3 text-xs text-slate-200 hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50">Próxima</button>
3748
+ </div>
3749
+ </div>
3750
+ <div className="grid min-w-0 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-2.5 sm:gap-3">
3751
+ ${sortedPacks.map(
3752
+ (pack, index) =>
3753
+ html`<div key=${pack.pack_key || pack.id} className="fade-card">
3754
+ <${PackCard} pack=${pack} index=${index} onOpen=${openPack} hasNsfwAccess=${hasNsfwAccess} onRequireLogin=${requestNsfwUnlock} />
3755
+ </div>`,
3756
+ )}
3757
+ </div>
3758
+ <div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-800 bg-slate-900/60 px-3 py-2">
3759
+ <span className="text-xs text-slate-400">Página ${catalogPage}${packHasMore ? ' · há mais resultados' : ' · fim da lista'}</span>
3760
+ <div className="flex items-center gap-2">
3761
+ <button type="button" onClick=${() => goToCatalogPage(catalogPage - 1, { push: true })} disabled=${!canGoCatalogPrev} className="inline-flex h-8 items-center rounded-lg border border-slate-700 px-3 text-xs text-slate-200 hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50">Anterior</button>
3762
+ <button type="button" onClick=${() => goToCatalogPage(catalogPage + 1, { push: true })} disabled=${!canGoCatalogNext} className="inline-flex h-8 items-center rounded-lg border border-slate-700 px-3 text-xs text-slate-200 hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50">Próxima</button>
3763
+ </div>
3764
+ </div>
3765
+ </section>
3766
+ `
3767
+ : null}
3768
+ ${packsLoading ? html`<${SkeletonGrid} count=${10} />` : null} ${!packsLoading && !hasAnyResult ? html`<${EmptyState} onClear=${clearFilters} />` : null}
3769
+ </div>
3770
+ </div>
3771
+ `}
3772
+ </main>
3773
+ <${UploadTaskWidget}
3774
+ task=${uploadTask}
3775
+ onClose=${() => {
3776
+ try {
3777
+ localStorage.removeItem(PACK_UPLOAD_TASK_KEY);
3778
+ } catch {
3779
+ // ignore storage cleanup errors
3780
+ }
3781
+ setUploadTask(null);
3782
+ }}
3783
+ />
3784
+ <${CatalogSortPicker} open=${sortPickerOpen} currentSort=${sortBy} busy=${sortPickerBusy} onClose=${closeSortPicker} onSelect=${handleCatalogSortSelection} />
3785
+ <${PackActionsSheet} open=${Boolean(packActionsSheetPack)} pack=${packActionsSheetPack} busyAction=${packActionsSheetPack ? packActionBusyByKey?.[packActionsSheetPack.pack_key] || '' : ''} onClose=${closePackActionsSheet} onAction=${handlePackActionsSheetAction} />
3786
+ <${PackManagerModal} open=${managePackOpen} data=${managePackData} loading=${managePackLoading} error=${managePackError} busyAction=${managePackBusyAction} onClose=${closeManagePackModal} onRefresh=${refreshManagePackData} onSaveMetadata=${handleManageSaveMetadata} onAddSticker=${handleManageAddSticker} onRemoveSticker=${handleManageRemoveSticker} onReplaceSticker=${handleManageReplaceSticker} onSetCover=${handleManageSetCover} onReorder=${handleManageReorder} onOpenAnalytics=${() => openAnalyticsModalForPack(managePackData?.pack || null)} />
3787
+ <${PackAnalyticsModal} open=${analyticsModalOpen} pack=${analyticsModalPack} data=${analyticsModalData} loading=${analyticsModalLoading} error=${analyticsModalError} onClose=${closeAnalyticsModal} />
3788
+ <${ConfirmDialog} open=${Boolean(confirmDeletePack)} title="Apagar pack" message=${confirmDeletePack ? `Tem certeza que deseja apagar o pack "${confirmDeletePack.name || confirmDeletePack.pack_key}"? Essa ação remove o pack do seu painel.` : ''} confirmLabel="Apagar pack" cancelLabel="Cancelar" danger=${true} busy=${confirmDeleteBusy} onCancel=${() => (confirmDeleteBusy ? null : setConfirmDeletePack(null))} onConfirm=${handleDeletePackConfirmed} />
3789
+ <${ToastStack} toasts=${profileToasts} onDismiss=${dismissProfileToast} />
3790
+ </div>
3791
+ `;
3792
+ }
3793
+
3794
+ const rootEl = document.getElementById('stickers-react-root');
3795
+ if (rootEl) {
3796
+ createRoot(rootEl).render(html`<${StickersApp} />`);
3797
+ }